diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 70c1e910..3941c04a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -82,9 +82,9 @@ if ! ./gradlew --quiet help >/dev/null 2>&1; then echo "Run ./gradlew help to see why. This is not a formatting or detekt failure." >&2 exit 1 fi -if ! ./gradlew --quiet spotlessCheck :app:detekt; then +if ! ./gradlew --quiet spotlessCheck :app:detekt :shared:detekt; then echo >&2 echo "pre-commit: checks failed. Fix formatting with ./gradlew spotlessApply, fix the" >&2 - echo "detekt findings (do not add them to app/detekt-baseline.xml), then commit again." >&2 + echo "detekt findings (do not add them to a detekt-baseline.xml), then commit again." >&2 exit 1 fi diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d50592b6..0f895866 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,6 +18,6 @@ - [ ] No new path that puts plaintext outside the vault (clipboard, share, notifications, files, logs). - [ ] If key derivation, the SQLCipher passphrase, the vault slots or any on-disk format changed: existing installs still open (migration added and tested), and SECURITY.md is updated. -- [ ] If the Room schema changed: `DB_VERSION` bumped, a migration added, the new `app/schemas` +- [ ] If the Room schema changed: `DB_VERSION` bumped, a migration added, the new `core/database/schemas` export committed, and `AppDatabaseSchemaTest` extended. - [ ] User-visible changes are listed under `[Unreleased]` in CHANGELOG.md. diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b7c5c6aa..6cd1a534 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -27,7 +27,11 @@ permissions: contents: read jobs: + # The checks run as three parallel jobs (app, libraries, release build) rather than one long Gradle + # invocation; fdroid flavor only, the play flavor differs only in store flags and is built by the + # release workflow. The instrumented tests run in the `instrumented` job below. build: + name: app (format, unit tests, coverage, detekt) # Dependabot bumps (build(deps) / ci(deps)) don't run CI on their own PR: they are merged in # batches and checked on the branch that takes them, or on main after the merge. if: github.actor != 'dependabot[bot]' @@ -40,7 +44,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/wrapper-validation@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Set up JDK 21 uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 @@ -49,24 +53,428 @@ jobs: java-version: '21' - name: Set up Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Grant execute permission for gradlew run: chmod +x ./gradlew - # One Gradle invocation, fdroid flavor only, to keep Actions minutes low. The play flavor - # differs only in store flags and is built by the release workflow; instrumented tests - # (Room migrations, SQLCipher) run locally on an emulator: see CONTRIBUTING.md. - - name: Format, unit tests, detekt, release build + - name: Format, unit tests, coverage, detekt run: >- ./gradlew spotlessCheck :app:testFdroidDebugUnitTest :app:koverXmlReportFdroidDebug :app:koverVerifyFdroidDebug :app:detekt - :app:assembleFdroidRelease - # Separate invocation: lint running alongside Hilt code generation for another variant has + # For information only: posts (and keeps updating) one PR comment with the app + # coverage. It never fails the build. + - name: Coverage comment + if: github.event_name == 'pull_request' + continue-on-error: true + uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0 + with: + paths: app/build/reports/kover/reportFdroidDebug.xml + token: ${{ secrets.GITHUB_TOKEN }} + title: 'Coverage' + update-comment: true + min-coverage-overall: 0 + min-coverage-changed-files: 0 + + - name: Upload reports on failure + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: reports-app + path: | + app/build/reports/ + app/build/test-results/ + retention-days: 7 + if-no-files-found: ignore + + # The Kotlin Multiplatform libraries on their desktop JVM and Android host targets: unit tests, the + # Kover gates, detekt and the public API dump (and Dokka for :editor). The database's desktop tests + # open and migrate an encrypted vault through Sealite's native library. Their iOS targets compile in + # the `editor-ios` job below, which also builds the iOS app around :shared (the domain, the data logic and + # the whole UI). Its Compose UI tests run under Robolectric in testAndroidHostTest; it has no public API to + # dump. + modules: + name: libraries (tests, coverage, detekt, API) + # Dependabot bumps (build(deps) / ci(deps)) don't run CI on their own PR: they are merged in + # batches and checked on the branch that takes them, or on main after the merge. + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Set up JDK 21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # The desktop app's tests open real AWT windows (capture exclusion, window hooks), so they run under a + # virtual X display. + - name: Virtual display for the desktop tests + run: sudo apt-get update && sudo apt-get install -y xvfb + + - name: Library checks + run: >- + xvfb-run --auto-servernum ./gradlew + :editor:desktopTest + :editor:testAndroidHostTest + :editor:koverVerify + :editor:koverVerifyEditorLogic + :editor:detekt + :editor:checkKotlinAbi + :editor:dokkaGeneratePublicationHtml + :core:common:desktopTest + :core:common:testAndroidHostTest + :core:common:koverVerify + :core:common:detekt + :core:common:checkKotlinAbi + :core:crypto:desktopTest + :core:crypto:testAndroidHostTest + :core:crypto:koverVerify + :core:crypto:detekt + :core:crypto:checkKotlinAbi + :core:security:desktopTest + :core:security:testAndroidHostTest + :core:security:koverVerify + :core:security:detekt + :core:security:checkKotlinAbi + :core:keystore:desktopTest + :core:keystore:detekt + :core:keystore:koverVerify + :core:keystore:checkKotlinAbi + :core:database:desktopTest + :core:database:testAndroidHostTest + :core:database:koverVerify + :core:database:detekt + :core:database:checkKotlinAbi + :core:resources:desktopTest + :core:resources:testAndroidHostTest + :shared:desktopTest + :shared:testAndroidHostTest + :shared:koverVerify + :shared:detekt + :desktopApp:desktopTest + :desktopApp:koverVerify + :desktopApp:detekt + + - name: Upload reports on failure + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: reports-libraries + path: | + editor/build/reports/ + editor/build/test-results/ + core/*/build/reports/ + core/*/build/test-results/ + shared/build/reports/ + shared/build/test-results/ + desktopApp/build/reports/ + desktopApp/build/test-results/ + retention-days: 7 + if-no-files-found: ignore + + # The desktop installers: MSI (Windows), DMG (macOS) and DEB (Linux), each built on its own OS since jpackage + # cannot cross-build, plus a Flatpak bundle on Linux. They are signed (macOS: Developer ID, hardened runtime, + # notarized and stapled; Windows: the MSI with signtool) only when the signing secrets exist and the run is not + # a pull request; otherwise they stay unsigned, as before, and the artifact name keeps `-unsigned`. Partly set + # secrets fail the job. The secrets and what they are: desktopApp/README.md, "Signing the desktop installers". + # The DEB and the Flatpak bundle are not code-signed on any path (a Linux release is signed by the repository + # or the store that serves it), so their artifacts always say `-unsigned`. + desktop-package: + name: desktop installer (${{ matrix.os }}) + if: github.actor != 'dependabot[bot]' + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, task: packageDeb, pattern: 'desktopApp/build/compose/binaries/main/deb/*.deb' } + - { os: windows-latest, task: packageMsi, pattern: 'desktopApp/build/compose/binaries/main/msi/*.msi' } + - { os: macos-15, task: packageDmg, pattern: 'desktopApp/build/compose/binaries/main/dmg/*.dmg' } + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up JDK 21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # Decides whether this run signs, from which secrets exist. `secrets` cannot be used in an `if:`, so the + # secrets are read into this one step's environment and the result goes out as step outputs. A pull request + # never signs (its code must not run with the certificates). Some of a group set and some not is an error, + # never a silent unsigned build. + - name: Signing + id: signing + env: + EVENT_NAME: ${{ github.event_name }} + ENCLY_MACOS_CERT_P12_BASE64: ${{ secrets.ENCLY_MACOS_CERT_P12_BASE64 }} + ENCLY_MACOS_CERT_PASSWORD: ${{ secrets.ENCLY_MACOS_CERT_PASSWORD }} + ENCLY_MACOS_SIGN_IDENTITY: ${{ secrets.ENCLY_MACOS_SIGN_IDENTITY }} + ENCLY_MACOS_NOTARY_APPLE_ID: ${{ secrets.ENCLY_MACOS_NOTARY_APPLE_ID }} + ENCLY_MACOS_NOTARY_PASSWORD: ${{ secrets.ENCLY_MACOS_NOTARY_PASSWORD }} + ENCLY_MACOS_NOTARY_TEAM_ID: ${{ secrets.ENCLY_MACOS_NOTARY_TEAM_ID }} + ENCLY_WINDOWS_CERT_PFX_BASE64: ${{ secrets.ENCLY_WINDOWS_CERT_PFX_BASE64 }} + ENCLY_WINDOWS_CERT_PASSWORD: ${{ secrets.ENCLY_WINDOWS_CERT_PASSWORD }} + run: | + set -euo pipefail + # none / all / partial, for the secrets named in the arguments + state() { + local set=0 total=0 name + for name in "$@"; do + total=$((total + 1)) + if [ -n "${!name:-}" ]; then set=$((set + 1)); fi + done + if [ "$set" -eq 0 ]; then echo none; elif [ "$set" -eq "$total" ]; then echo all; else echo partial; fi + } + signed=false + notarize=false + if [ "$EVENT_NAME" = pull_request ]; then + echo "::notice::Pull request: the installer is not signed." + elif [ "$RUNNER_OS" = macOS ]; then + cert=$(state ENCLY_MACOS_CERT_P12_BASE64 ENCLY_MACOS_CERT_PASSWORD ENCLY_MACOS_SIGN_IDENTITY) + notary=$(state ENCLY_MACOS_NOTARY_APPLE_ID ENCLY_MACOS_NOTARY_PASSWORD ENCLY_MACOS_NOTARY_TEAM_ID) + if [ "$cert" = partial ] || [ "$notary" = partial ] || { [ "$notary" = all ] && [ "$cert" != all ]; }; then + echo "::error::Incomplete macOS signing secrets (certificate: $cert, notarization: $notary). Set all of a group or none; notarization needs the certificate." + exit 1 + fi + if [ "$cert" = all ]; then signed=true; fi + if [ "$notary" = all ]; then notarize=true; fi + if [ "$signed" = false ]; then echo "::notice::No macOS signing secrets: the DMG is not signed."; fi + if [ "$signed" = true ] && [ "$notarize" = false ]; then echo "::warning::Signed but not notarized: Gatekeeper will still warn."; fi + elif [ "$RUNNER_OS" = Windows ]; then + cert=$(state ENCLY_WINDOWS_CERT_PFX_BASE64 ENCLY_WINDOWS_CERT_PASSWORD) + if [ "$cert" = partial ]; then + echo "::error::Incomplete Windows signing secrets. Set ENCLY_WINDOWS_CERT_PFX_BASE64 and ENCLY_WINDOWS_CERT_PASSWORD, or neither." + exit 1 + fi + if [ "$cert" = all ]; then signed=true; else echo "::notice::No Windows signing secrets: the MSI is not signed."; fi + fi + suffix=-unsigned + if [ "$RUNNER_OS" != Linux ] && [ "$signed" = true ]; then suffix=''; fi + { + echo "signed=$signed" + echo "notarize=$notarize" + echo "suffix=$suffix" + } >> "$GITHUB_OUTPUT" + + # jpackage builds a .deb with dpkg-deb and fakeroot. + - name: Linux packaging tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y fakeroot + + # macOS: the Developer ID certificate goes into a keychain of its own that exists for this job only + # (random password, masked) and is deleted again in the last step. Nothing is written to the repository. + - name: Import the signing certificate (macOS) + if: runner.os == 'macOS' && steps.signing.outputs.signed == 'true' + env: + CERT_P12_BASE64: ${{ secrets.ENCLY_MACOS_CERT_P12_BASE64 }} + CERT_PASSWORD: ${{ secrets.ENCLY_MACOS_CERT_PASSWORD }} + SIGN_IDENTITY: ${{ secrets.ENCLY_MACOS_SIGN_IDENTITY }} + run: | + set -euo pipefail + keychain="$RUNNER_TEMP/encly-signing.keychain-db" + keychain_password=$(openssl rand -base64 24) + echo "::add-mask::$keychain_password" + printf '%s' "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/encly-signing.p12" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$RUNNER_TEMP/encly-signing.p12" -P "$CERT_PASSWORD" -t cert -f pkcs12 \ + -T /usr/bin/codesign -T /usr/bin/security -k "$keychain" > /dev/null + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" > /dev/null + rm -f "$RUNNER_TEMP/encly-signing.p12" + # The keychain must be on the search list for codesign to find the chain. + keychains=("$keychain") + while IFS= read -r existing; do + keychains+=("$existing") + done < <(security list-keychains -d user | sed 's/^[[:space:]]*"//; s/"$//') + security list-keychains -d user -s "${keychains[@]}" + if ! security find-identity -v -p codesigning "$keychain" | grep -qF "$SIGN_IDENTITY"; then + echo "::error::The certificate has no code-signing identity named like ENCLY_MACOS_SIGN_IDENTITY." + exit 1 + fi + echo "ENCLY_MACOS_KEYCHAIN=$keychain" >> "$GITHUB_ENV" + + - name: Package + if: runner.os != 'macOS' || steps.signing.outputs.signed != 'true' + run: ./gradlew :desktopApp:${{ matrix.task }} + + # Gradle signs the app image (every binary, hardened runtime, desktopApp/packaging/macos/entitlements.plist) + # and the DMG; notarizeDmg submits it to Apple with notarytool, waits, and staples the ticket. + - name: Package, sign and notarize (macOS) + if: runner.os == 'macOS' && steps.signing.outputs.signed == 'true' + env: + ENCLY_MACOS_SIGN_IDENTITY: ${{ secrets.ENCLY_MACOS_SIGN_IDENTITY }} + ENCLY_MACOS_NOTARY_APPLE_ID: ${{ secrets.ENCLY_MACOS_NOTARY_APPLE_ID }} + ENCLY_MACOS_NOTARY_PASSWORD: ${{ secrets.ENCLY_MACOS_NOTARY_PASSWORD }} + ENCLY_MACOS_NOTARY_TEAM_ID: ${{ secrets.ENCLY_MACOS_NOTARY_TEAM_ID }} + NOTARIZE: ${{ steps.signing.outputs.notarize }} + run: | + set -euo pipefail + tasks=(:desktopApp:packageDmg) + if [ "$NOTARIZE" = true ]; then tasks+=(:desktopApp:notarizeDmg); fi + ./gradlew "${tasks[@]}" + + # A signed build that Gatekeeper would reject must fail here, not at the first user. + - name: Verify the signature (macOS) + if: runner.os == 'macOS' && steps.signing.outputs.signed == 'true' + env: + NOTARIZE: ${{ steps.signing.outputs.notarize }} + run: | + set -euo pipefail + dmg=$(ls desktopApp/build/compose/binaries/main/dmg/*.dmg) + mount="$RUNNER_TEMP/encly-dmg" + mkdir -p "$mount" + hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount" > /dev/null + trap 'hdiutil detach "$mount" -quiet || true' EXIT + app=$(ls -d "$mount"/*.app) + codesign --verify --deep --strict --verbose=2 "$app" + if [ "$NOTARIZE" = true ]; then + xcrun stapler validate "$dmg" + spctl --assess --type execute --verbose=2 "$app" + fi + + # Windows: signtool, from the Windows SDK on the runner, signs the MSI with the PFX and a timestamp, so the + # signature outlives the certificate. The PFX exists on disk only for this step. + - name: Sign the MSI (Windows) + if: runner.os == 'Windows' && steps.signing.outputs.signed == 'true' + env: + PFX_BASE64: ${{ secrets.ENCLY_WINDOWS_CERT_PFX_BASE64 }} + PFX_PASSWORD: ${{ secrets.ENCLY_WINDOWS_CERT_PASSWORD }} + TIMESTAMP_URL: ${{ vars.ENCLY_WINDOWS_TIMESTAMP_URL || 'http://timestamp.digicert.com' }} + MSYS2_ARG_CONV_EXCL: '*' + run: | + set -euo pipefail + pfx="$RUNNER_TEMP/encly-signing.pfx" + printf '%s' "$PFX_BASE64" | base64 --decode > "$pfx" + signtool=$(find "/c/Program Files (x86)/Windows Kits/10/bin" -path '*/x64/signtool.exe' | sort -V | tail -n 1) + if [ -z "$signtool" ]; then + echo "::error::signtool.exe not found on the runner" + exit 1 + fi + msi=$(ls desktopApp/build/compose/binaries/main/msi/*.msi) + "$signtool" sign /f "$(cygpath -w "$pfx")" /p "$PFX_PASSWORD" /fd SHA256 \ + /tr "$TIMESTAMP_URL" /td SHA256 /d "Encly" "$(cygpath -w "$msi")" + "$signtool" verify /pa /v "$(cygpath -w "$msi")" + + - name: Upload the installer + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: encly-desktop-${{ matrix.os }}${{ steps.signing.outputs.suffix }} + path: ${{ matrix.pattern }} + retention-days: 14 + if-no-files-found: error + + # Linux only: the Flatpak bundle (desktopApp/packaging/flatpak). flatpak-builder installs the freedesktop + # runtime from Flathub; ubuntu-latest restricts unprivileged user namespaces, which bubblewrap needs. + - name: Flatpak tooling + if: runner.os == 'Linux' + run: | + set -euo pipefail + sudo apt-get install -y flatpak flatpak-builder appstream desktop-file-utils libxml2-utils + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + + - name: App image + if: runner.os == 'Linux' + run: ./gradlew :desktopApp:createDistributable + + - name: Flatpak + if: runner.os == 'Linux' + env: + ENCLY_FLATPAK_SKIP_GRADLE: '1' + ENCLY_FLATPAK_REQUIRE_LINT: '1' + run: desktopApp/packaging/flatpak/build.sh + + # For information only: Flathub's linter. It expects a source build and screenshots, so it reports things + # that do not apply to this bundle; it never fails the job. + - name: Flatpak lint + if: runner.os == 'Linux' + continue-on-error: true + run: | + flatpak install --user -y flathub org.flatpak.Builder + flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest desktopApp/packaging/flatpak/io.github.pasichdev.Encly.yml + flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo desktopApp/build/flatpak/repo + + - name: Upload the Flatpak bundle + if: runner.os == 'Linux' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: encly-desktop-flatpak-unsigned + path: desktopApp/build/flatpak/*.flatpak + retention-days: 14 + if-no-files-found: error + + # Whatever happened above, the certificate does not stay on the runner. + - name: Remove the signing material + if: always() + run: | + rm -f "$RUNNER_TEMP/encly-signing.p12" "$RUNNER_TEMP/encly-signing.pfx" + if [ "$RUNNER_OS" = macOS ]; then + security delete-keychain "$RUNNER_TEMP/encly-signing.keychain-db" 2> /dev/null || true + fi + + release: + name: release build (R8, lint, manifest) + # Dependabot bumps (build(deps) / ci(deps)) don't run CI on their own PR: they are merged in + # batches and checked on the branch that takes them, or on main after the merge. + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Set up JDK 21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Release build + run: ./gradlew :app:assembleFdroidRelease + + # Separate invocation: lint running alongside Metro code generation for another variant has # raced on generated sources; after the build it reads finished outputs. - name: Lint (release) run: ./gradlew :app:lintFdroidRelease @@ -91,27 +499,288 @@ jobs: done exit $found - # For information only: posts (and keeps updating) one PR comment with the app - # coverage. It never fails the build. - - name: Coverage comment - if: github.event_name == 'pull_request' - continue-on-error: true - uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0 - with: - paths: app/build/reports/kover/reportFdroidDebug.xml - token: ${{ secrets.GITHUB_TOKEN }} - title: 'Coverage' - update-comment: true - min-coverage-overall: 0 - min-coverage-changed-files: 0 - - name: Upload reports on failure if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: reports + name: reports-release path: | app/build/reports/ - app/build/test-results/ retention-days: 7 if-no-files-found: ignore + + # The KMP libraries with iOS targets compile for iOS here. Kotlin/Native compiles for Apple targets only on + # macOS, so this job builds the iOS klibs there (for the database, Room's KSP runs for the iOS targets too). + # The crypto, security and key store tests run on the iOS simulator: the AES-GCM bridge to CryptoKit and + # the golden vectors of every vault and backup format must give the JVM's bytes. So do the common tests of + # :shared (the ViewModels, the formatters), which also exercise the iOS actuals of dates and Unicode. The + # last step builds the iOS app (iosApp/) for the Simulator, unsigned: the Xcode project links the static + # framework that Gradle builds from :shared. The job keeps its name so that the name of the check does not change. + # F-Droid builds the fdroid flavor from the tag on its own server and publishes it only when its APK + # is byte for byte the one on the GitHub release (fdroiddata: Binaries + AllowedAPKSigningKeys). Two + # builds of the same commit in different directories, without the build cache, must give identical + # unsigned APKs; a path or machine value that leaks into the output would stop every F-Droid update. + fdroid-reproducible: + name: fdroid build reproducible + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: first + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: elsewhere/second + + - name: Set up JDK 21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + with: + build-root-directory: first + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + # The release workflow's command, no build cache, no shared daemon, and a Gradle home of its own for the + # second build, so it downloads and transforms every dependency again. + - name: Build twice + run: | + set -euo pipefail + (cd first && chmod +x ./gradlew && ./gradlew --no-daemon --no-build-cache --no-configuration-cache \ + :app:assembleRelease) + (cd elsewhere/second && chmod +x ./gradlew && GRADLE_USER_HOME="$RUNNER_TEMP/gradle-home-2" \ + ./gradlew --no-daemon --no-build-cache --no-configuration-cache :app:assembleRelease) + + - name: Compare the APKs + run: | + set -euo pipefail + a=$(ls first/app/build/outputs/apk/fdroid/release/*.apk) + b=$(ls elsewhere/second/app/build/outputs/apk/fdroid/release/*.apk) + if cmp -s "$a" "$b"; then + echo "Identical: $(sha256sum "$a" | cut -d' ' -f1)" + exit 0 + fi + echo "::error::The two fdroid release builds differ; F-Droid would not publish this version." + python3 - "$a" "$b" <<'PY' + import hashlib, sys, zipfile + def entries(path): + with zipfile.ZipFile(path) as z: + return {i.filename: hashlib.sha256(z.read(i)).hexdigest() for i in z.infolist()} + a, b = entries(sys.argv[1]), entries(sys.argv[2]) + for name in sorted(set(a) | set(b)): + if a.get(name) != b.get(name): + print("differs:", name) + PY + exit 1 + + editor-ios: + if: github.actor != 'dependabot[bot]' + name: editor (iOS compile) + runs-on: macos-15 + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up JDK 21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Compile the iOS targets + run: >- + ./gradlew + :editor:compileKotlinIosSimulatorArm64 + :editor:compileKotlinIosArm64 + :core:common:compileKotlinIosSimulatorArm64 + :core:common:compileKotlinIosArm64 + :core:database:compileKotlinIosSimulatorArm64 + :core:database:compileKotlinIosArm64 + :core:resources:compileKotlinIosSimulatorArm64 + :core:resources:compileKotlinIosArm64 + :core:crypto:compileKotlinIosArm64 + :core:security:compileKotlinIosArm64 + :core:keystore:compileKotlinIosArm64 + :shared:compileKotlinIosSimulatorArm64 + :shared:compileKotlinIosArm64 + + - name: iOS tests (crypto, security, key store, shared) + run: >- + ./gradlew + :core:crypto:iosSimulatorArm64Test + :core:security:iosSimulatorArm64Test + :core:keystore:iosSimulatorArm64Test + :shared:iosSimulatorArm64Test + + # On a Mac the klib dump of the public API is made from the real iOS klibs (elsewhere it is + # inferred), so this is where editor/api/editor.klib.api and core/common/api/common.klib.api + # are really checked. + - name: Public API dump + run: >- + ./gradlew :editor:checkKotlinAbi :core:common:checkKotlinAbi :core:database:checkKotlinAbi + :core:crypto:checkKotlinAbi :core:security:checkKotlinAbi :core:keystore:checkKotlinAbi + + # The app itself, for the Simulator and without signing. The first build phase of the project runs + # Gradle (`:shared:embedAndSignAppleFrameworkForXcode`), which links the framework. + - name: Build the iOS app (Simulator, unsigned) + run: >- + xcodebuild + -project iosApp/iosApp.xcodeproj + -scheme iosApp + -sdk iphonesimulator + -destination 'generic/platform=iOS Simulator' + CODE_SIGNING_ALLOWED=NO + build + + # The desktop PIN factor against the real secret store of each OS: DPAPI on Windows, the login Keychain on + # macOS, the freedesktop Secret Service (GNOME Keyring) on Linux. The unit tests of :core:keystore run in + # the `build` job; here ENCLY_KEYSTORE_IT=1 turns on the one test per OS that creates, reads, uses and + # deletes a secret in the real store (under a test namespace). --rerun: the test result must not come + # from the build cache of a run without the variable. + keystore-it: + if: github.actor != 'dependabot[bot]' + name: keystore (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-15, ubuntu-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up JDK 21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # A headless Secret Service: a private session bus, GNOME Keyring started on it with the login keyring + # created and unlocked with a throw-away password (read from stdin), then the Gradle run inside that + # same bus session. The probe fails the job early, and readably, if no Secret Service answers. + - name: Keystore tests (Linux, Secret Service) + if: runner.os == 'Linux' + env: + ENCLY_KEYSTORE_IT: '1' + run: | + sudo apt-get update + sudo apt-get install -y gnome-keyring dbus-x11 libsecret-tools + dbus-run-session -- bash -euo pipefail -c ' + printf "%s" "ci-throwaway-keyring-password" | gnome-keyring-daemon --replace --daemonize --unlock --components=secrets > /dev/null + printf "probe" | secret-tool store --label="probe" application encly-ci-probe + secret-tool lookup application encly-ci-probe + secret-tool clear application encly-ci-probe + ./gradlew :core:keystore:desktopTest --rerun + ' + + - name: Keystore tests (Windows, macOS) + if: runner.os != 'Linux' + env: + ENCLY_KEYSTORE_IT: '1' + run: ./gradlew :core:keystore:desktopTest --rerun + + # RealBackendTest skips every OS but the runner's own: exactly one of its tests must have run here, + # so a backend that silently skipped (wrong OS detection, variable not passed) fails the job. + - name: The real secret store was exercised + run: | + report=core/keystore/build/test-results/desktopTest/TEST-com.pasich.encly.core.keystore.RealBackendTest.xml + test -f "$report" + tests=$(grep -o 'tests="[0-9]*"' "$report" | head -1 | tr -dc 0-9) + skipped=$(grep -o 'skipped="[0-9]*"' "$report" | head -1 | tr -dc 0-9) + failures=$(grep -o 'failures="[0-9]*"' "$report" | head -1 | tr -dc 0-9) + echo "RealBackendTest: tests=$tests skipped=$skipped failures=$failures" + if [ $((tests - skipped)) -lt 1 ] || [ "$failures" != "0" ]; then + echo "::error::RealBackendTest did not run against the ${{ runner.os }} secret store" + exit 1 + fi + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: keystore-reports-${{ matrix.os }} + path: core/keystore/build/reports/tests/ + retention-days: 7 + if-no-files-found: ignore + + # The tests that need the real native SQLCipher and Room on Android: opening a vault written by + # earlier releases, the lock/unlock lifecycle, every schema migration. They live in the + # :core:database module (source set androidDeviceTest) and run in its own test app. On the oldest + # supported API and a current one. + instrumented: + if: github.actor != 'dependabot[bot]' + name: instrumented (API ${{ matrix.api }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - { api: 26, target: default } + - { api: 35, target: google_apis } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up JDK 21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build the test APK + run: ./gradlew :core:database:assembleAndroidDeviceTest + + - name: Instrumented tests + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: ${{ matrix.api }} + arch: x86_64 + target: ${{ matrix.target }} + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew :core:database:connectedAndroidDeviceTest + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: instrumented-reports-api-${{ matrix.api }} + path: core/database/build/reports/androidTests/ + retention-days: 7 diff --git a/.github/workflows/publish-play.yml b/.github/workflows/publish-play.yml index 648c315f..113cb47e 100644 --- a/.github/workflows/publish-play.yml +++ b/.github/workflows/publish-play.yml @@ -62,7 +62,7 @@ jobs: persist-credentials: false - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/wrapper-validation@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Tag must match version.properties id: version @@ -103,7 +103,7 @@ jobs: java-version: '21' - name: Set up Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-read-only: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7c7c479..c9fd9a38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,9 +38,12 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The release is published with the action's own token; the build never needs git credentials. + persist-credentials: false - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/wrapper-validation@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Tag must match version.properties id: version @@ -77,12 +80,12 @@ jobs: java-version: '21' - name: Set up Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-read-only: true - name: Build and test release APKs (unsigned) - run: ./gradlew :app:testFdroidDebugUnitTest assembleRelease + run: ./gradlew :app:testFdroidDebugUnitTest :app:assembleRelease - name: Sign APKs env: diff --git a/.gitignore b/.gitignore index 611bf470..db0cb9dc 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ keystore.properties *.jks *.keystore *.p12 + +# Xcode +xcuserdata/ +*.xcuserstate +DerivedData/ +/iosApp/build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 835382a9..6f664454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,50 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +### Added + +- Encly for Windows, macOS and Linux (MSI, DMG, DEB and a Flatpak bundle): the same vault, notes and security as on the phone, with list and note side by side, note tabs, several windows, keyboard shortcuts and a menu bar, export to PDF and print, and the PIN's device key in the system's secret store ([#85](https://github.com/pasichDev/Encly/issues/85), [#106](https://github.com/pasichDev/Encly/issues/106)). +- Encly for iPhone: each PIN attempt needs the Secure Enclave, Face ID and Touch ID unlock, the vault locks when the app leaves the screen, and the app is hidden in the app switcher ([#107](https://github.com/pasichDev/Encly/issues/107)). + +## [3.0.0] - 2026-11-07 + +versionCode 30000. + +### Added + +- Choose the length of your PIN: 6 to 12 digits, for the PIN, the wipe PIN and the duress PIN. PINs you already have keep working unchanged. The lock screen no longer sends the PIN by itself or shows how long it is: press Unlock when you have typed it ([#95](https://github.com/pasichDev/Encly/issues/95)). +- Arabic, and right-to-left layouts in every screen; each paragraph and list item of a note follows the direction of its own text ([#105](https://github.com/pasichDev/Encly/issues/105)). +- Backups carry your settings (appearance, lists, editor, language and privacy preferences); a full restore applies them after the notes. Security state never leaves the vault ([#93](https://github.com/pasichDev/Encly/issues/93)). +- Select text across paragraphs, lists and headings, like in a document: drag, use the handles or Shift with the arrow keys, or press Select all twice. Typing, pasting or deleting over a selection is one undo step, and copies go through the protected clipboard. Whole blocks can also be picked from their left edge ([#83](https://github.com/pasichDev/Encly/issues/83)). +- Give a note several tags: pick as many as you like in the note's sheet or the editor, and the note appears in the view of each one. Notes keep the tag they have, backups carry every tag, and a note with a hidden tag stays out of every list but that tag's own view ([#109](https://github.com/pasichDev/Encly/issues/109)). +- Nested lists up to three levels (Tab and Shift+Tab), and list items that can be dragged into place ([#76](https://github.com/pasichDev/Encly/issues/76)). +- Tables, code blocks, and a toolbar you can arrange yourself in Settings → Appearance ([#76](https://github.com/pasichDev/Encly/issues/76)). +- A lock button next to a checklist while you work in it, and lists indented under their heading ([#76](https://github.com/pasichDev/Encly/issues/76)). +- Settings → Security → "Show note previews": when off, the notes list, the grid and search results show only the title, tag and date; search still matches the text inside notes ([#105](https://github.com/pasichDev/Encly/issues/105)). +- Settings → Security → "Allow screenshots until the app locks": after your PIN or biometrics and a warning, screenshots and screen recording are allowed until Encly locks. The lock, PIN and recovery phrase screens stay protected, and the setting is never stored ([#94](https://github.com/pasichDev/Encly/issues/94)). +- Sort notes by title, A to Z or Z to A, in the language's alphabetical order. +- Auto-lock after 5 or 10 minutes ([#98](https://github.com/pasichDev/Encly/issues/98)). +- Notes are deleted for good after 28 days in the trash, the same way "Delete forever" does. Notes already in the trash start their 28 days with this update ([#97](https://github.com/pasichDev/Encly/issues/97)). +- After an update, the first unlock shows what changed in that version, once; Settings → About keeps it ([#105](https://github.com/pasichDev/Encly/issues/105)). + +### Changed + +- The unlock animation takes half as long, and is skipped when animations are turned off in the system settings. +- After you unlock with the recovery phrase, a PIN that still works is no longer replaced: choose "Keep my PIN" or "Set a new PIN". A new PIN is still required when the PIN or its key is lost, or while it is locked out ([#95](https://github.com/pasichDev/Encly/issues/95)). +- Encly is rebuilt on a Kotlin Multiplatform core ([#92](https://github.com/pasichDev/Encly/pull/92)). The encrypted database now opens through the Sealite driver (SQLCipher 4.20). Vaults, recovery phrases, backups and notes from earlier versions open unchanged. +- Opening and closing a note is quicker ([#76](https://github.com/pasichDev/Encly/issues/76)). +- Find in note highlights matches more strongly ([#76](https://github.com/pasichDev/Encly/issues/76)). +- Backspace at the start of a list item joins it into the item above, so holding delete runs through a list without stopping ([#76](https://github.com/pasichDev/Encly/issues/76)). + +### Fixed + +- Fingerprint unlock did not start, and its button did nothing, after Encly locked itself in the background ([#104](https://github.com/pasichDev/Encly/issues/104)). +- Checking or unchecking a task, or archiving a note in a hidden tag's view, could close the app. +- A new note lost its creation date at the first autosave. Notes that already lost it now sort by their last edit ([#103](https://github.com/pasichDev/Encly/issues/103)). +- Stepping through matches in Find in note now scrolls to each match, also inside long paragraphs and lists ([#76](https://github.com/pasichDev/Encly/issues/76)). +- Scrolling over a list no longer leaves its toolbar button active ([#76](https://github.com/pasichDev/Encly/issues/76)). +- The gap below a quote matches the gap above it ([#76](https://github.com/pasichDev/Encly/issues/76)). + ## [2.3.1] - 2026-10-27 versionCode 20301. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1b98767..6794e845 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,8 @@ By taking part you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). `local.properties` (see [README → Build](README.md#build)). 3. Open the project in Android Studio or use `./gradlew`. Pick the `fdroidDebug` variant for day-to-day work. +4. The desktop app (Windows, macOS, Linux) is the `:desktopApp` module: `./gradlew :desktopApp:run`, and + `:desktopApp:desktopTest` for its headless tests. See [desktopApp/README.md](desktopApp/README.md). ## Before you open a pull request @@ -37,7 +39,9 @@ Run the same gates as CI: ```bash ./gradlew spotlessCheck # formatting (ktlint) ./gradlew :app:testFdroidDebugUnitTest :app:koverVerifyFdroidDebug # tests + coverage gate -./gradlew :app:detekt # config/detekt/detekt.yml +./gradlew :app:detekt :editor:detekt # config/detekt/detekt.yml +./gradlew :editor:desktopTest :editor:testAndroidHostTest :editor:koverVerify :editor:checkKotlinAbi # the :editor module +./gradlew :shared:desktopTest :shared:testAndroidHostTest :shared:koverVerify :shared:detekt # the UI and data logic ./gradlew :app:lintFdroidRelease :app:lintPlayRelease # release lint ./gradlew assembleRelease # R8 build ``` @@ -45,8 +49,9 @@ Run the same gates as CI: - Add or extend unit tests for behaviour you change. The app has a minimum line coverage gate of 85% (Kover, generated code excluded, see `app/build.gradle.kts`); do not lower it to get a PR through. -- For storage, Keystore or SQLCipher changes also run the instrumented tests on an emulator: - `./gradlew :app:connectedFdroidDebugAndroidTest` (they wipe the debug app's data). +- The instrumented tests (SQLCipher, Room migrations) run in CI on API 26 and 35. To run them + yourself use an emulator, never a phone with a vault you care about: + `./gradlew :core:database:connectedAndroidDeviceTest` (they run in the test app's own data and wipe it). - New detekt or lint findings must be fixed, not added to `app/detekt-baseline.xml` or `app/lint-baseline.xml`. Pull requests that shrink those baselines are very welcome. - For UI changes, say in the PR which device or emulator you checked it on. @@ -61,7 +66,7 @@ Run the same gates as CI: - [detekt](https://detekt.dev) (`config/detekt/detekt.yml`) adds code-smell, unused-code and [Jetpack Compose](https://mrmans0n.github.io/compose-rules/) rules: public composables take a `modifier: Modifier = Modifier` applied to their root, ViewModels are obtained with a - `hiltViewModel()` default parameter and not passed down to child composables (hoist state and + `metroViewModel()` default parameter and not passed down to child composables (hoist state and pass lambdas instead), and event lambdas are named in the present tense (`onClick`, not `onClicked`). - Android lint severities live in [`app/lint.xml`](app/lint.xml): hardcoded or untranslated @@ -73,7 +78,7 @@ Run the same gates as CI: The repository ships two opt-in hooks in [`.githooks/`](.githooks): -- `pre-commit` runs `spotlessCheck` and `:app:detekt` when the commit stages Kotlin or Gradle +- `pre-commit` runs `spotlessCheck`, `:app:detekt` and `:shared:detekt` when the commit stages Kotlin or Gradle files (`*.kt`, `*.kts`, `gradle/libs.versions.toml`, `.editorconfig`, `config/detekt/`), and does nothing for other commits, so docs and resource commits stay instant. It checks the working tree, so unstaged edits count too. @@ -98,34 +103,43 @@ git config core.hooksPath .githooks ## Room schema changes -Room schemas are exported to `app/schemas` and committed. When an entity changes: bump -`DB_VERSION` (`data/database/AppDatabase.kt`), add a `Migration`, commit the new schema JSON, and add a +Room schemas are exported to `core/database/schemas` and committed. When an entity changes: bump +`DB_VERSION` (`core/database/.../data/database/AppDatabase.kt`), add a `Migration`, commit the new schema JSON, and add a `runMigrationsAndValidate` case to `AppDatabaseSchemaTest`. Destructive migration is disabled on purpose. ## Translations -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), a `strings_handoff.xml` (the -import from My Notes) and a `strings_import.xml` (Import notes from other apps). +Every user-visible string lives in the `:core:resources` module, in Android's `strings.xml` +format; the build turns them into Compose Multiplatform resources. English is the default +(`core/resources/src/commonMain/strings/values/`); each translation is a `values-/` +folder next to it with a `strings.xml` (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). The only string left in the Android resources is the launcher label +(`app/src/main/res/values/manifest_strings.xml`). [![Translation status](https://hosted.weblate.org/widget/encly/svg-badge.svg)](https://hosted.weblate.org/engage/encly/) **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 +*Security*, *Handoff*, *Import*). 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. Thank you to Weblate for libre hosting of Encly's translations. -**Fixing a translation in Git:** edit the string in `values-/strings.xml` (or -`strings_security.xml`, `strings_handoff.xml`, `strings_import.xml`) and open a PR. Keep the key; +**Fixing a translation in Git:** edit the string in +`core/resources/src/commonMain/strings/values-/strings.xml` (or `strings_security.xml`, +`strings_handoff.xml`, `strings_import.xml`) and open a PR. Keep the key; only change the text. If a Weblate pull request is open, say so in yours; Weblate picks up merged changes from `main` on its own. -**For maintainers:** Weblate pulls `main` on every push (GitHub webhook +**For maintainers:** Weblate reads the same Android XML files, now under +`core/resources/src/commonMain/strings/` (file mask +`core/resources/src/commonMain/strings/values-*/strings.xml`, template +`.../values/strings.xml`, and the same for `strings_security.xml`, `strings_handoff.xml` and +`strings_import.xml`, one component each). +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 @@ -134,27 +148,38 @@ 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 -English placeholder you flag in the PR. Use it -with `stringResource(R.string.…)` in Compose. Text produced outside the UI (ViewModels, -managers, validators) must not be resolved there: return a resource id or a `UiText` -(`core/common/UiText.kt`) and resolve it in the UI, so it follows the in-app language. Never -resolve UI text with the application context — it ignores the in-app language on Android 12 and -lower. +English placeholder you flag in the PR. Use it with `stringResource(Res.string.…)` (and +`pluralStringResource(Res.plurals.…)`) in Compose; `Res` and the key are imports from +`com.pasich.encly.core.resources`. Text produced outside the UI (ViewModels, managers, validators) +must not be resolved there: return a `StringResource` or a `UiText` (`core/common/UiText.kt`) and +resolve it in the UI, so it follows the in-app language. Where no composable is at hand, resolve +it with the suspend `getString(Res.string.…)` (or the `AppStrings` interface for text that is +stored); both read the process's default locale, which `AppLocales.apply` sets to the in-app +language. + +**Format arguments:** write strings as for Android (`\'` for an apostrophe, `%d`, `%s`, `%1$d`, `%%`); +the build converts them (`ConvertAndroidStrings` in `build-logic`). Compose resources fill in only +`%d` and `%s` (also numbered), so another format (`%.1f`, `%02d`) fails the build. **Adding a language:** 1. start it on Weblate, or copy `values/strings.xml`, `values/strings_security.xml`, - `values/strings_handoff.xml` and `values/strings_import.xml` to `values-/` and translate them, + `values/strings_handoff.xml` and `values/strings_import.xml` of + `core/resources/src/commonMain/strings/` 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 - non-translatable `language_name_` string in `values/strings.xml`; + non-translatable `language_name_` string in `values/strings.xml`, and the language to + `androidResources.localeFilters` in `app/build.gradle.kts`; 4. add the language's CLDR plural categories to `REQUIRED_QUANTITIES` in - `TranslationCompletenessTest`. - -**Rules the build enforces** (`./gradlew :app:testFdroidDebugUnitTest :app:lintFdroidDebug`): -every locale has every key and no extra ones (lint `MissingTranslation` is an error, and -`TranslationCompletenessTest` checks it too); format arguments (`%1$s`, `%d`, …) match English; + `TranslationCompletenessTest`, and the language, with a number for each category, to + `AndroidStrings.languages` and `QUANTITIES` in `AndroidStringsConversionTest`; +5. a right-to-left language sets `rightToLeft = true` in `AppLanguage`: the whole UI is then laid out + right to left on every platform. The recovery words and the PIN keypad stay left to right. + +**Rules the build enforces** (`./gradlew :app:testFdroidDebugUnitTest`): every locale has every +key and no extra ones (`TranslationCompletenessTest`; Android lint cannot see Compose resources); +format arguments (`%1$s`, `%1$d`, …) are numbered and match English; each `` has all categories its language uses (e.g. `one`/`few`/`many`/`other` for Ukrainian and Polish); apostrophes and double quotes are escaped (`\'`, `\"`). @@ -206,8 +231,10 @@ upload this key instead of letting Google generate one). notes, so use absolute links in it. 3. Write the store release notes (≤ 500 characters each) to `fastlane/metadata/android//changelogs/.txt` for **every** locale folder - (`en-US`, `uk`, `de-DE`, `fr-FR`, `es-ES`, `it-IT`, `pl-PL`, `pt-PT`, `nl-NL`); + (`en-US`, `uk`, `de-DE`, `fr-FR`, `es-ES`, `it-IT`, `pl-PL`, `pt-PT`, `nl-NL`, `ar`); `StoreMetadataTest` checks they exist and fit. + Add the version to `` in `desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml` + too; the Flatpak build in CI refuses a version that is not listed there. 4. Test the release APK on a device. 5. Merge to `main`, then tag the merge commit `vX.Y.Z` and push the tag. The [release workflow](.github/workflows/release.yml) refuses a tag that does not match diff --git a/PRIVACY.md b/PRIVACY.md index 383e1212..5816b4f3 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -68,8 +68,9 @@ You can export a backup in **Settings → Backup**. Nothing is exported automati - **What it contains:** all your notes (title, content, trash, pin, archive and colour state, created and edited dates), tags (name, visibility, order) and tasks (title, description, completion, dates, priority, - order). It does **not** contain your PIN, your recovery phrase, biometric data, any encryption - key of the app itself, or app settings. + order), plus your display and behaviour preferences (theme, fonts, language, list layout, + auto-lock delay and similar). It does **not** contain your PIN, your recovery phrase, biometric + data, or any encryption key of the app itself. - **How it is protected:** the whole file is encrypted with AES-256-GCM under a key derived from your 12-word recovery phrase. There is no separate backup password; without the 12 words the file cannot be read or changed unnoticed, not even by the developer. If your vault has no diff --git a/README.md b/README.md index 72a76cfb..90746b06 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ backup and, only when you ask for it, a readable Markdown export that is not enc ## Features - 🔒 **Encrypted at rest** — Room on SQLCipher with a random 256-bit database key. -- 🔐 **Mandatory lock** — a 6-digit PIN bound to the phone's Android Keystore, plus optional +- 🔐 **Mandatory lock** — a PIN of 6 to 12 digits bound to the phone's Android Keystore, plus optional Class 3 biometric unlock. - ⏱️ **Auto-lock** — the database closes and its key is wiped after you leave the app (immediately up to 2 minutes, 15 s by default) and at once when the screen turns off. @@ -38,7 +38,7 @@ backup and, only when you ask for it, a readable Markdown export that is not enc open-source license pages, a note's link block and, in the `play` flavor, the store page or, in the `fdroid` flavor, the Ko-fi donation page) open in another app and only when you tap them. - 🛡️ **Protected UI** — `FLAG_SECURE` is enforced from the first Activity frame. -- 🌍 **9 languages** — pick one in Settings → Language, independently of the system language. +- 🌍 **10 languages** — pick one in Settings → Language, independently of the system language. ## Languages @@ -52,7 +52,7 @@ it also appears in the system's per-app language settings. 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. A pull request that edits the `values-/` files +a language? Fix it there, no Git needed. A pull request that edits the `values-/` files of `core/resources` still works as a fallback: see [CONTRIBUTING.md → Translations](CONTRIBUTING.md#translations). Many thanks to [Weblate](https://weblate.org) for hosting Encly's translations as libre software. @@ -167,8 +167,8 @@ See [SECURITY.md](SECURITY.md) for the threat model and reporting process, and t ## Tech stack -Kotlin · Jetpack Compose · Material 3 · Hilt · Room · SQLCipher · Coroutines · -kotlinx.serialization · BIP39 (kotlin-bip39) +Kotlin · Jetpack Compose · Material 3 · Metro · Room · SQLCipher · Coroutines · +kotlinx.serialization · BIP39 (own implementation) Clean architecture: `presentation` → `domain` → `data`, with `core/security` coordinating the key vault. @@ -203,12 +203,15 @@ links, no baseline profile) and `play`. ```bash ./gradlew assembleFdroidDebug # debug APK (com.pasich.encly.debug) ./gradlew :app:testFdroidDebugUnitTest # JVM unit tests -./gradlew :app:koverVerifyFdroidDebug # coverage gate (whole app, 85%) +./gradlew :app:koverVerifyFdroidDebug # coverage gate (the Android app: activities and adapters, 85%) +./gradlew :shared:desktopTest :shared:testAndroidHostTest :shared:koverVerify # the UI and data logic (85%) +./gradlew :editor:desktopTest :editor:testAndroidHostTest :editor:koverVerify # the editor module (100%) ./gradlew :app:detekt :app:lintFdroidRelease # static analysis ./gradlew spotlessApply # format Kotlin/Gradle (ktlint; spotlessCheck in CI) ./gradlew assembleRelease # both release APKs, R8 enabled ./gradlew :app:bundlePlayRelease # Google Play App Bundle (play flavor) -./gradlew :app:connectedFdroidDebugAndroidTest # instrumented tests (emulator; wipes the debug app's data) +./gradlew :core:database:desktopTest :core:database:testAndroidHostTest :core:database:koverVerify # the database module (85%) +./gradlew :core:database:connectedAndroidDeviceTest # instrumented tests (emulator): native SQLCipher, migrations ``` Outputs: `app/build/outputs/apk///`, and the bundle in @@ -255,6 +258,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#releasing). - [Security model](SECURITY.md) - [Changelog](CHANGELOG.md) - [Architecture](docs/architecture.md) +- [Desktop app](desktopApp/README.md) (Windows, macOS, Linux: run, package, data location, security notes; in development) ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 5252e4a5..17b51181 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,17 @@ decoy DEK (random 256 bit) ── SQLCipher raw key of the decoy vault (vault_b. ### PIN slot -- The app requires a 6-digit PIN. +- The PIN is 6 to 12 digits, the user's choice. Shorter PINs are not offered: the lockout and the + device-bound half below limit how many guesses anyone gets, and 6 digits is the shortest that + stays safe under those limits. The length is not stored anywhere: the KDF below is the same for + every length, so a PIN made when the PIN was fixed at 6 digits derives exactly the keys it did + (the golden tests pin that), and an attacker who copies the slot learns nothing about the length. + **The lock screen does not auto-submit and does not show how long the PIN is**: the dots start + at six rings and grow with each digit typed, and an *Unlock* button sends what was typed. A + stored length (to submit at it) would leak it, and would also make the wipe PIN and the duress + PIN, which may have another length than the PIN, impossible to enter. The wipe PIN and the + duress PIN follow the same 6 to 12 digit rule and the same KDF run, so nothing in the store or + on screen tells which of the three a PIN is. - The PIN KEK has two halves, mixed with HKDF-SHA256 (`info = "encly/pin/kek/v3"`, the slot's random 16-byte salt as HKDF salt): - **software:** PBKDF2-HMAC-SHA256 (600,000 iterations) of the PIN and the salt; @@ -37,9 +47,11 @@ decoy DEK (random 256 bit) ── SQLCipher raw key of the decoy vault (vault_b. - That KEK wraps the DEK with **AES-256-GCM**. The slot is `version ‖ salt ‖ IV ‖ ciphertext`; the version and salt are authenticated as associated data (`"encly/pin/slot/v3"`). - No separate PIN hash is stored. -- Because of the device-bound half, a copied slot cannot be attacked off the device: every PIN - guess needs this phone's secure hardware. PBKDF2 stays at full cost as defence in depth, for - the case where the Keystore key itself were ever extracted. +- Because of the device-bound half, a copied slot cannot be attacked off the device: on Android + every PIN guess needs this phone's secure hardware (the iOS build gets the same property with + a different construction, see [iOS](#ios); the desktop build does not have it, see + [Desktop](#desktop)). PBKDF2 stays at full cost as defence in depth, for the case where the + Keystore key itself were ever extracted. - If the Keystore key is lost (a Keystore reset, a key the system invalidated), the PIN can no longer unlock. The lock screen says so and switches to the recovery phrase (or keeps fingerprint unlock); the vault without either routes to the damaged-vault screen. A new PIN @@ -56,6 +68,81 @@ decoy DEK (random 256 bit) ── SQLCipher raw key of the decoy vault (vault_b. 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. +- **Downgrade and PIN length.** A PIN longer than 6 digits cannot be entered in versions before 3.0.0: + their keypad submits at the sixth digit, so every attempt with a longer PIN is a counted miss and + the lockout escalates, until the user turns to the recovery phrase. Upgrading again changes nothing: + the PIN and its slot are untouched. A PIN of 6 digits works in every version. +- **Leftover entries.** Development builds briefly wrote a `pin.password.slot` entry (and + `pin.password.notice`). No release reads or writes them. A vault file that has them opens with its PIN + as before; the entries are ignored and dropped by the next PIN write or erase. + +#### Desktop + +The desktop build (in development) computes the same device-bound half, `HMAC-SHA256(deviceSecret, salt ‖ PIN)`, +with a different key store. The `deviceSecret` is 32 random bytes from `SecureRandom`, created once per OS user +(and once per PIN key slot) and kept **only** in the operating system's secret store of that user. Encly's own +files never hold it unprotected, and it is never logged. + +| OS | Where the secret lives | +|---|---| +| Windows | Encrypted with DPAPI (`CryptProtectData`, current-user scope, no UI, Encly-specific entropy). The encrypted blob is a file in `%LOCALAPPDATA%\Encly\` (local to the machine, not the roaming profile that is copied to profile servers); only the same Windows user can decrypt it. | +| macOS | A generic password in the user's login Keychain (service `com.pasich.encly`, account `pin-factor-v1`), through Security.framework. | +| Linux | An item of the default collection of the freedesktop Secret Service (GNOME Keyring, KWallet, KeePassXC), attributes `application = com.pasich.encly`, `purpose = pin-factor-v1`. The secret crosses the session bus encrypted (`dh-ietf1024-sha256-aes128-cbc-pkcs7`: Diffie-Hellman in the 1024-bit Oakley group 2, an AES-128 key from HKDF-SHA256, AES-CBC), never as `plain`; a service that cannot do the exchange is reported as unavailable. Without a running Secret Service (a headless session) the PIN cannot be used; there is no fallback to a file. A locked or missing default keyring is a temporary failure ("unlock your keyring"), not a lost key. | + +The second PIN key slot (used after a wipe-PIN unlock) keeps its own secret under the same names with a `-b` suffix. + +On Windows the DPAPI blob and the vault are in the local profile (`%LOCALAPPDATA%`), never the roaming one. In a domain, +DPAPI master keys can be recovered with the domain's backup key, so domain administrators can obtain the secret of a +domain user; that is part of the desktop trust boundary below. On macOS the data folder is excluded from Time Machine +(the sticky `com.apple.metadata:com_apple_backup_excludeItem` attribute, set at every start). + +A secret that is missing or can no longer be read is the same state as a lost Keystore key above: the PIN no +longer unlocks and the recovery phrase is needed. A store that is only locked or not running is a temporary +failure, not a lost key. + +Difference from Android: this key is **software-bound to the OS user account, not hardware-bound**. It does not +sit in a StrongBox or TEE, and the secret store releases it to any process running as the same user (macOS may +ask the user on the first read by another program; DPAPI and the Secret Service do not). A copied vault slot is +still useless on its own, since the secret never leaves the user's account, but malware running as the user, or +anyone who has the user's OS login (a DPAPI master key follows the Windows password, the login keyring the +session password), can obtain it and then guess PINs off the machine against PBKDF2 alone. Full-disk +encryption and a strong OS login are part of the desktop trust boundary. Secure Enclave and TPM-bound keys are +not used yet; they would need a signed app (Apple) or extra platform work. + +#### iOS + +The device-bound half is computed by the **Secure Enclave** once per PIN guess, but not as an HMAC: the Enclave +holds only EC keys and answers ECDH. Per PIN key slot the app makes a non-extractable **P-256** key inside the +Enclave (`kSecAttrTokenIDSecureEnclave`, access control `privateKeyUsage` only, `WhenUnlockedThisDeviceOnly`: +usable while the phone is unlocked, never in iCloud Keychain or a backup restored to another device, no +biometry). For a guess, with `data = salt ‖ PIN`: + +``` +Q = hash_to_curve("encly.ios.pin-factor.v2/hash-to-point" ‖ slot ‖ data) (SHA-256, try-and-increment) +Z = x(d · Q) one ECDH inside the Secure Enclave +hw = HKDF-SHA256(Z, salt = info prefix, info = "encly.ios.pin-factor.v2/hw" ‖ data) (32 bytes) +``` + +`hw` replaces the Android HMAC in the PIN KEK. Why this needs the Enclave for every guess: `Q` is a hash of the +guess, so nobody knows its discrete logarithm, and `d · Q` cannot be computed without `d`, which exists only in the +Enclave. Two simpler constructions were rejected because they let the PIN be searched without it: `Q = k·G` +with `k` derived from the PIN gives `d·Q = k·(d·G)`, computable from the public key; and a fixed peer key with the +PIN mixed in after the agreement gives a per-slot constant that one capture turns into an offline search. + +- A copy of the vault file, the Keychain and everything else on the phone except the Enclave does not let anyone test + PINs. The point `Q` is as sensitive as the PIN (10^6 SHA-256 calls recover the PIN from it) and is wiped after + use. Someone who can run code in the app while the phone is unlocked (a jailbreak, a debugger on a development + build) can still ask the Enclave one guess at a time; the guess rate is the Enclave's, not a GPU's, and the + app's lockout is not what protects against that attacker. +- The Enclave key is gone when the phone is restored from a backup onto another one, or when iOS drops it (removing + the device passcode deletes `WhenUnlocked...` items). That is the lost-key state above: the PIN no longer + unlocks and the recovery phrase is needed. +- **No Enclave, no PIN.** Every iPhone that runs iOS 15 has a Secure Enclave. If a device cannot make the key, setting + a PIN fails; the app never falls back to a software key on a device. The iOS Simulator has no Enclave and uses a + software key in the Keychain so that the app and its tests can run; that code is compiled only for the Simulator and + gives no protection against someone who can read the Mac. +- Not verified on hardware by the test suite: the Enclave key itself (the Simulator cannot run it). The + construction is tested with software P-256 keys against values computed independently with CryptoKit. ### Wipe PIN @@ -77,7 +164,7 @@ and show me"), not against a forensic examination. 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). +- **Rules.** 6 to 12 digits and different from the PIN (checked by opening the PIN slot with it). Changing the PIN to the wipe PIN turns the wipe PIN off (one PIN never opens both slots). A reset of the PIN's Keystore key (see above), or a new PIN set after that key was deleted and had to be created anew, turns it off too, since the slot can never open again; Settings then @@ -181,7 +268,7 @@ meant for "open the app and show me", not against a forensic examination. 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 +- **Rules.** 6 to 12 digits, different from the PIN and from the wipe PIN (checked by opening their slots with it); a wipe PIN equal to the duress PIN is refused, and so is changing the PIN to the duress PIN, since either would silently orphan the decoy notes. A reset of the PIN's Keystore key, or a PIN set with a new salt (after a recovery-phrase unlock when the PIN slot @@ -250,6 +337,12 @@ Onboarding offers an optional BIP39 recovery slot. - The resulting recovery KEK wraps the same random DEK with AES-256-GCM. - Entering the correct recovery seed can therefore recover local database access even when the PIN is unavailable. +- **After a recovery unlock** the PIN is no longer replaced by force. If the PIN slot still exists, is + not locked out and its device key is not lost (probed with one HMAC; a transient Keystore failure + counts as not lost), the user chooses *Keep my PIN* (the default) or *Set a new PIN*. Only when the + slot is missing, the key is lost or a lockout runs is a new PIN required before going on. *Keep* changes nothing in the store and + ends the session's permission to reset the PIN without the current one; the recovery phrase is + the proof of ownership either way, and the lockout is left alone (a new PIN ends it). Choosing "PIN only, no backups" during onboarding intentionally creates no recovery slot. One can be added later from an unlocked session (Settings → Security, or Settings → Backup before @@ -283,8 +376,22 @@ description, completion, created/completed dates, priority, order). Records carr stable random `uid`; links between them (note → tag, task → tag) are stored as uids, never as per-device database ids. -A backup **never** contains the DEK, the PIN or biometric slots, lockout state, the recovery -seed, the backup key, or app settings. +It also carries the user's display and behaviour preferences (theme, palette, fonts, text size +and spacing, list layout, sort order, swipe actions, language, auto-lock delay, strict keyboard +privacy, note previews). They come from an explicit allow-list (`BackupSettings` in +`:core:security`); a test fails for any preference key the app uses that is on neither the +allow-list nor the documented exclusion list. A restore reads each value separately: an unknown +key or a value outside the allowed set is ignored, and preferences are applied only after the +notes were imported, and only by "replace all" and the first-run restore (a merge keeps the +preferences of the install it lands in). + +A backup **never** contains the DEK, the PIN or biometric slots, lockout state, onboarding or +vault-commit flags, the screenshot allowance, the recovery seed, the backup key, or any other +state of the install or the vault. + +Payload schema 5 added the preferences section. A schema-4 file restores exactly as before. An app +that reads only schema 4 reports a schema-5 file as "unsupported version, update the app": its +strict reader cannot skip a section, and it never half-restores or reports the file as damaged. ### Key: the recovery seed, not a separate password @@ -540,6 +647,14 @@ keyboard itself: it still receives every key it types, and a malicious or non-co ignore all of these flags (see "Important limitations"). The cost is no autocorrect, and some keyboards hide emoji or voice input in password fields. +On iOS every text input is Compose's `CMPTextInputView`, and the app makes it answer "no" for autocorrection, +spell checking, smart quotes, smart dashes, smart insert/delete and inline predictions, whatever the field asked +for: the system keyboard then has no suggestions to learn from. iOS has no "do not learn" flag and no +password-like mode that fits a multi-line editor, so **Strict keyboard privacy** is not shown on iOS. Third-party +keyboards are not loaded at all (the app refuses the keyboard extension point). This is applied by replacing those +trait getters on that Compose view class at start; if a Compose upgrade renames the class, the test +`IosKeyboardPrivacyTest` and the review of the upgrade must catch it. + The app requests no `INTERNET` permission and performs no analytics or sync. ## Platform storage @@ -598,7 +713,7 @@ The app requests no `INTERNET` permission and performs no analytics or sync. - A rooted or fully compromised OS can observe process memory, UI input, or execute code in the app's security context. Encly cannot provide a trustworthy boundary against a hostile kernel / system image. -- A numeric PIN has finite entropy (10^6 values). Off the device, a copied PIN slot is useless +- A numeric PIN has finite entropy (10^6 to 10^12 values, by its length). Off the device, a copied PIN slot is useless without the Keystore factor. **On** a rooted device that is unlocked, an attacker can drive the Keystore key from the app's own security context: guesses then run at the secure hardware's HMAC speed (StrongBox is much slower than the TEE), the PBKDF2 half can be diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1908888f..c2d76fbe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import dev.zacsweers.metro.gradle.OptionalBindingBehavior import java.util.Properties // Minimum line coverage (%) of the whole app (generated code excluded) enforced by koverVerify. @@ -37,21 +38,18 @@ val hasReleaseSigning = listOf( plugins { alias(libs.plugins.android.application) - alias(libs.plugins.hilt) - alias(libs.plugins.ksp) + alias(libs.plugins.metro) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.detekt) - alias(libs.plugins.kover) + // detekt (with the Compose rules) and Kover, from build-logic. + id("encly.quality") } -// Static analysis (default rules + Jetpack Compose rules, config/detekt/detekt.yml). Legacy +// Static analysis (default rules + Jetpack Compose rules, config/detekt/detekt.yml; the rest of the +// setup is in encly.quality). Legacy // findings are captured in detekt-baseline.xml so only NEW issues fail. Fix new findings instead // of regenerating the baseline; it should only ever shrink. detekt { - buildUponDefaultConfig = true - parallel = true - config.setFrom(files("$rootDir/config/detekt/detekt.yml")) baseline = file("$projectDir/detekt-baseline.xml") } @@ -65,12 +63,14 @@ android { targetSdk = 36 versionCode = appVersionCode versionName = appVersionName - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } packaging { jniLibs { useLegacyPackaging = false + // The native libraries come stripped from their AARs. Stripping again would depend on whether the build + // machine has an NDK, and the APK must be byte for byte the same on F-Droid's server as on CI. + keepDebugSymbols += "**/*.so" } resources { excludes += listOf( @@ -116,11 +116,12 @@ android { } } - // Keep only the app's own 9 languages in the APK and bundle, so the store listing does not + // Keep only the app's own 10 languages in the APK and bundle, so the store listing does not // advertise the ~85 languages AndroidX libraries ship strings for. Keep in sync with the - // values-* folders, res/xml/locales_config.xml and AppLanguage (TranslationCompletenessTest). + // values-* folders of :core:resources (src/commonMain/strings), res/xml/locales_config.xml and AppLanguage + // (TranslationCompletenessTest). androidResources { - localeFilters += listOf("en", "uk", "de", "fr", "es", "it", "pl", "pt", "nl") + localeFilters += listOf("en", "uk", "de", "fr", "es", "it", "pl", "pt", "nl", "ar") } // Google Play serves the `play` bundle (bundlePlayRelease). Language splits stay off: Play @@ -164,10 +165,6 @@ android { } } - hilt { - enableAggregatingTask = true - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 @@ -198,16 +195,14 @@ android { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } } +} - ksp { - // Exported Room schemas are committed (app/schemas) so every schema change is reviewed - // and MigrationTestHelper can validate upgrades in androidTest. - arg("room.schemaLocation", "$projectDir/schemas") - } - - sourceSets { - getByName("androidTest").assets.srcDir("$projectDir/schemas") - } +// Dependency injection (compiler plugin): the graph is validated at compile time. +metro { + // A Kotlin default on an injected constructor parameter stays a plain default: the parameter + // is always taken from the graph, and a missing binding is an error instead of silently + // falling back to the default (e.g. a lock event source that never locks). + optionalBindingBehavior.set(OptionalBindingBehavior.DISABLED) } composeCompiler { @@ -222,13 +217,16 @@ tasks.configureEach { } } -// StoreMetadataTest and CodeQualityToolingTest read files outside the module; declare them as -// inputs so an edited store text, changelog, workflow or tool config re-runs the unit tests -// instead of leaving them UP-TO-DATE. +// StoreMetadataTest, TranslationCompletenessTest and CodeQualityToolingTest read files outside the +// module; declare them as inputs so an edited store text, translation, changelog, workflow or tool +// config re-runs the unit tests instead of leaving them UP-TO-DATE. tasks.withType().configureEach { inputs.dir(rootProject.file("fastlane/metadata/android")) .withPropertyName("storeMetadata") .withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir(rootProject.file("core/resources/src/commonMain/strings")) + .withPropertyName("translations") + .withPathSensitivity(PathSensitivity.RELATIVE) inputs.files( rootProject.file("CHANGELOG.md"), rootProject.file("version.properties"), @@ -241,12 +239,14 @@ tasks.withType().configureEach { inputs.files( rootProject.file(".github/workflows/android.yml"), rootProject.file("config/detekt/detekt.yml"), + rootProject.file("build-logic/src/main/kotlin/encly.quality.gradle.kts"), file("lint.xml"), ).withPropertyName("qualityConfig").withPathSensitivity(PathSensitivity.RELATIVE) } // Coverage: `./gradlew :app:koverXmlReportFdroidDebug :app:koverVerifyFdroidDebug`. -// The gate is scoped to core/security, the code that guards the key hierarchy. +// The gate covers what the app itself holds: the activities, the graph and the Android adapters of the +// platform interfaces. The domain, the data logic and the UI are :shared (its own 85 % gate). kover { reports { variant("fdroidDebug") { @@ -258,17 +258,20 @@ kover { "*.R", "*.R$*", "*ComposableSingletons*", - "*Hilt_*", - "*_HiltModules*", - "*_Factory", - "*_Factory$*", - "*_MembersInjector", - "*_GeneratedInjector", + "*MetroFactory*", + "*MetroContributionTo*", + "*MetroMembersInjector*", + "*AppGraph\$Impl*", "*_Impl", "*_Impl$*", - "*Dagger*", + "*\$DefaultImpls", + // The system BiometricPrompt and its callbacks need a device (androidx.biometric shows + // a dialog Robolectric cannot answer); what wraps and unwraps the key around them is + // tested in BiometricManagerTest. + "*KeystoreBiometricManager*", ) - packages("dagger.hilt", "hilt_aggregated_deps") + // The graph's binding containers only declare which implementation answers an interface. + annotatedBy("dev.zacsweers.metro.BindingContainer") } } verify { @@ -281,13 +284,27 @@ kover { } dependencies { + // The shared layers (Kotlin Multiplatform libraries): texts and flags, crypto, the security core. + implementation(project(":core:common")) + implementation(project(":core:crypto")) + implementation(project(":core:security")) + + // The domain, the repositories, the backup / import / export logic and the whole UI (Compose Multiplatform); + // the app implements the platform interfaces they declare (documents, links, window, clipboard...). + implementation(project(":shared")) + + // The block editor (Kotlin Multiplatform library); the app binds its extension points. + implementation(project(":editor")) + // The encrypted vault database (Room + Sealite): entities, DAOs, migrations, SecureDatabaseManager. + implementation(project(":core:database")) + // Every string and plural the UI shows (Compose Multiplatform resources, generated Res class). + implementation(project(":core:resources")) // Jetpack Compose: every androidx.compose module takes its version from the BOM. val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) debugImplementation(composeBom) testImplementation(composeBom) - androidTestImplementation(composeBom) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -298,18 +315,12 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.foundation) - implementation(libs.androidx.navigation.compose) debugImplementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.core.splashscreen) - // Drag and Drop - implementation(libs.reorderable) - - // Room + // Room (the schema and the compiler are in :core:database) implementation(libs.androidx.room.ktx) implementation(libs.androidx.core.ktx) - ksp(libs.androidx.room.compiler) // Coroutines implementation(libs.kotlinx.coroutines.core) @@ -318,23 +329,22 @@ dependencies { // Biometric Authentication implementation(libs.androidx.biometric) - // Dagger Hilt - Use KSP consistently - implementation(libs.hilt.android) - ksp(libs.hilt.compiler) + // Metro: compile-time dependency injection (compiler plugin) and its ViewModel integration + implementation(libs.metrox.viewmodel) + implementation(libs.metrox.viewmodel.compose) // Other dependencies - implementation(libs.gson) implementation(libs.androidx.datastore.preferences) // Serialization implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.json.io) - // Cipher - implementation(libs.sqlcipher.android) - implementation(libs.androidx.sqlite.ktx) - implementation(libs.kotlin.bip39) + // Bytes and streams (Source / Sink), so import, export and hand-off do not depend on java.io + implementation(libs.kotlinx.io.core) - implementation(libs.icons.lucide) + // Date and time + implementation(libs.kotlinx.datetime) // detekt rule set for Jetpack Compose (Apache-2.0); runs only inside the detekt task. detektPlugins(libs.compose.rules.detekt) @@ -348,10 +358,4 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) - - // Instrumented tests: native SQLCipher open/lock under WAL and the exported Room schema. - androidTestImplementation(libs.androidx.test.core) - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.androidx.room.testing) } diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 680b67fb..72fbc687 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -2,148 +2,6 @@ - ComplexCondition:ListBlock.kt$firstItem != null && firstItem.value.isEmpty() && itemsList.size == 1 && focusedItemIndex == null - CyclomaticComplexMethod:BlockSerialization.kt$BlockDeserializer$override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Block - CyclomaticComplexMethod:EditNoteScreen.kt$@OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, ) @Composable fun EditNoteScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: EditNoteViewModel = hiltViewModel(), ) - CyclomaticComplexMethod:EditTagScreen.kt$@Composable private fun TagEditItem( item: Tag, onAction: (TagEditTypeAction, Tag) -> Unit, globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, onFocusChange: (Boolean) -> Unit, shouldPreventFocus: Boolean, isDragging: Boolean = false, ) - CyclomaticComplexMethod:ListBlock.kt$@Composable fun ListBlock( block: Block.ListBlock, blockActions: BlockActions, index: Int, modifier: Modifier = Modifier, isLocked: Boolean = false, ) - CyclomaticComplexMethod:NoteSubTitle.kt$@Composable fun NoteSubTitle( statusSaveNote: SaveStatusNote, note: Note, modifier: Modifier = Modifier, tagButtonPadding: PaddingValues = PaddingValues(0.dp), editTagListViewModel: TagListViewModel = hiltViewModel(), tagsViewListen: ((Boolean) -> Unit)? = null, changeTag: ((Long) -> Unit)? = null, isDuplicate: Boolean = false, ) - CyclomaticComplexMethod:NotesTextFormatter.kt$NotesTextFormatter$fun blocksToPlainText(blocks: List<Block>): String - CyclomaticComplexMethod:TasksScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TasksScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: TasksViewModel = hiltViewModel(), ) - LongMethod:AboutScreen.kt$@Composable private fun DeveloperActionsSection(context: Context) - LongMethod:ActionLinkBottomSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ActionLinkBottomSheet( settings: SettingsBlockDialog, sheetState: SheetState, onAction: (ActionBlockDialog) -> Unit, onDismiss: () -> Unit, ) - LongMethod:EditNoteBottomSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditNoteBottomSheet( isVisible: Boolean, onDismiss: () -> Unit, viewModel: EditNoteViewModel = hiltViewModel<EditNoteViewModel>(), onAction: (EditNoteBottomSheetAction) -> Unit = {}, ) - LongMethod:EditNoteScreen.kt$@OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, ) @Composable fun EditNoteScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: EditNoteViewModel = hiltViewModel(), ) - LongMethod:EditTagScreen.kt$@Composable private fun TagEditItem( item: Tag, onAction: (TagEditTypeAction, Tag) -> Unit, globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, onFocusChange: (Boolean) -> Unit, shouldPreventFocus: Boolean, isDragging: Boolean = false, ) - LongMethod:EditTagScreen.kt$@OptIn(ExperimentalFoundationApi::class) @SuppressLint("MutableCollectionMutableState") @Composable private fun ListTagsEdit( globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, modifier: Modifier = Modifier, viewModel: TagListViewModel = hiltViewModel(), ) - LongMethod:HeroCard.kt$@Composable fun HeroCard(icon: HeroIcon, title: String, subtitle: String, description: String, modifier: Modifier = Modifier) - LongMethod:HomeBar.kt$@Composable fun HomeBar( drawerState: DrawerState, isGrid: Boolean, onToggleView: () -> Unit, showSortDialog: () -> Unit, modifier: Modifier = Modifier, ) - LongMethod:ListBlock.kt$@Composable fun ListBlock( block: Block.ListBlock, blockActions: BlockActions, index: Int, modifier: Modifier = Modifier, isLocked: Boolean = false, ) - LongMethod:LossRecoveryScreen.kt$@Composable fun LossRecoveryScreen(onRecoveryConfirm: () -> Unit, modifier: Modifier = Modifier) - LongMethod:MainScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( navController: NavHostController, drawerState: DrawerState, modifier: Modifier = Modifier, mainListStateViewModel: MainListStateViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel(), noteListViewModel: NoteListViewModel = hiltViewModel(), ) - LongMethod:ModalBoxItem.kt$@Composable fun ModalBoxItem( title: String, modifier: Modifier = Modifier, roundPosition: RoundPosition = RoundPosition.Full, icon: Painter = painterResource(id = R.drawable.ic_about), active: Boolean = false, next: Boolean = false, confirmationRequest: Color? = null, action: () -> Unit = {}, enable: Boolean = true, checked: Boolean = false, ) - LongMethod:NavHost.kt$@Composable fun AppNavHost(navController: NavHostController, startDestination: String = NavRoutes.HomeRoute.name) - LongMethod:NoteBottomBar.kt$@Composable fun NoteBottomBar( modifier: Modifier = Modifier, viewModel: EditNoteViewModel = hiltViewModel(), simpleEdit: Boolean = false, ) - LongMethod:NoteCardBottomSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun NoteCardBottomSheet( isVisible: Boolean, item: NoteWithTag, onAction: (NoteAction) -> Unit, sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), onDismiss: () -> Unit, ) - LongMethod:NoteSubTitle.kt$@Composable fun NoteSubTitle( statusSaveNote: SaveStatusNote, note: Note, modifier: Modifier = Modifier, tagButtonPadding: PaddingValues = PaddingValues(0.dp), editTagListViewModel: TagListViewModel = hiltViewModel(), tagsViewListen: ((Boolean) -> Unit)? = null, changeTag: ((Long) -> Unit)? = null, isDuplicate: Boolean = false, ) - LongMethod:NotesListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun NotesList( isGrid: Boolean, listScrollState: LazyListState, gridScrollState: LazyStaggeredGridState, onItemClick: (Note) -> Unit, modifier: Modifier = Modifier, onSecondActionClick: (Note) -> Unit = {}, noteListViewModel: NoteListViewModel = hiltViewModel(), ) - LongMethod:SecuritySettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SecuritySettingsScreen( navController: NavHostController, modifier: Modifier = Modifier, securityViewModel: SecuritySettingsViewModel = hiltViewModel(), vaultViewModel: BackupViewModel = hiltViewModel(), ) - LongMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( navController: NavHostController?, modifier: Modifier = Modifier, settingViewState: SettingsViewModel = hiltViewModel(), securityDisable: Boolean = false, ) - LongMethod:TaskProgressCard.kt$@Composable fun TaskProgressCard( completionPercentage: Int, completedTasksCount: Int, totalTasksCount: Int, modifier: Modifier = Modifier, ) - LongMethod:TasksScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TasksScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: TasksViewModel = hiltViewModel(), ) - LongMethod:TrashNotesList.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun TrashNotesList( onItemClick: (Int, Note) -> Unit, modifier: Modifier = Modifier, trashViewModel: TrashViewModel = hiltViewModel(), ) - LongMethod:TrashScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TrashScreen( navController: NavHostController, modifier: Modifier = Modifier, trashViewModel: TrashViewModel = hiltViewModel(), ) - LongParameterList:EditNoteScreen.kt$( isReadTrashOnly: Boolean, lockEditor: Boolean, onBackClick: () -> Unit, onRestoreClick: () -> Unit, onDeleteClick: () -> Unit, onMenuClick: () -> Unit, onLockToggle: () -> Unit, onDoneClick: () -> Unit, ) - LongParameterList:EditTagScreen.kt$( item: Tag, onAction: (TagEditTypeAction, Tag) -> Unit, globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, onFocusChange: (Boolean) -> Unit, shouldPreventFocus: Boolean, isDragging: Boolean = false, ) - MagicNumber:AuthenticationManager.kt$AuthenticationManager$15 - MagicNumber:AuthenticationManager.kt$AuthenticationManager$256 - MagicNumber:AuthenticationManager.kt$AuthenticationManager$30_000L - MagicNumber:AuthenticationManager.kt$AuthenticationManager$5 - MagicNumber:AuthenticationManager.kt$AuthenticationManager$60_000L - MagicNumber:Color.kt$0xFF000000 - MagicNumber:Color.kt$0xFF00201E - MagicNumber:Color.kt$0xFF003735 - MagicNumber:Color.kt$0xFF0C0F09 - MagicNumber:Color.kt$0xFF102000 - MagicNumber:Color.kt$0xFF12140E - MagicNumber:Color.kt$0xFF151E0B - MagicNumber:Color.kt$0xFF1A1C16 - MagicNumber:Color.kt$0xFF1E201A - MagicNumber:Color.kt$0xFF1F3701 - MagicNumber:Color.kt$0xFF1F4E4B - MagicNumber:Color.kt$0xFF282B24 - MagicNumber:Color.kt$0xFF2A331E - MagicNumber:Color.kt$0xFF2F312A - MagicNumber:Color.kt$0xFF33362E - MagicNumber:Color.kt$0xFF354E16 - MagicNumber:Color.kt$0xFF383A32 - MagicNumber:Color.kt$0xFF386663 - MagicNumber:Color.kt$0xFF404A33 - MagicNumber:Color.kt$0xFF410002 - MagicNumber:Color.kt$0xFF44483D - MagicNumber:Color.kt$0xFF4C662B - MagicNumber:Color.kt$0xFF586249 - MagicNumber:Color.kt$0xFF690005 - MagicNumber:Color.kt$0xFF75796C - MagicNumber:Color.kt$0xFF8F9285 - MagicNumber:Color.kt$0xFF93000A - MagicNumber:Color.kt$0xFFA0D0CB - MagicNumber:Color.kt$0xFFB1D18A - MagicNumber:Color.kt$0xFFBA1A1A - MagicNumber:Color.kt$0xFFBCECE7 - MagicNumber:Color.kt$0xFFBFCBAD - MagicNumber:Color.kt$0xFFC5C8BA - MagicNumber:Color.kt$0xFFCDEDA3 - MagicNumber:Color.kt$0xFFDADBD0 - MagicNumber:Color.kt$0xFFDCE7C8 - MagicNumber:Color.kt$0xFFE1E4D5 - MagicNumber:Color.kt$0xFFE2E3D8 - MagicNumber:Color.kt$0xFFE8E9DE - MagicNumber:Color.kt$0xFFEEEFE3 - MagicNumber:Color.kt$0xFFF1F2E6 - MagicNumber:Color.kt$0xFFF3F4E9 - MagicNumber:Color.kt$0xFFF9FAEF - MagicNumber:Color.kt$0xFFFFB4AB - MagicNumber:Color.kt$0xFFFFDAD6 - MagicNumber:Color.kt$0xFFFFFFFF - MagicNumber:EditNoteViewModel.kt$EditNoteViewModel$5000 - MagicNumber:EditTagScreen.kt$20 - MagicNumber:FontSizeUtils.kt$FontSizeUtils$10 - MagicNumber:FontSizeUtils.kt$FontSizeUtils$4 - MagicNumber:FontSizeUtils.kt$FontSizeUtils$6 - MagicNumber:FontSizeUtils.kt$FontSizeUtils$8 - MagicNumber:ListBlock.kt$0.7f - MagicNumber:ListBlock.kt$50 - MagicNumber:MainDrawer.kt$0.8f - MagicNumber:MainDrawer.kt$600 - MagicNumber:MainListStateViewModel.kt$MainListStateViewModel$5_000 - MagicNumber:ModalBoxItem.kt$0.5f - MagicNumber:ModalBoxItem.kt$0.8f - MagicNumber:ModalBoxItem.kt$5000L - MagicNumber:NoteCardBottomSheet.kt$300 - MagicNumber:NoteSkeleton.kt$0.7f - MagicNumber:NoteSkeleton.kt$0.9f - MagicNumber:NoteSubTitle.kt$200 - MagicNumber:NotesListScreen.kt$200 - MagicNumber:NotesListScreen.kt$4 - MagicNumber:PinCodeWidget.kt$3 - MagicNumber:SettingBox.kt$0.5f - MagicNumber:SettingBox.kt$0.8f - MagicNumber:SettingBox.kt$20 - MagicNumber:SettingsLocalDataSource.kt$SettingsLocalDataSource$10 - MagicNumber:SettingsLocalDataSource.kt$SettingsLocalDataSource$16 - MagicNumber:SettingsLocalDataSource.kt$SettingsLocalDataSource$32 - MagicNumber:StatisticViewModel.kt$StatisticViewModel$5000 - MagicNumber:TagItem.kt$0.6f - MagicNumber:TaskItem.kt$0.8f - MagicNumber:TaskProgressCard.kt$100f - MagicNumber:ThemeViewModel.kt$ThemeViewModel$5_000 - MatchingDeclarationName:EditNoteBottomSheet.kt$EditNoteBottomSheetAction - MatchingDeclarationName:EditTagScreen.kt$TagEditTypeAction - MatchingDeclarationName:HeroCard.kt$HeroIcon - MatchingDeclarationName:InfoSnackbar.kt$SnackType - MatchingDeclarationName:NavigationDrawerStyleButton.kt$StyleButton - MatchingDeclarationName:NoteBottomBar.kt$NoteBottomBarFragment - MatchingDeclarationName:NoteCardBottomSheet.kt$NoteAction - MatchingDeclarationName:SettingBox.kt$RoundPosition - MatchingDeclarationName:TasksScreen.kt$TasksScreenUiState - MatchingDeclarationName:ThemeItems.kt$RadioItem - MatchingDeclarationName:TrashScreen.kt$DialogTrashAction - NestedBlockDepth:NotesTextFormatter.kt$NotesTextFormatter$fun blocksToPlainText(blocks: List<Block>): String - ReturnCount:SecurityManager.kt$SecurityManager$private fun initializeSecurity(): InitialStatus TooGenericExceptionCaught:MyApplication.kt$MyApplication$e: Exception - TooGenericExceptionCaught:SecureDatabaseManager.kt$SecureDatabaseManager$e: Exception - TooManyFunctions:AuthenticationManager.kt$AuthenticationManager - TooManyFunctions:BlockOperations.kt$BlockOperations - TooManyFunctions:EditNoteViewModel.kt$EditNoteViewModel : ViewModel - TooManyFunctions:OnboardingViewModel.kt$OnboardingViewModel : ViewModel - TooManyFunctions:SecurityManager.kt$SecurityManager - TooManyFunctions:SeedPhraseManager.kt$SeedPhraseManager - TooManyFunctions:TasksDao.kt$TasksDao - TooManyFunctions:TasksRepositoryImpl.kt$TasksRepositoryImpl : TasksRepository - TooManyFunctions:TasksViewModel.kt$TasksViewModel : ViewModel - UnusedPrivateProperty:DeviceCapabilities.kt$DeviceCapabilities$@param:ApplicationContext private val context: Context - UnusedPrivateProperty:SettingsBuilder.kt$SettingsBuilder$private val deviceCapabilities: DeviceCapabilities - UseRequire:BlockSerialization.kt$BlockSerializer$throw IllegalArgumentException("Block cannot be null") diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 55ce535c..3a778e14 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -56,17 +56,6 @@ column="18"/> - - - - diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e03cb38d..8b903a60 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -2,18 +2,17 @@ # # Deliberately almost empty: everything the app needs kept at runtime is already covered by the # consumer rules the libraries ship, and the app has no reflective code paths of its own. -# - SQLCipher (JNI classes and native methods): sqlcipher-android's proguard.txt. +# - SQLCipher (JNI classes and native methods): Sealite's consumer rules. # - Room: the KSP-generated *_Impl classes, instantiated reflectively by class name, are # kept by room-runtime's rules. -# - Hilt / Dagger, Lifecycle ViewModels, Navigation, Compose, DataStore: their own -# consumer rules. -# - Gson: gson.jar ships the TypeToken/Signature rules. Note blocks are written and read -# field by field in BlockSerializer/BlockDeserializer, never by reflection, so no model -# class needs its field names kept (BlockConverterTest pins the stored key names). +# - Metro (generated at compile time, no reflection), Lifecycle ViewModels, Navigation, Compose, +# DataStore: their own consumer rules. +# - Note blocks (BlockJson) are written and read key by key with the kotlinx.serialization +# JSON tree, never by reflection, so no model class needs its field names kept +# (BlockConverterTest and BlockJsonFormatTest pin the stored format). # - kotlinx.serialization: backup classes use the plugin-generated serializers, called # explicitly (BackupPayload.serializer()); the key names are string constants in that # generated code, and the library ships its own rules (BackupPayloadCodecTest pins them). -# - kotlin-bip39: the wordlist is compiled Kotlin code, not a resource loaded by name. # Add a rule here only for a concrete reflective, JNI or by-name lookup, next to a comment # saying which one. diff --git a/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt b/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt deleted file mode 100644 index 5d0869c0..00000000 --- a/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.pasich.encly.data.database - -import androidx.room.testing.MigrationTestHelper -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Validates the committed Room schema export (app/schemas) against the compiled entities. - * - * Every future DB_VERSION bump must add its migration and a `runMigrationsAndValidate` case - * from the previous exported version here. The vault builder (VaultSchema.databaseBuilder) has - * no destructive fallback, so a missing migration crashes instead of silently dropping the - * user's encrypted notes. - * The helper uses a plain (non-SQLCipher) test database: it checks the schema, not encryption. - */ -@RunWith(AndroidJUnit4::class) -class AppDatabaseSchemaTest { - - @get:Rule - val helper = MigrationTestHelper( - InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java, - ) - - @Test - fun currentSchemaMatchesExport() { - helper.createDatabase(TEST_DB, DB_VERSION).close() - helper.runMigrationsAndValidate(TEST_DB, DB_VERSION, true).close() - } - - @Test - fun migrate2To3DropsReminderAndKeepsTasks() { - helper.createDatabase(TEST_DB, 2).use { db -> - db.execSQL( - "INSERT INTO tasks (id, title, description, isCompleted, createdDate, completedDate, " + - "reminderDate, priority, categoryId, position, uid) " + - "VALUES (7, 'Buy milk', '2 l', 0, 100, NULL, 200, 2, NULL, 3, 'task-uid')", - ) - } - - helper.runMigrationsAndValidate(TEST_DB, 3, true, VaultSchema.MIGRATION_2_3).use { db -> - db.query("SELECT id, title, description, priority, position, uid FROM tasks").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(7L, c.getLong(0)) - assertEquals("Buy milk", c.getString(1)) - assertEquals("2 l", c.getString(2)) - assertEquals(2, c.getInt(3)) - assertEquals(3, c.getInt(4)) - assertEquals("task-uid", c.getString(5)) - } - db.query("PRAGMA table_info(tasks)").use { c -> - val nameIndex = c.getColumnIndexOrThrow("name") - while (c.moveToNext()) assertNotEquals("reminderDate", c.getString(nameIndex)) - } - - // The uid triggers are rebuilt with the table: a blank uid is still filled in. - db.execSQL( - "INSERT INTO tasks (title, isCompleted, createdDate, priority, position) VALUES ('x', 0, 1, 0, 0)", - ) - db.query("SELECT uid FROM tasks WHERE title = 'x'").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(32, c.getString(0).length) - } - } - } - - @Test - fun migrate3To4AddsSubtasksWithTriggersAndCascade() { - helper.createDatabase(TEST_DB, 3).use { db -> - db.execSQL( - "INSERT INTO tasks (id, title, isCompleted, createdDate, priority, position, uid) " + - "VALUES (7, 'Buy milk', 0, 100, 2, 3, 'task-uid')", - ) - } - - helper.runMigrationsAndValidate(TEST_DB, 4, true, VaultSchema.MIGRATION_3_4).use { db -> - db.query("SELECT id, title, uid FROM tasks").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(7L, c.getLong(0)) - assertEquals("Buy milk", c.getString(1)) - assertEquals("task-uid", c.getString(2)) - } - db.query("SELECT COUNT(*) FROM subtasks").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(0, c.getInt(0)) - } - - // A blank uid is filled in, and a later blank update keeps it. - db.execSQL("INSERT INTO subtasks (taskId, title, isCompleted, position) VALUES (7, 'Oat', 0, 0)") - val uid = db.query("SELECT uid FROM subtasks").use { c -> - assertTrue(c.moveToFirst()) - c.getString(0) - } - assertEquals(32, uid.length) - db.execSQL("UPDATE subtasks SET uid = '' WHERE taskId = 7") - db.query("SELECT uid FROM subtasks").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(uid, c.getString(0)) - } - - // Deleting the task deletes its sub-tasks (Room turns foreign keys on when it opens the vault). - db.execSQL("PRAGMA foreign_keys = ON") - db.execSQL("DELETE FROM tasks WHERE id = 7") - db.query("SELECT COUNT(*) FROM subtasks").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(0, c.getInt(0)) - } - } - } - - @Test - fun migrate4To5KeepsNotesUnpinnedUnarchivedUncoloured() { - helper.createDatabase(TEST_DB, 4).use { db -> - db.execSQL( - "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + - "VALUES (3, 'Groceries', '[]', 'milk', 200, 100, NULL, 1, 'note-uid')", - ) - } - - helper.runMigrationsAndValidate(TEST_DB, 5, true, VaultSchema.MIGRATION_4_5).use { db -> - db.query( - "SELECT title, isTrash, uid, isPinned, isArchived, color FROM notes WHERE id = 3", - ).use { c -> - assertTrue(c.moveToFirst()) - assertEquals("Groceries", c.getString(0)) - assertEquals(1, c.getInt(1)) - assertEquals("note-uid", c.getString(2)) - assertEquals(0, c.getInt(3)) - assertEquals(0, c.getInt(4)) - assertEquals("none", c.getString(5)) - } - - // A note inserted by older-style code (no new columns) still gets the defaults. - db.execSQL( - "INSERT INTO notes (title, value, description, date, dateCreate, isTrash) " + - "VALUES ('n', '[]', '', 1, 1, 0)", - ) - db.query("SELECT isPinned, isArchived, color, uid FROM notes WHERE title = 'n'").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(0, c.getInt(0)) - assertEquals(0, c.getInt(1)) - assertEquals("none", c.getString(2)) - assertEquals(32, c.getString(3).length) - } - } - } - - @Test - fun migrate5To6AddsNullableTextSettings() { - helper.createDatabase(TEST_DB, 5).use { db -> - db.execSQL( - "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid, " + - "isPinned, isArchived, color) VALUES (3, 'Groceries', '[]', 'milk', 200, 100, NULL, 0, " + - "'note-uid', 1, 0, 'red')", - ) - } - - helper.runMigrationsAndValidate(TEST_DB, 6, true, VaultSchema.MIGRATION_5_6).use { db -> - db.query("SELECT title, isPinned, color, textSize, lineSpacing FROM notes WHERE id = 3").use { c -> - assertTrue(c.moveToFirst()) - assertEquals("Groceries", c.getString(0)) - assertEquals(1, c.getInt(1)) - assertEquals("red", c.getString(2)) - assertTrue(c.isNull(3)) - assertTrue(c.isNull(4)) - } - } - } - - @Test - fun migrate1ToCurrentRunsTheWholeChain() { - helper.createDatabase(TEST_DB, 1).close() - - helper.runMigrationsAndValidate( - TEST_DB, - DB_VERSION, - true, - VaultSchema.MIGRATION_1_2, - VaultSchema.MIGRATION_2_3, - VaultSchema.MIGRATION_3_4, - VaultSchema.MIGRATION_4_5, - VaultSchema.MIGRATION_5_6, - ).close() - } - - private companion object { - const val TEST_DB = "schema-test.db" - } -} diff --git a/app/src/androidTest/java/com/pasich/encly/data/database/TagsDaoTest.kt b/app/src/androidTest/java/com/pasich/encly/data/database/TagsDaoTest.kt deleted file mode 100644 index 180b68b5..00000000 --- a/app/src/androidTest/java/com/pasich/encly/data/database/TagsDaoTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.pasich.encly.data.database - -import androidx.room.Room -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.Tag -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -/** Deleting a tag must not leave notes pointing at it. Plain (non-SQLCipher) in-memory DB. */ -@RunWith(AndroidJUnit4::class) -class TagsDaoTest { - private lateinit var db: AppDatabase - - @Before - fun setUp() { - db = VaultSchema.install( - Room.inMemoryDatabaseBuilder( - InstrumentationRegistry.getInstrumentation().targetContext, - AppDatabase::class.java, - ), - ).build() - } - - @After - fun tearDown() { - db.close() - } - - @Test - fun deletingATagUntagsItsNotesAndLeavesOthersAlone() = runBlocking { - val work = db.tagsDao().addTag(Tag(nameTag = "Work")) - val home = db.tagsDao().addTag(Tag(nameTag = "Home")) - val workNote = db.notesDao().insertNote(Note(title = "w", tagId = work)) - val homeNote = db.notesDao().insertNote(Note(title = "h", tagId = home)) - - assertEquals(1, db.tagsDao().deleteTagDetachingNotes(Tag(id = work, nameTag = "Work"))) - - assertNull(db.notesDao().getNoteById(workNote)?.tagId) - assertEquals(home, db.notesDao().getNoteById(homeNote)?.tagId) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4aea2d1e..c94d191c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,7 @@ android:label="@string/app_name" android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" android:theme="@style/Theme.Encly.Splash" tools:targetApi="34"> @@ -104,22 +114,20 @@ class ImportFromMyNotesActivity : AppCompatActivity() { themeReady = true } lifecycleScope.launch { viewModel.step.collect(::publishResult) } - viewModel.start(uri) + viewModel.start(DocumentRef(uri.toString())) setContent { if (!themeReady) return@setContent val strictKeyboard by keyboardPrivacy.strict.collectAsState() - AppTheme { - SecureTextInputBoundary(strict = strictKeyboard) { - SensitiveClipboardBoundary { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .windowInsetsPadding(WindowInsets.statusBars), - ) { - ImportFromMyNotesScreen(onClose = ::finish) - } + hostServices.Provide(this, strictKeyboard) { + AppTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding(WindowInsets.statusBars), + ) { + ImportFromMyNotesScreen(onClose = ::finish) } } } diff --git a/app/src/main/java/com/pasich/encly/MainActivity.kt b/app/src/main/java/com/pasich/encly/MainActivity.kt index 79c17b3d..831e53b9 100644 --- a/app/src/main/java/com/pasich/encly/MainActivity.kt +++ b/app/src/main/java/com/pasich/encly/MainActivity.kt @@ -9,61 +9,33 @@ import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalView import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavController -import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController +import com.pasich.encly.core.di.appGraph +import com.pasich.encly.core.locale.AppLocales +import com.pasich.encly.core.security.BiometricTextSource import com.pasich.encly.core.security.InitialStatus import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.domain.repository.SettingsRepository -import com.pasich.encly.presentation.components.SecureTextInputBoundary -import com.pasich.encly.presentation.components.SensitiveClipboardBoundary -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.LocalEraseEpoch -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.navigation.RelockReturn -import com.pasich.encly.presentation.navigation.SessionResume -import com.pasich.encly.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint +import com.pasich.encly.platform.AndroidHostServices +import com.pasich.encly.presentation.navigation.EnclyApp +import dev.zacsweers.metro.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.IOException -import javax.inject.Inject /** * AppCompatActivity (a FragmentActivity, as BiometricPrompt needs) so the in-app language set * through AppCompatDelegate.setApplicationLocales is applied on every API level, not only on * Android 13+ where the framework does it. */ -@AndroidEntryPoint class MainActivity : AppCompatActivity() { @Inject @@ -78,7 +50,14 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var keyboardPrivacy: KeyboardPrivacy + @Inject + lateinit var biometricTexts: BiometricTextSource + + @Inject + lateinit var hostServices: AndroidHostServices + override fun onCreate(savedInstanceState: Bundle?) { + appGraph.inject(this) // Encly always renders protected content. Apply FLAG_SECURE before the splash/content // lifecycle starts so screenshots, screen recording, casting and recents snapshots // cannot capture an unprotected first frame. @@ -87,6 +66,8 @@ class MainActivity : AppCompatActivity() { stripNavigationExtras(intent) val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + // The strings follow the process's default locale: put the in-app language there before composing. + AppLocales.syncDefaultLocale() // After super.onCreate: touching decorView earlier builds the window with the splash // theme, which has an action bar, instead of Theme.Encly. protectWindow() @@ -113,38 +94,21 @@ class MainActivity : AppCompatActivity() { // A committed vault that is not open starts locked, before the first frame, so a back // stack restored after process death is replaced by the lock screen, never shown. if (status == InitialStatus.AUTH) sessionLockManager.requireUnlock() + // The system biometric prompt is built outside any coroutine: give it finished texts first. + biometricTexts.refresh() initialStatus = status } setContent { - val destination = initialStatus?.let(::startRoute) ?: return@setContent - val navController = rememberNavController() - - // Auto re-lock. Collected without a lifecycle gate so the value is already true in - // the first frame after returning from the background. That frame still composes - // the pre-lock destination (e.g. an open note), so the shield below covers the - // NavHost until the lock screen is the only visible entry. Navigation itself runs - // after the frame, from this effect. - val locked by sessionLockManager.locked.collectAsState() + // Auto re-lock and the navigation guards live in the shared UI (EnclyApp). val strictKeyboard by keyboardPrivacy.strict.collectAsState() - LockRouteGuard(navController, securityManager::isDatabaseUnlocked) - SessionResumeGuard(navController, sessionLockManager) - val visibleEntries by navController.visibleEntries.collectAsState() - val shielded = locked && visibleEntries.any { - it.destination.route != NavRoutes.LockRoute.name - } - LaunchedEffect(locked) { - if (locked) navController.showLockScreen() + hostServices.Provide(this, strictKeyboard) { + EnclyApp( + startStatus = initialStatus, + securityManager = securityManager, + sessionLockManager = sessionLockManager, + ) } - - App( - navController = navController, - startDestination = destination.name, - eraseEpoch = securityManager::eraseEpoch, - shielded = shielded, - locked = locked, - strictKeyboard = strictKeyboard, - ) } } @@ -170,82 +134,6 @@ internal fun Activity.protectWindow() { } } -/** Where the app starts for [status]; null while there is nothing to show yet. */ -private fun startRoute(status: InitialStatus): NavRoutes? = when (status) { - InitialStatus.MAIN -> NavRoutes.HomeRoute - - // PIN setup is part of onboarding, which restarts until the vault is committed. - InitialStatus.ONBOARDING, InitialStatus.SETUP_AUTH -> NavRoutes.OnboardingRoute - - InitialStatus.LOSS_DATABASE, InitialStatus.LOSS_CRYPTO -> NavRoutes.LossDataRoute - - InitialStatus.AUTH -> NavRoutes.LockRoute - - InitialStatus.LEGACY_VAULT -> NavRoutes.LegacyVaultRoute - - InitialStatus.NO -> null -} - -/** - * Central route guard: whatever tries to show a vault screen while the database is closed (a - * back stack restored after process death, a stale navigation) is sent to the lock screen. - * Navigating from inside the listener is deferred to the next main-thread turn. - */ -@Composable -private fun LockRouteGuard(navController: NavHostController, isDatabaseUnlocked: () -> Boolean) { - val view = LocalView.current - val currentIsDatabaseUnlocked by rememberUpdatedState(isDatabaseUnlocked) - DisposableEffect(navController) { - val guard = NavController.OnDestinationChangedListener { _, destination, _ -> - if (isProtectedRoute(destination.route) && !currentIsDatabaseUnlocked()) { - view.post { navController.showLockScreen() } - } - } - navController.addOnDestinationChangedListener(guard) - onDispose { navController.removeOnDestinationChangedListener(guard) } - } -} - -/** - * Coming back to the foreground after the vault was unlocked on another lock screen (see - * [SessionResume.afterStop]) rebuilds the back stack instead of showing screens of a session - * that ended. Runs on ON_START itself, before the first frame and the re-lock effect. - */ -@Composable -private fun SessionResumeGuard(navController: NavHostController, sessionLockManager: SessionLockManager) { - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, navController) { - var stoppedAt: Long? = null - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_STOP -> stoppedAt = sessionLockManager.sessionGeneration - - Lifecycle.Event.ON_START -> { - val resume = SessionResume.afterStop( - stoppedAt = stoppedAt, - generation = sessionLockManager.sessionGeneration, - locked = sessionLockManager.locked.value, - ) - stoppedAt = null - when (resume) { - SessionResume.KEEP -> Unit - - SessionResume.LOCK_SCREEN -> navController.showLockScreen(keepNote = false) - - SessionResume.HOME -> navController.navigate(NavRoutes.HomeRoute.name) { - popUpTo(navController.graph.id) { inclusive = true } - } - } - } - - else -> Unit - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } -} - /** Navigation's own deep-link extras; the exported launcher activity must not honour them. */ private const val NAV_EXTRA_PREFIX = "android-support-nav:controller:" @@ -253,121 +141,3 @@ private fun stripNavigationExtras(intent: Intent?) { val extras = intent?.extras ?: return extras.keySet().filter { it.startsWith(NAV_EXTRA_PREFIX) }.forEach(intent::removeExtra) } - -/** Screens that are reachable without an open vault; every other route shows vault content. */ -private val PUBLIC_ROUTES = setOf( - NavRoutes.LockRoute.name, - NavRoutes.OnboardingRoute.name, - NavRoutes.LossDataRoute.name, - NavRoutes.LegacyVaultRoute.name, -) - -private fun isProtectedRoute(route: String?): Boolean = route != null && route !in PUBLIC_ROUTES - -/** The editor's "opened from the trash, read-only" navigation argument (see AppNavHost). */ -private const val EDIT_NOTE_READ_ONLY_ARG = "isReadTrashOnly" - -/** - * Replaces every screen with the lock screen after a background re-lock, remembering the note - * that was open (see [RelockReturn]) with the erase epoch of the vault it was opened in, so the - * unlock returns to it only in that vault. Without [keepNote], or from any other screen, - * whatever was remembered before is forgotten. - */ -private fun NavHostController.showLockScreen(keepNote: Boolean = true) { - val editor = currentBackStackEntry?.takeIf { keepNote } - val returnRoute = editor?.let { entry -> - RelockReturn.routeFor( - destinationRoute = entry.destination.route, - openNoteId = entry.savedStateHandle.get(RelockReturn.OPEN_NOTE_ID), - readOnly = entry.arguments?.getBoolean(EDIT_NOTE_READ_ONLY_ARG) == true, - ) - } - val returnEpoch = editor?.savedStateHandle?.get(RelockReturn.OPEN_NOTE_EPOCH) - navigate(NavRoutes.LockRoute.name) { - // Drop every screen/ViewModel backed by the now-closed Room instance. - // Unlock starts a fresh Home graph with fresh DAO flows. - popUpTo(graph.id) { - inclusive = false - } - launchSingleTop = true - } - val lockState = currentBackStackEntry?.savedStateHandle ?: return - if (returnRoute != null && returnEpoch != null) { - lockState[RelockReturn.RETURN_ROUTE] = returnRoute - lockState[RelockReturn.RETURN_EPOCH] = returnEpoch - } else { - lockState.remove(RelockReturn.RETURN_ROUTE) - lockState.remove(RelockReturn.RETURN_EPOCH) - } -} - -@Composable -fun App( - navController: NavHostController, - startDestination: String, - eraseEpoch: () -> Long, - modifier: Modifier = Modifier, - shielded: Boolean = false, - locked: Boolean = false, - strictKeyboard: Boolean = false, -) { - AppTheme { - SecureTextInputBoundary(strict = strictKeyboard) { - SensitiveClipboardBoundary { - // Edge-to-edge is forced on Android 15+/targetSdk 36. The window is painted with - // the same Compose background the screens use; every screen draws under the - // transparent status bar and pads its own top inset (its top bar does), so it - // paints that band itself and the band moves with the screen in a transition. - Box( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - val unlockReveal = remember { UnlockRevealState() } - // A re-lock during the reveal ends it: no deferred navigation into the vault, - // no reveal left covering the lock screen. - LaunchedEffect(locked) { if (locked) unlockReveal.cancel() } - CompositionLocalProvider(LocalUnlockReveal provides unlockReveal) { - // The whole app layer moves with the unlock reveal: it slides in from the - // right and pushes the logo colour out (see UnlockRevealOverlay). - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { translationX = unlockReveal.contentOffset * size.width } - .background(MaterialTheme.colorScheme.background), - ) { - CompositionLocalProvider(LocalEraseEpoch provides eraseEpoch) { - AppNavHost( - navController = navController, - startDestination = startDestination, - ) - } - } - } - UnlockRevealOverlay(unlockReveal) - if (shielded) LockShield() - } - } - } - } -} - -/** - * Opaque, input-swallowing cover drawn above the NavHost while a re-locked session is still - * composing protected screens. Plaintext from before the lock is never drawn or tappable. - */ -@Composable -private fun LockShield() { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - awaitPointerEvent().changes.forEach { it.consume() } - } - } - }, - ) -} diff --git a/app/src/main/java/com/pasich/encly/MyApplication.kt b/app/src/main/java/com/pasich/encly/MyApplication.kt index eab4a26e..6aca4d2f 100644 --- a/app/src/main/java/com/pasich/encly/MyApplication.kt +++ b/app/src/main/java/com/pasich/encly/MyApplication.kt @@ -3,16 +3,23 @@ package com.pasich.encly import android.app.Application import androidx.lifecycle.ProcessLifecycleOwner import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.di.AppGraph +import com.pasich.encly.core.di.AppGraphOwner import com.pasich.encly.core.di.ApplicationScope +import com.pasich.encly.core.security.HandoffStagingSweeper import com.pasich.encly.core.security.SessionLockManager -import com.pasich.encly.data.handoff.HandoffStagingSweeper -import dagger.hilt.android.HiltAndroidApp +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.createGraphFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltAndroidApp -class MyApplication : Application() { +class MyApplication : + Application(), + AppGraphOwner { + + override val appGraph: AppGraph by lazy { + createGraphFactory().create(this) + } // Observes process foreground/background to auto-lock the app when it is backgrounded. @Inject @@ -27,6 +34,7 @@ class MyApplication : Application() { override fun onCreate() { super.onCreate() + appGraph.inject(this) try { ProcessLifecycleOwner.get().lifecycle.addObserver(sessionLockManager) } catch (e: Exception) { diff --git a/app/src/main/java/com/pasich/encly/core/backup/BackupCipher.kt b/app/src/main/java/com/pasich/encly/core/backup/BackupCipher.kt deleted file mode 100644 index 69c81e9c..00000000 --- a/app/src/main/java/com/pasich/encly/core/backup/BackupCipher.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.pasich.encly.core.backup - -import com.pasich.encly.core.security.SensitiveDataCleaner -import java.security.GeneralSecurityException -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec - -/** - * What the per-file key is derived from. Both forms describe the same recovery phrase; the - * caller keeps ownership of the wrapped arrays and wipes them. [BackupCipher] never retains them. - */ -sealed class BackupSecret { - /** The 12 BIP39 words, as typed (import on a fresh install or in Settings). */ - class RecoveryPhrase(val words: CharArray) : BackupSecret() - - /** The vault's stored `backupRoot` (export from an unlocked vault). See [BackupKeys]. */ - class RecoveryRoot(val root: ByteArray) : BackupSecret() -} - -/** - * Seals a plaintext payload into the [BackupFormat] container and opens it again. - * Derived keys are zeroized before returning, on success and on failure. - */ -object BackupCipher { - private const val TRANSFORMATION = "AES/GCM/NoPadding" - private val random = SecureRandom() - - fun seal(plaintext: ByteArray, secret: BackupSecret): ByteArray { - val salt = ByteArray(BackupFormat.SALT_LENGTH).also(random::nextBytes) - val nonce = ByteArray(BackupFormat.NONCE_LENGTH).also(random::nextBytes) - val header = BackupFormat.encodeHeader( - kdf = BackupKdf.RECOVERY_PHRASE_HKDF_SHA256, - salt = salt, - nonce = nonce, - ciphertextLength = plaintext.size + BackupFormat.TAG_LENGTH, - ) - val key = deriveKey(secret, salt) - return try { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), gcmSpec(nonce)) - cipher.updateAAD(header) - header + cipher.doFinal(plaintext) - } finally { - SensitiveDataCleaner.clear(key) - } - } - - /** Parses the header only: rejects non-backups and unsupported versions before any typing. */ - fun inspect(file: ByteArray): BackupHeader = BackupFormat.parse(file).header - - /** - * Authenticates and decrypts [file]. Returns the plaintext payload, which the caller owns - * and must wipe. Throws [BackupException] on any structural or authentication failure. - */ - fun open(file: ByteArray, secret: BackupSecret): ByteArray { - val parsed = BackupFormat.parse(file) - val key = deriveKey(secret, parsed.header.salt) - return try { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), gcmSpec(parsed.header.nonce)) - // Offsets instead of copies: a large backup is held in memory once, not twice. - cipher.updateAAD(parsed.file, 0, BackupFormat.HEADER_LENGTH) - cipher.doFinal(parsed.file, parsed.ciphertextOffset, parsed.header.ciphertextLength) - } catch (e: GeneralSecurityException) { - // AEADBadTagException: wrong recovery phrase, or a modified header/ciphertext. - throw BackupException(BackupError.WRONG_SECRET, e) - } finally { - SensitiveDataCleaner.clear(key) - } - } - - private fun gcmSpec(nonce: ByteArray) = GCMParameterSpec(BackupFormat.TAG_LENGTH * Byte.SIZE_BITS, nonce) - - private fun deriveKey(secret: BackupSecret, salt: ByteArray): ByteArray = when (secret) { - is BackupSecret.RecoveryRoot -> BackupKeys.fileKeyFromRoot(secret.root, salt) - - is BackupSecret.RecoveryPhrase -> { - val root = BackupKeys.rootFromMnemonic(secret.words) - try { - BackupKeys.fileKeyFromRoot(root, salt) - } finally { - SensitiveDataCleaner.clear(root) - } - } - } -} diff --git a/app/src/main/java/com/pasich/encly/core/backup/BackupFormat.kt b/app/src/main/java/com/pasich/encly/core/backup/BackupFormat.kt deleted file mode 100644 index 592a5908..00000000 --- a/app/src/main/java/com/pasich/encly/core/backup/BackupFormat.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.pasich.encly.core.backup - -import java.nio.ByteBuffer - -/** Why a backup could not be written or read. Never carries secrets or plaintext. */ -enum class BackupError { - /** Not an Encly backup (wrong magic). */ - NOT_A_BACKUP, - - /** An Encly backup written by a newer (or unknown) format version. */ - UNSUPPORTED_VERSION, - - /** The file ends before the header or ciphertext does. */ - TRUNCATED, - - /** Malformed header fields or trailing bytes after the ciphertext. */ - CORRUPTED, - - /** - * Larger than [BackupFormat.MAX_FILE_BYTES]: on import the file, on export the vault, which - * is refused rather than written as a file no install could read back. - */ - TOO_LARGE, - - /** Export target already holds data; it is never overwritten (see BackupDocuments.write). */ - TARGET_EXISTS, - - /** AES-GCM authentication failed: wrong recovery phrase, or a modified header/ciphertext. */ - WRONG_SECRET, - - /** Authenticated, but the payload is not a valid Encly backup payload. */ - INVALID_PAYLOAD, - - /** The words are not a valid 12-word BIP39 recovery phrase. */ - INVALID_PHRASE, - - /** The vault could not provide the backup key (e.g. it was locked meanwhile). */ - VAULT_UNAVAILABLE, - - /** Reading, writing or importing failed for a reason other than the above. */ - IO, -} - -class BackupException(val error: BackupError, cause: Throwable? = null) : Exception(error.name, cause) - -/** - * How the per-file key is derived; stored in (and authenticated with) the header so a future - * format can add another derivation without guessing. - */ -enum class BackupKdf(val id: Int) { - /** HKDF-SHA256 from the BIP39 recovery phrase. See [BackupKeys]. */ - RECOVERY_PHRASE_HKDF_SHA256(1), - ; - - companion object { - fun fromId(id: Int): BackupKdf? = entries.firstOrNull { it.id == id } - } -} - -class BackupHeader( - val formatVersion: Int, - val kdf: BackupKdf, - val salt: ByteArray, - val nonce: ByteArray, - val ciphertextLength: Int, -) - -/** - * A parsed file: the header plus the whole [file], which it does not copy. The first - * [BackupFormat.HEADER_LENGTH] bytes are the GCM AAD; the ciphertext with tag follows them. - */ -class ParsedBackup(val header: BackupHeader, val file: ByteArray) { - val ciphertextOffset: Int get() = BackupFormat.HEADER_LENGTH -} - -/** - * Encly backup container, format version 1. All integers are unsigned big-endian. - * - * | offset | size | field | - * |-------:|-----:|--------------------------------------------------------| - * | 0 | 8 | magic `ENCLYBAK` (ASCII) | - * | 8 | 2 | format version (= 1) | - * | 10 | 1 | KDF id (1 = HKDF-SHA256 from the recovery phrase) | - * | 11 | 1 | salt length (= 32) | - * | 12 | 32 | HKDF salt (random per file) | - * | 44 | 1 | nonce length (= 12) | - * | 45 | 12 | AES-256-GCM nonce (random per file) | - * | 57 | 4 | ciphertext length N, including the 16-byte GCM tag | - * | 61 | N | AES-256-GCM ciphertext ‖ tag of the UTF-8 JSON payload | - * - * The 61 header bytes are the GCM additional authenticated data, so changing any header - * field (version, KDF, salt, nonce, length) fails authentication. Nothing may - * follow the ciphertext. - */ -object BackupFormat { - val MAGIC = "ENCLYBAK".toByteArray(Charsets.US_ASCII) - const val VERSION = 1 - const val SALT_LENGTH = 32 - const val NONCE_LENGTH = 12 - const val TAG_LENGTH = 16 - const val HEADER_LENGTH = 61 - const val MAX_FILE_BYTES = 64 * 1024 * 1024 - const val FILE_EXTENSION = "enclybak" - const val MIME_TYPE = "application/octet-stream" - - private const val VERSION_END = 10 - private const val U8_MASK = 0xFF - private const val U16_MASK = 0xFFFF - private const val U32_MASK = 0xFFFF_FFFFL - - fun encodeHeader(kdf: BackupKdf, salt: ByteArray, nonce: ByteArray, ciphertextLength: Int): ByteArray { - require(salt.size == SALT_LENGTH && nonce.size == NONCE_LENGTH) - return ByteBuffer.allocate(HEADER_LENGTH) - .put(MAGIC) - .putShort(VERSION.toShort()) - .put(kdf.id.toByte()) - .put(SALT_LENGTH.toByte()) - .put(salt) - .put(NONCE_LENGTH.toByte()) - .put(nonce) - .putInt(ciphertextLength) - .array() - } - - /** - * The size of a file sealing [plaintextBytes] of payload. Export refuses anything above - * [MAX_FILE_BYTES], so every file this app writes can be read back by [parse]. - */ - fun fileSizeFor(plaintextBytes: Int): Long = HEADER_LENGTH.toLong() + plaintextBytes + TAG_LENGTH - - /** Validates the container structure. Does not touch keys or decrypt anything. */ - fun parse(file: ByteArray): ParsedBackup { - ensure(file.size <= MAX_FILE_BYTES, BackupError.TOO_LARGE) - // The whole file is here: a length field that overruns it is a truncation, not a size. - val header = parseHeader(file, limit = Long.MAX_VALUE) - val available = file.size - HEADER_LENGTH - ensure(available >= header.ciphertextLength, BackupError.TRUNCATED) - ensure(available == header.ciphertextLength, BackupError.CORRUPTED) - return ParsedBackup(header, file) - } - - /** - * Validates the header at the start of [bytes], which may hold just the header, and - * rejects one whose declared file size exceeds [limit]. Lets a reader size its buffer from - * the header instead of growing one. - */ - fun parseHeader(bytes: ByteArray, limit: Long = MAX_FILE_BYTES.toLong()): BackupHeader { - ensure( - bytes.size >= MAGIC.size && bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC), - BackupError.NOT_A_BACKUP, - ) - ensure(bytes.size >= VERSION_END, BackupError.TRUNCATED) - val buffer = ByteBuffer.wrap(bytes) - buffer.position(MAGIC.size) - val version = buffer.short.toInt() and U16_MASK - ensure(version == VERSION, BackupError.UNSUPPORTED_VERSION) - ensure(bytes.size >= HEADER_LENGTH, BackupError.TRUNCATED) - - val kdf = BackupKdf.fromId(buffer.get().toInt() and U8_MASK) - ensure(kdf != null && (buffer.get().toInt() and U8_MASK) == SALT_LENGTH, BackupError.CORRUPTED) - val salt = ByteArray(SALT_LENGTH).also { buffer.get(it) } - ensure((buffer.get().toInt() and U8_MASK) == NONCE_LENGTH, BackupError.CORRUPTED) - val nonce = ByteArray(NONCE_LENGTH).also { buffer.get(it) } - // Unsigned: a length above Int.MAX_VALUE reads as negative and is simply too large. - val ciphertextLength = buffer.int.toLong() and U32_MASK - ensure(HEADER_LENGTH + ciphertextLength <= limit, BackupError.TOO_LARGE) - ensure(ciphertextLength >= TAG_LENGTH, BackupError.CORRUPTED) - return BackupHeader(version, checkNotNull(kdf), salt, nonce, ciphertextLength.toInt()) - } - - private fun ensure(condition: Boolean, error: BackupError) { - if (!condition) throw BackupException(error) - } -} diff --git a/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt b/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt deleted file mode 100644 index ec9a0807..00000000 --- a/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt +++ /dev/null @@ -1,233 +0,0 @@ -package com.pasich.encly.core.backup - -import com.pasich.encly.core.security.SensitiveDataCleaner -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.encodeToStream -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream - -/** - * The decrypted backup content. Records reference each other by their stable `uid`, never by - * the per-device autoincrement ids. It deliberately holds no vault material: no DEK, PIN or - * biometric slot, recovery phrase or backup key. - * - * Decoding is strict: an unknown key is rejected as [BackupError.INVALID_PAYLOAD]. So **every** - * change to these classes (a new field, even an optional one) must bump [SCHEMA_VERSION]; an - * older app then reports such a file as [BackupError.UNSUPPORTED_VERSION] ("update the app") - * instead of "damaged". Keep reading every older schema: see [BackupPayloadCodec.decode]. - * - * Schema history: - * - 1: first format; tasks carried a `reminderDate`. - * - 2: task reminders removed from the app, so tasks no longer carry `reminderDate`. - * - 3: each task carries its `subtasks` (a list, empty for a task without any). - * - 4: each note carries `isPinned`, `isArchived`, a `color` key and its own `textSize` and - * `lineSpacing` (null: the default). An app that reads only schema 3 reports such a file as - * [BackupError.UNSUPPORTED_VERSION], it does not fail on the keys. - */ -@Serializable -data class BackupPayload( - val schema: Int = SCHEMA_VERSION, - val exportedAt: Long, - val tags: List, - val notes: List, - val tasks: List, -) { - companion object { - const val SCHEMA_VERSION = 4 - - /** Tasks carried a `reminderDate`, dropped on import. */ - const val SCHEMA_V1 = 1 - - /** Tasks had no sub-tasks; they are imported with none. */ - const val SCHEMA_V2 = 2 - - /** Notes had no pin, archive or colour; they are imported unpinned, not archived, uncoloured. */ - const val SCHEMA_V3 = 3 - } -} - -@Serializable -data class BackupTag(val uid: String, val name: String, val visible: Boolean, val position: Int) - -/** - * [value] is the note's serialized block list, carried verbatim so every block type survives. - * [color] is a stable palette key (`none`, `red`, ...), never a colour value. - */ -@Serializable -data class BackupNote( - val uid: String, - val title: String, - val value: String, - val description: String, - val date: Long, - val dateCreate: Long, - val tagUid: String?, - val isTrash: Boolean, - val isPinned: Boolean = false, - val isArchived: Boolean = false, - val color: String = "none", - /** The note's own text size, or null for the default. */ - val textSize: Int? = null, - /** The note's own line spacing (an ordinal of NoteLineSpacing), or null for the default. */ - val lineSpacing: Int? = null, -) - -@Serializable -data class BackupTask( - val uid: String, - val title: String, - val description: String?, - val isCompleted: Boolean, - val createdDate: Long, - val completedDate: Long?, - val priority: Int, - val categoryTagUid: String?, - val position: Int, - val subtasks: List, -) - -/** A task's checklist item. Its task is the [BackupTask] that holds it; [uid]s are vault-wide unique. */ -@Serializable -data class BackupSubtask(val uid: String, val title: String, val isCompleted: Boolean, val position: Int) - -/** UTF-8 JSON encoding of [BackupPayload] with strict validation on the way in. */ -@OptIn(ExperimentalSerializationApi::class) -object BackupPayloadCodec { - const val MAX_UID_LENGTH = 64 - const val MAX_COLOR_LENGTH = 32 - private const val REMINDER_DATE_KEY = "reminderDate" - private const val TASKS_KEY = "tasks" - private const val SUBTASKS_KEY = "subtasks" - private const val NOTES_KEY = "notes" - private val NOTE_ORGANIZING_KEYS = listOf("isPinned", "isArchived", "color", "textSize", "lineSpacing") - private val PRIORITIES = 0..2 - private const val MIN_TEXT_SIZE = 10 - private const val MAX_TEXT_SIZE = 32 - private const val LINE_SPACING_COUNT = 3 - private val TEXT_SIZES = MIN_TEXT_SIZE..MAX_TEXT_SIZE - private val LINE_SPACINGS = 0 until LINE_SPACING_COUNT - - private val json = Json { - encodeDefaults = true - explicitNulls = true - ignoreUnknownKeys = false - } - - /** Reads only `schema`, skipping everything else, before the strict decode. */ - private val schemaJson = Json { ignoreUnknownKeys = true } - - @Serializable - private class SchemaProbe(val schema: Int = BackupPayload.SCHEMA_VERSION) - - /** The returned buffer is plaintext: the caller must wipe it once it has been sealed. */ - fun encode(payload: BackupPayload): ByteArray { - val out = WipeableByteArrayOutputStream() - return try { - json.encodeToStream(BackupPayload.serializer(), payload, out) - out.toByteArray() - } finally { - out.wipe() - } - } - - /** - * A payload from a newer schema is [BackupError.UNSUPPORTED_VERSION]: it authenticated, so - * it is intact, and this app merely cannot read it. Anything else malformed is - * [BackupError.INVALID_PAYLOAD]. An older schema is upgraded to the current one before the - * strict decode, so it is validated exactly like a current file. - */ - fun decode(plaintext: ByteArray): BackupPayload { - val schema = invalidPayloadOn { decodeStream(schemaJson, SchemaProbe.serializer(), plaintext).schema } - if (schema > BackupPayload.SCHEMA_VERSION) throw BackupException(BackupError.UNSUPPORTED_VERSION) - val payload = invalidPayloadOn { - if (schema in BackupPayload.SCHEMA_V1..BackupPayload.SCHEMA_V3) { - val tree = decodeStream(json, JsonElement.serializer(), plaintext) - json.decodeFromJsonElement(BackupPayload.serializer(), upgrade(tree.jsonObject, schema)) - } else { - decodeStream(json, BackupPayload.serializer(), plaintext) - } - } - validate(payload) - return payload - } - - /** - * Schema 1, 2 or 3 -> current. 1 -> 2 drops each task's `reminderDate`; 2 -> 3 gives each task - * an empty `subtasks` list; 3 -> 4 leaves notes to the defaults of the new fields (unpinned, - * not archived, no colour). Everything else is unchanged. A key an older schema did not have - * (`subtasks` in 1 or 2, a note's pin, archive or colour in 1 to 3) fails like any other - * unknown key. - */ - private fun upgrade(old: JsonObject, schema: Int): JsonObject { - val tasks = old[TASKS_KEY]?.jsonArray?.map { element -> - val task = element.jsonObject.let { if (schema == BackupPayload.SCHEMA_V1) it - REMINDER_DATE_KEY else it } - if (schema < BackupPayload.SCHEMA_V3) { - require(SUBTASKS_KEY !in task) { "Unexpected sub-tasks in schema $schema" } - JsonObject(task + (SUBTASKS_KEY to JsonArray(emptyList()))) - } else { - element - } - } - old[NOTES_KEY]?.jsonArray?.forEach { note -> - require(NOTE_ORGANIZING_KEYS.none { it in note.jsonObject }) { "Unexpected note field in schema $schema" } - } - return JsonObject( - old + ("schema" to JsonPrimitive(BackupPayload.SCHEMA_VERSION)) + - (tasks?.let { mapOf(TASKS_KEY to JsonArray(it)) }.orEmpty()), - ) - } - - private fun decodeStream(format: Json, deserializer: DeserializationStrategy, bytes: ByteArray): T = - format.decodeFromStream(deserializer, ByteArrayInputStream(bytes)) - - private inline fun invalidPayloadOn(block: () -> T): T = try { - block() - } catch (e: SerializationException) { - throw BackupException(BackupError.INVALID_PAYLOAD, e) - } catch (e: IllegalArgumentException) { - throw BackupException(BackupError.INVALID_PAYLOAD, e) - } - - fun validate(payload: BackupPayload) { - val tagUids = payload.tags.map { it.uid }.toSet() - val valid = payload.schema == BackupPayload.SCHEMA_VERSION && - uniqueValidUids(payload.tags.map { it.uid }) && - uniqueValidUids(payload.notes.map { it.uid }) && - uniqueValidUids(payload.tasks.map { it.uid }) && - uniqueValidUids(payload.tasks.flatMap { task -> task.subtasks.map { it.uid } }) && - payload.notes.all { it.tagUid == null || it.tagUid in tagUids } && - payload.notes.all(::validNoteStyle) && - payload.tasks.all { - it.priority in PRIORITIES && (it.categoryTagUid == null || it.categoryTagUid in tagUids) - } - if (!valid) throw BackupException(BackupError.INVALID_PAYLOAD) - } - - /** A note's colour key and its own text size and line spacing are within what the app can show. */ - private fun validNoteStyle(note: BackupNote): Boolean = - note.color.isNotBlank() && note.color.length <= MAX_COLOR_LENGTH && - (note.textSize == null || note.textSize in TEXT_SIZES) && - (note.lineSpacing == null || note.lineSpacing in LINE_SPACINGS) - - private fun uniqueValidUids(uids: List): Boolean = - uids.all { it.isNotBlank() && it.length <= MAX_UID_LENGTH } && uids.toSet().size == uids.size - - /** Lets the serializer's internal buffer be zeroized instead of left for the GC. */ - private class WipeableByteArrayOutputStream : ByteArrayOutputStream() { - fun wipe() { - SensitiveDataCleaner.clear(buf) - reset() - } - } -} diff --git a/app/src/main/java/com/pasich/encly/core/common/AppStrings.kt b/app/src/main/java/com/pasich/encly/core/common/AppStrings.kt deleted file mode 100644 index f1f2bd0b..00000000 --- a/app/src/main/java/com/pasich/encly/core/common/AppStrings.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.pasich.encly.core.common - -import android.content.Context -import android.content.res.Configuration -import android.os.LocaleList -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatDelegate -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Resolves text that is stored rather than shown (for example a duplicated note's title), in - * the in-app language. The application context alone ignores that language on Android 12 and - * lower, so the per-app locale is applied explicitly. Text that is only shown is [UiText]. - */ -@Singleton -class AppStrings @Inject constructor(@param:ApplicationContext private val context: Context) { - - fun get(@StringRes id: Int, vararg args: Any): String = localizedContext().getString(id, *args) - - private fun localizedContext(): Context { - val appLocales = AppCompatDelegate.getApplicationLocales() - if (appLocales.isEmpty) return context - val configuration = Configuration(context.resources.configuration).apply { - setLocales(LocaleList.forLanguageTags(appLocales.toLanguageTags())) - } - return context.createConfigurationContext(configuration) - } -} diff --git a/app/src/main/java/com/pasich/encly/core/common/LoadState.kt b/app/src/main/java/com/pasich/encly/core/common/LoadState.kt deleted file mode 100644 index 413aa571..00000000 --- a/app/src/main/java/com/pasich/encly/core/common/LoadState.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.pasich.encly.core.common - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart - -/** - * What a list screen shows while its data is read. [Failed] is a state of its own: a screen - * renders it as an error, never as the "nothing here" empty state, so a read failure cannot - * look like an empty (or wiped) vault. - */ -sealed interface LoadState { - data object Loading : LoadState - data class Ready(val value: T) : LoadState - data class Failed(val error: LoadError) : LoadState -} - -/** A read failure as the user sees it. Never carries `Throwable.message`. */ -data class LoadError(val title: UiText, val message: UiText) - -/** The loaded value, or null while loading or after a failure. */ -fun LoadState.valueOrNull(): T? = (this as? LoadState.Ready)?.value - -/** - * The state after [next] arrives, except that a reload keeps showing the data already loaded - * until the new data (or a failure) replaces it, instead of blinking back to a placeholder. - */ -fun LoadState.reloadWith(next: LoadState): LoadState = if (next is LoadState.Loading && - this is LoadState.Ready -) { - this -} else { - next -} - -/** - * Wraps a data flow for a screen: [LoadState.Loading] first, then every value as - * [LoadState.Ready], and an upstream failure as [LoadState.Failed] with [error]. - * - * `catch` only sees upstream exceptions, so a collector's own exception is never turned into - * an error state, and cancellation always propagates. - */ -fun Flow.asLoadState(error: LoadError): Flow> = map> { LoadState.Ready(it) } - .onStart { emit(LoadState.Loading) } - .catch { cause -> - if (cause is CancellationException) throw cause - emit(LoadState.Failed(error)) - } diff --git a/app/src/main/java/com/pasich/encly/core/common/UiText.kt b/app/src/main/java/com/pasich/encly/core/common/UiText.kt deleted file mode 100644 index 418e011e..00000000 --- a/app/src/main/java/com/pasich/encly/core/common/UiText.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.pasich.encly.core.common - -import android.content.Context -import androidx.annotation.StringRes -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource - -/** - * User-visible text produced outside the UI layer (ViewModels, managers, validators). - * - * It carries a resource id instead of resolved text, so the UI resolves it with its own - * configuration and the string follows the in-app language, including after a language - * switch recreates the activity while the ViewModel survives. Never resolve it with the - * application context: on Android 12 and lower that context ignores the in-app language. - */ -sealed interface UiText { - - data class Resource(@param:StringRes val id: Int) : UiText - - @Composable - fun asString(): String = when (this) { - is Resource -> stringResource(id) - } - - fun asString(context: Context): String = when (this) { - is Resource -> context.getString(id) - } - - companion object { - fun of(@StringRes id: Int): UiText = Resource(id) - } -} diff --git a/app/src/main/java/com/pasich/encly/core/di/AppGraph.kt b/app/src/main/java/com/pasich/encly/core/di/AppGraph.kt new file mode 100644 index 00000000..bbf71992 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/di/AppGraph.kt @@ -0,0 +1,50 @@ +package com.pasich.encly.core.di + +import android.app.Activity +import android.content.Context +import com.pasich.encly.ImportFromMyNotesActivity +import com.pasich.encly.MainActivity +import com.pasich.encly.MyApplication +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.Qualifier +import dev.zacsweers.metrox.viewmodel.ViewModelGraph + +/** Qualifier for the application [Context]: the only Context the graph provides. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationContext + +/** + * The one dependency graph of the process. Every `@SingleIn(AppScope::class)` binding lives as + * long as it; the bindings themselves are contributed to [AppScope] by their own declarations + * (the binding containers named *Module, `@ContributesIntoMap` on the ViewModels). + * + * Android creates the Application, the Activities and the ViewModels, so none of them can take + * constructor arguments: the Application and the Activities are injected as members through + * the `inject` functions below, the ViewModels through [ViewModelGraph.metroViewModelFactory]. + */ +@DependencyGraph(AppScope::class) +interface AppGraph : ViewModelGraph { + + fun inject(application: MyApplication) + + fun inject(activity: MainActivity) + + fun inject(activity: ImportFromMyNotesActivity) + + @DependencyGraph.Factory + fun interface Factory { + fun create(@Provides @ApplicationContext context: Context): AppGraph + } +} + +/** Implemented by the Application: the only way to reach the graph from the UI layer. */ +interface AppGraphOwner { + val appGraph: AppGraph +} + +/** The app graph, for Activities that inject themselves in `onCreate`. */ +internal val Activity.appGraph: AppGraph + get() = (application as AppGraphOwner).appGraph diff --git a/app/src/main/java/com/pasich/encly/core/di/CoroutineModule.kt b/app/src/main/java/com/pasich/encly/core/di/CoroutineModule.kt deleted file mode 100644 index 39e5b729..00000000 --- a/app/src/main/java/com/pasich/encly/core/di/CoroutineModule.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.pasich.encly.core.di - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import javax.inject.Qualifier -import javax.inject.Singleton - -/** - * Qualifier for the application-lifetime [CoroutineScope]. - * - * Use it for fire-and-forget work that MUST outlive the caller's own scope — - * e.g. persisting a note during ViewModel.onCleared(), where [viewModelScope] is - * already cancelled and any coroutine launched in it would be dropped. - */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class ApplicationScope - -/** - * Qualifier for the dispatcher that blocking I/O (Room/SQLCipher) runs on. - * - * Injected instead of referencing [Dispatchers.IO] directly so unit tests can run that work on - * their test dispatcher: a real I/O thread outlives the test and resumes onto a - * Dispatchers.Main that was already reset. - */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class IoDispatcher - -/** Provides coroutine infrastructure that lives for the whole application process. */ -@Module -@InstallIn(SingletonComponent::class) -object CoroutineModule { - - /** - * A [SupervisorJob]-backed scope on [Dispatchers.IO]. A failure in one child does - * not cancel the others, and the scope is never cancelled (it is tied to the - * application process), so work launched here survives ViewModel teardown. - */ - @Provides - @Singleton - @ApplicationScope - fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - @Provides - @IoDispatcher - fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO -} diff --git a/app/src/main/java/com/pasich/encly/core/di/DatabaseModule.kt b/app/src/main/java/com/pasich/encly/core/di/DatabaseModule.kt index a33c58ef..2d81711b 100644 --- a/app/src/main/java/com/pasich/encly/core/di/DatabaseModule.kt +++ b/app/src/main/java/com/pasich/encly/core/di/DatabaseModule.kt @@ -1,22 +1,36 @@ package com.pasich.encly.core.di +import android.content.Context import com.pasich.encly.data.backup.RoomVaultDataStore import com.pasich.encly.data.backup.VaultDataStore +import com.pasich.encly.data.database.AndroidDatabasePlatform +import com.pasich.encly.data.database.DatabasePlatform import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.database.SecureDatabaseManager -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn -@Module -@InstallIn(SingletonComponent::class) -abstract class DatabaseModule { +@ContributesTo(AppScope::class) +@BindingContainer +interface DatabaseModule { + // SecureDatabaseManager is itself a singleton, so this binding is one instance too. @Binds - @Singleton - abstract fun bindDatabaseProvider(secureDatabaseManager: SecureDatabaseManager): DatabaseProvider + fun bindDatabaseProvider(secureDatabaseManager: SecureDatabaseManager): DatabaseProvider @Binds - abstract fun bindVaultDataStore(store: RoomVaultDataStore): VaultDataStore + fun bindVaultDataStore(store: RoomVaultDataStore): VaultDataStore +} + +@ContributesTo(AppScope::class) +@BindingContainer +object DatabasePlatformModule { + /** Where the vault's files are: the app's own database directory, as ever. */ + @Provides + @SingleIn(AppScope::class) + fun provideDatabasePlatform(@ApplicationContext context: Context): DatabasePlatform = + AndroidDatabasePlatform(context) } diff --git a/app/src/main/java/com/pasich/encly/core/di/SecurityModule.kt b/app/src/main/java/com/pasich/encly/core/di/SecurityModule.kt index 83727f45..f7e41ff2 100644 --- a/app/src/main/java/com/pasich/encly/core/di/SecurityModule.kt +++ b/app/src/main/java/com/pasich/encly/core/di/SecurityModule.kt @@ -1,10 +1,16 @@ package com.pasich.encly.core.di import android.content.Context -import android.content.SharedPreferences +import com.pasich.encly.core.platform.HandlerMainThreadTimer +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.core.platform.SharedPreferencesKeyValueStore import com.pasich.encly.core.security.AutoLock import com.pasich.encly.core.security.AutoLockPolicy +import com.pasich.encly.core.security.BiometricManager import com.pasich.encly.core.security.DeviceLockWatcher +import com.pasich.encly.core.security.HandoffStagingSweeper +import com.pasich.encly.core.security.KeystoreBiometricManager import com.pasich.encly.core.security.KeystorePinFactor import com.pasich.encly.core.security.LockoutClock import com.pasich.encly.core.security.PinHardwareFactor @@ -14,18 +20,16 @@ import com.pasich.encly.core.security.SystemLockoutClock import com.pasich.encly.core.security.VaultLockEvents import com.pasich.encly.core.security.VaultStore import com.pasich.encly.data.handoff.CacheHandoffStagingSweeper -import com.pasich.encly.data.handoff.HandoffStagingSweeper -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn import java.io.File -import javax.inject.Singleton -@Module -@InstallIn(SingletonComponent::class) +@ContributesTo(AppScope::class) +@BindingContainer object SecurityModule { /** @@ -34,7 +38,7 @@ object SecurityModule { * [VaultStore]). Construction touches no disk, so it can never fail at injection time. */ @Provides - @Singleton + @SingleIn(AppScope::class) fun provideVaultStore(@ApplicationContext context: Context): VaultStore = VaultStore(File(context.noBackupFilesDir, VAULT_FILE)) @@ -44,32 +48,38 @@ object SecurityModule { * unreadable at startup. */ @Provides - @Singleton - fun provideAppFlags(@ApplicationContext context: Context): SharedPreferences = - context.getSharedPreferences(FLAGS_FILE, Context.MODE_PRIVATE) + @SingleIn(AppScope::class) + fun provideAppFlags(@ApplicationContext context: Context): KeyValueStore = + SharedPreferencesKeyValueStore(context.getSharedPreferences(FLAGS_FILE, Context.MODE_PRIVATE)) private const val VAULT_FILE = "vault_state_v3.bin" private const val FLAGS_FILE = "encly_flags_v3" } -@Module -@InstallIn(SingletonComponent::class) -abstract class SecurityBindingsModule { +@ContributesTo(AppScope::class) +@BindingContainer +interface SecurityBindingsModule { @Binds - abstract fun bindPinHardwareFactor(factor: KeystorePinFactor): PinHardwareFactor + fun bindPinHardwareFactor(factor: KeystorePinFactor): PinHardwareFactor @Binds - abstract fun bindLockoutClock(clock: SystemLockoutClock): LockoutClock + fun bindBiometricManager(manager: KeystoreBiometricManager): BiometricManager @Binds - abstract fun bindDeviceLockWatcher(watcher: SystemDeviceLockWatcher): DeviceLockWatcher + fun bindMainThreadTimer(timer: HandlerMainThreadTimer): MainThreadTimer @Binds - abstract fun bindVaultLockEvents(manager: SessionLockManager): VaultLockEvents + fun bindLockoutClock(clock: SystemLockoutClock): LockoutClock @Binds - abstract fun bindAutoLockPolicy(autoLock: AutoLock): AutoLockPolicy + fun bindDeviceLockWatcher(watcher: SystemDeviceLockWatcher): DeviceLockWatcher @Binds - abstract fun bindHandoffStagingSweeper(sweeper: CacheHandoffStagingSweeper): HandoffStagingSweeper + fun bindVaultLockEvents(manager: SessionLockManager): VaultLockEvents + + @Binds + fun bindAutoLockPolicy(autoLock: AutoLock): AutoLockPolicy + + @Binds + fun bindHandoffStagingSweeper(sweeper: CacheHandoffStagingSweeper): HandoffStagingSweeper } diff --git a/app/src/main/java/com/pasich/encly/core/di/TextModule.kt b/app/src/main/java/com/pasich/encly/core/di/TextModule.kt new file mode 100644 index 00000000..b11eb590 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/di/TextModule.kt @@ -0,0 +1,19 @@ +package com.pasich.encly.core.di + +import com.pasich.encly.core.common.AppStrings +import com.pasich.encly.core.common.ComposeAppStrings +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn + +@ContributesTo(AppScope::class) +@BindingContainer +object TextModule { + + /** Stored text is resolved by the Compose resources, in the in-app language. */ + @Provides + @SingleIn(AppScope::class) + fun provideAppStrings(): AppStrings = ComposeAppStrings() +} diff --git a/app/src/main/java/com/pasich/encly/core/locale/AppLanguage.kt b/app/src/main/java/com/pasich/encly/core/locale/AppLanguage.kt deleted file mode 100644 index dd8f4644..00000000 --- a/app/src/main/java/com/pasich/encly/core/locale/AppLanguage.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.pasich.encly.core.locale - -import androidx.annotation.StringRes -import com.pasich.encly.R - -/** - * Languages the UI is translated into. [tag] matches the `values-` resource folder and - * res/xml/locales_config.xml; [nativeName] is the language's own name, which is what the - * picker shows so a user can find their language whatever the current UI language is. - * - * [SYSTEM] clears the per-app override and follows the device language. - */ -enum class AppLanguage(val tag: String?, @param:StringRes val nativeName: Int) { - SYSTEM(null, R.string.language_system_default), - ENGLISH("en", R.string.language_name_en), - UKRAINIAN("uk", R.string.language_name_uk), - GERMAN("de", R.string.language_name_de), - FRENCH("fr", R.string.language_name_fr), - SPANISH("es", R.string.language_name_es), - ITALIAN("it", R.string.language_name_it), - POLISH("pl", R.string.language_name_pl), - PORTUGUESE("pt", R.string.language_name_pt), - DUTCH("nl", R.string.language_name_nl), - ; - - companion object { - /** - * Maps a BCP 47 tag (e.g. "de", "pt-BR", "uk-UA") to a supported language by its - * primary subtag. A null/blank or unsupported tag means no supported override: SYSTEM. - */ - fun fromTag(tag: String?): AppLanguage { - val language = tag?.substringBefore('-')?.substringBefore('_')?.lowercase() - if (language.isNullOrBlank()) return SYSTEM - return entries.firstOrNull { it.tag == language } ?: SYSTEM - } - } -} diff --git a/app/src/main/java/com/pasich/encly/core/locale/AppLocales.kt b/app/src/main/java/com/pasich/encly/core/locale/AppLocales.kt index 5b040a78..f97b7059 100644 --- a/app/src/main/java/com/pasich/encly/core/locale/AppLocales.kt +++ b/app/src/main/java/com/pasich/encly/core/locale/AppLocales.kt @@ -1,7 +1,10 @@ package com.pasich.encly.core.locale +import android.content.res.Resources +import android.os.LocaleList import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat +import java.util.Locale /** * Per-app language, backed by AppCompatDelegate.setApplicationLocales. @@ -21,6 +24,28 @@ object AppLocales { fun apply(language: AppLanguage) { AppCompatDelegate.setApplicationLocales(localeListFor(language)) + useAsDefaultLocale(language) + } + + /** + * Makes the process's default locale the stored in-app language. Every activity calls it in + * onCreate, before it composes anything: after a relaunch nothing else has applied the stored + * language to the process yet, and the strings read it from there. + */ + fun syncDefaultLocale() = useAsDefaultLocale(current()) + + /** + * The strings are Compose resources, which pick their translation from the process's default + * locale (java.util.Locale and android.os.LocaleList), not from an activity's configuration. + * AppCompat sets it when it recreates an activity; setting it here as well makes text resolved + * outside an activity (stored copies, ViewModel messages) follow the language at once. + * [AppLanguage.SYSTEM] goes back to the device's locales. + */ + private fun useAsDefaultLocale(language: AppLanguage) { + val locales = language.tag?.let { LocaleList.forLanguageTags(it) } + ?: Resources.getSystem().configuration.locales + LocaleList.setDefault(locales) + Locale.setDefault(locales[0]) } fun localeListFor(language: AppLanguage): LocaleListCompat = diff --git a/app/src/main/java/com/pasich/encly/core/platform/HandlerMainThreadTimer.kt b/app/src/main/java/com/pasich/encly/core/platform/HandlerMainThreadTimer.kt new file mode 100644 index 00000000..e417e4e5 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/platform/HandlerMainThreadTimer.kt @@ -0,0 +1,17 @@ +package com.pasich.encly.core.platform + +import android.os.Handler +import android.os.Looper +import dev.zacsweers.metro.Inject + +/** [MainThreadTimer] on a Handler of the main Looper. */ +@Inject +class HandlerMainThreadTimer : MainThreadTimer { + override fun schedule(delayMs: Long, action: () -> Unit): () -> Unit { + val handler = Handler(Looper.getMainLooper()) + val runnable = Runnable(action) + handler.postDelayed(runnable, delayMs) + val cancel: () -> Unit = { handler.removeCallbacks(runnable) } + return cancel + } +} diff --git a/app/src/main/java/com/pasich/encly/core/platform/SharedPreferencesKeyValueStore.kt b/app/src/main/java/com/pasich/encly/core/platform/SharedPreferencesKeyValueStore.kt new file mode 100644 index 00000000..030e2257 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/platform/SharedPreferencesKeyValueStore.kt @@ -0,0 +1,51 @@ +package com.pasich.encly.core.platform + +import android.content.SharedPreferences + +/** + * [KeyValueStore] on Android's SharedPreferences: [KeyValueStore.edit] is `apply()` and + * [KeyValueStore.commit] is `commit()`. + */ +class SharedPreferencesKeyValueStore(private val preferences: SharedPreferences) : KeyValueStore { + override fun getString(key: String, default: String?): String? = preferences.getString(key, default) + + override fun getBoolean(key: String, default: Boolean): Boolean = preferences.getBoolean(key, default) + + override fun getLong(key: String, default: Long): Long = preferences.getLong(key, default) + + override fun contains(key: String): Boolean = preferences.contains(key) + + override fun edit(block: KeyValueStore.Editor.() -> Unit) { + val editor = preferences.edit() + PreferencesEditor(editor).block() + editor.apply() + } + + override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean { + val editor = preferences.edit() + PreferencesEditor(editor).block() + return editor.commit() + } + + private class PreferencesEditor(private val editor: SharedPreferences.Editor) : KeyValueStore.Editor { + override fun putString(key: String, value: String) { + editor.putString(key, value) + } + + override fun putBoolean(key: String, value: Boolean) { + editor.putBoolean(key, value) + } + + override fun putLong(key: String, value: Long) { + editor.putLong(key, value) + } + + override fun remove(key: String) { + editor.remove(key) + } + + override fun clear() { + editor.clear() + } + } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/ActivityBiometricHost.kt b/app/src/main/java/com/pasich/encly/core/security/ActivityBiometricHost.kt new file mode 100644 index 00000000..e31162d8 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/ActivityBiometricHost.kt @@ -0,0 +1,13 @@ +package com.pasich.encly.core.security + +import androidx.fragment.app.FragmentActivity + +/** + * The Android [BiometricHost]: the FragmentActivity BiometricPrompt attaches to. A data class so + * that two hosts of the same Activity are equal (a screen that asks again after a recreation is + * recognisable by its Activity). + */ +data class ActivityBiometricHost(val activity: FragmentActivity) : BiometricHost + +/** The Activity behind a host this app made, or null for any other. */ +internal fun BiometricHost.activityOrNull(): FragmentActivity? = (this as? ActivityBiometricHost)?.activity 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 deleted file mode 100644 index f27f51b8..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.pasich.encly.core.security - -import android.content.SharedPreferences -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -/** How long the vault may stay open after the user leaves the app (Settings → Security). */ -@Suppress("MagicNumber") // The offered delays themselves. -enum class AutoLockDelay(val millis: Long) { - IMMEDIATELY(0L), - SECONDS_15(15_000L), - SECONDS_30(30_000L), - MINUTE_1(60_000L), - MINUTES_2(120_000L), - MINUTES_15(900_000L), - MINUTES_30(1_800_000L), - ; - - companion object { - val DEFAULT = SECONDS_15 - } -} - -/** What [SessionLockManager] reads when the app goes to the background. */ -interface AutoLockPolicy { - val delayMillis: Long - - /** Tests and previews: lock the moment the app leaves. */ - object Immediately : AutoLockPolicy { - override val delayMillis: Long = 0L - } -} - -/** - * The stored auto-lock delay. The screen turning off or the device locking still closes the - * vault at once, whatever the delay (see [SessionLockManager]). - */ -@Singleton -class AutoLock @Inject constructor(private val flags: SharedPreferences) : AutoLockPolicy { - private val _delay = MutableStateFlow(read()) - val delay: StateFlow = _delay.asStateFlow() - - override val delayMillis: Long get() = _delay.value.millis - - fun setDelay(delay: AutoLockDelay) { - flags.edit().putString(DELAY_KEY, delay.name).apply() - _delay.value = delay - } - - private fun read(): AutoLockDelay = flags.getString(DELAY_KEY, null) - ?.let { name -> AutoLockDelay.entries.firstOrNull { it.name == name } } - ?: AutoLockDelay.DEFAULT - - private companion object { - const val DELAY_KEY = "auto_lock_delay" - } -} diff --git a/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt b/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt deleted file mode 100644 index 334af522..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt +++ /dev/null @@ -1,376 +0,0 @@ -package com.pasich.encly.core.security - -import android.content.Context -import android.os.Build -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyPermanentlyInvalidatedException -import android.security.keystore.KeyProperties -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import com.pasich.encly.R -import dagger.hilt.android.qualifiers.ApplicationContext -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec -import javax.inject.Inject -import javax.inject.Singleton -import androidx.biometric.BiometricManager as AndroidBiometricManager - -/** - * Biometric v2 unlock slot. - * - * The wrapping key lives in AndroidKeyStore and is auth-per-use. The DEK can only be wrapped or - * unwrapped by the Cipher instance returned from a successful BIOMETRIC_STRONG CryptoObject - * prompt. A plain "prompt succeeded" boolean never releases the database key. - */ -@Singleton -@Suppress("TooManyFunctions") // Centralizes the complete auth-bound biometric slot lifecycle. -class BiometricManager @Inject constructor( - @param:ApplicationContext private val context: Context, - private val store: VaultStore, -) { - enum class BiometricType { - APP_UNLOCK, - MASTER_KEY_ACCESS, - SETTINGS_TOGGLE, - GENERAL, - } - - interface BiometricCallback { - fun onSuccess() - fun onError(errorCode: Int, errorMessage: String) - fun onFailed() - fun onCancelled() {} - } - - /** Prompt text. A null [negativeButtonText] falls back to the localized "Cancel". */ - data class PromptConfig( - val title: String, - val subtitle: String, - val description: String? = null, - val negativeButtonText: String? = null, - ) - - companion object { - private const val KEYSTORE_PROVIDER = "AndroidKeyStore" - private const val KEY_ALIAS = "encly_biometric_wrap_v2" - internal const val SLOT_PREFIX = "bio." - private const val SLOT_KEY = "bio.slot" - private const val GCM_TAG_LENGTH = 128 - private const val IV_LENGTH = 12 - private const val DEK_LENGTH = 32 - private const val AES_KEY_SIZE_BITS = 256 - private const val AUTH_PER_USE_SECONDS = 0 - } - - private val keyStore by lazy { - KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } - } - - fun isStrongBiometricAvailable(): Boolean = strongBiometricStatus() == BiometricStatus.AVAILABLE - - /** Whether strong biometrics can be used, or why not. */ - fun strongBiometricStatus(): BiometricStatus = BiometricStatus.fromCanAuthenticate( - AndroidBiometricManager.from(context).canAuthenticate(AndroidBiometricManager.Authenticators.BIOMETRIC_STRONG), - ) - - fun hasSlot(): Boolean = try { - keyStore.containsAlias(KEY_ALIAS) && store.contains(SLOT_KEY) - } catch (_: Exception) { - false - } - - fun enroll(activity: FragmentActivity, dek: ByteArray, onResult: (Boolean) -> Unit) { - if (!isStrongBiometricAvailable() || dek.size != DEK_LENGTH) { - onResult(false) - return - } - - val dekCopy = dek.copyOf() - try { - store.edit { remove(SLOT_KEY) } - deleteKeyOnly() - val key = generateAuthBoundKey() - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, key) - - promptWithCipher( - activity = activity, - type = BiometricType.SETTINGS_TOGGLE, - cipher = cipher, - onSuccess = { authenticatedCipher -> - try { - val encrypted = authenticatedCipher.doFinal(dekCopy) - val iv = authenticatedCipher.iv - val combined = ByteArray(iv.size + encrypted.size).also { out -> - System.arraycopy(iv, 0, out, 0, iv.size) - System.arraycopy(encrypted, 0, out, iv.size, encrypted.size) - } - val stored = store.edit { putBytes(SLOT_KEY, combined) } - SensitiveDataCleaner.clear(encrypted) - SensitiveDataCleaner.clear(combined) - if (!stored) disable() - onResult(stored) - } catch (_: Exception) { - disable() - onResult(false) - } finally { - SensitiveDataCleaner.clear(dekCopy) - } - }, - onFailure = { - SensitiveDataCleaner.clear(dekCopy) - deleteKeyOnly() - onResult(false) - }, - ) - } catch (_: Exception) { - SensitiveDataCleaner.clear(dekCopy) - disable() - onResult(false) - } - } - - /** - * Returns the DEK only after a successful auth-bound CryptoObject operation. - * The caller owns the returned bytes and must zeroize them. - */ - fun unlock(activity: FragmentActivity, onResult: (ByteArray?) -> Unit) { - val combined = store.getBytes(SLOT_KEY) - if (!isStrongBiometricAvailable() || combined == null) { - combined?.let(SensitiveDataCleaner::clear) - onResult(null) - return - } - - try { - if (combined.size <= IV_LENGTH) { - SensitiveDataCleaner.clear(combined) - onResult(null) - return - } - - val iv = combined.copyOfRange(0, IV_LENGTH) - val ciphertext = combined.copyOfRange(IV_LENGTH, combined.size) - val key = getAuthBoundKey() - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init( - Cipher.DECRYPT_MODE, - key, - GCMParameterSpec(GCM_TAG_LENGTH, iv), - ) - - promptWithCipher( - activity = activity, - type = BiometricType.APP_UNLOCK, - cipher = cipher, - onSuccess = { authenticatedCipher -> - val dek = try { - authenticatedCipher.doFinal(ciphertext) - } catch (_: Exception) { - null - } finally { - SensitiveDataCleaner.clear(iv) - SensitiveDataCleaner.clear(ciphertext) - SensitiveDataCleaner.clear(combined) - } - onResult(dek) - }, - onFailure = { - SensitiveDataCleaner.clear(iv) - SensitiveDataCleaner.clear(ciphertext) - SensitiveDataCleaner.clear(combined) - onResult(null) - }, - ) - } catch (_: KeyPermanentlyInvalidatedException) { - disable() - onResult(null) - } catch (_: Exception) { - onResult(null) - } - } - - /** Generic re-auth prompt for settings actions that do not themselves release a key. */ - fun authenticate( - activity: FragmentActivity, - type: BiometricType, - callback: BiometricCallback, - customConfig: PromptConfig? = null, - ) { - if (!isStrongBiometricAvailable()) { - callback.onError(-1, activity.getString(R.string.biometric_unavailable)) - return - } - val executor = ContextCompat.getMainExecutor(activity) - val prompt = BiometricPrompt( - activity, - executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - callback.onSuccess() - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - if ( - errorCode == BiometricPrompt.ERROR_USER_CANCELED || - errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON - ) { - callback.onCancelled() - } else { - callback.onError(errorCode, errString.toString()) - } - } - - override fun onAuthenticationFailed() { - callback.onFailed() - } - }, - ) - prompt.authenticate(createPromptInfo(activity, type, customConfig)) - } - - private fun promptWithCipher( - activity: FragmentActivity, - type: BiometricType, - cipher: Cipher, - onSuccess: (Cipher) -> Unit, - onFailure: () -> Unit, - ) { - val executor = ContextCompat.getMainExecutor(activity) - val prompt = BiometricPrompt( - activity, - executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - val authenticatedCipher = result.cryptoObject?.cipher - if (authenticatedCipher != null) onSuccess(authenticatedCipher) else onFailure() - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - onFailure() - } - - override fun onAuthenticationFailed() { - // Keep the system prompt alive. A failed scan is not a terminal result. - } - }, - ) - prompt.authenticate( - createPromptInfo(activity, type), - BiometricPrompt.CryptoObject(cipher), - ) - } - - /** - * Prompt text is resolved from the activity (not the application context) so it follows the - * in-app language chosen through AppCompatDelegate.setApplicationLocales. - */ - private fun createPromptInfo( - activity: FragmentActivity, - type: BiometricType, - customConfig: PromptConfig? = null, - ): BiometricPrompt.PromptInfo { - val config = customConfig ?: when (type) { - BiometricType.APP_UNLOCK -> PromptConfig( - title = activity.getString(R.string.biometric_prompt_unlock_title), - subtitle = activity.getString(R.string.biometric_prompt_unlock_subtitle), - negativeButtonText = activity.getString(R.string.biometric_prompt_use_pin), - ) - - BiometricType.MASTER_KEY_ACCESS -> PromptConfig( - title = activity.getString(R.string.biometric_prompt_key_title), - subtitle = activity.getString(R.string.biometric_prompt_key_subtitle), - ) - - BiometricType.SETTINGS_TOGGLE -> PromptConfig( - title = activity.getString(R.string.biometric_prompt_settings_title), - subtitle = activity.getString(R.string.biometric_prompt_settings_subtitle), - ) - - BiometricType.GENERAL -> PromptConfig( - title = activity.getString(R.string.biometric_prompt_general_title), - subtitle = activity.getString(R.string.biometric_prompt_general_subtitle), - ) - } - - val builder = BiometricPrompt.PromptInfo.Builder() - .setTitle(config.title) - .setSubtitle(config.subtitle) - .setNegativeButtonText( - config.negativeButtonText ?: activity.getString(R.string.cancel), - ) - .setAllowedAuthenticators(AndroidBiometricManager.Authenticators.BIOMETRIC_STRONG) - - config.description?.let { builder.setDescription(it) } - return builder.build() - } - - private fun generateAuthBoundKey(): SecretKey { - val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) - val builder = KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(AES_KEY_SIZE_BITS) - .setUserAuthenticationRequired(true) - .setInvalidatedByBiometricEnrollment(true) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - builder.setUserAuthenticationParameters( - AUTH_PER_USE_SECONDS, - KeyProperties.AUTH_BIOMETRIC_STRONG, - ) - } else { - @Suppress("DEPRECATION") - builder.setUserAuthenticationValidityDurationSeconds(-1) - } - - generator.init(builder.build()) - return generator.generateKey() - } - - private fun getAuthBoundKey(): SecretKey { - val entry = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry - ?: error("Biometric key is missing") - return entry.secretKey - } - - private fun deleteKeyOnly() { - try { - if (keyStore.containsAlias(KEY_ALIAS)) keyStore.deleteEntry(KEY_ALIAS) - } catch (_: Exception) { - } - } - - fun disable() { - store.edit { removePrefix(SLOT_PREFIX) } - deleteKeyOnly() - } -} - -/** Whether strong (class 3) biometrics can unlock the vault on this device. */ -enum class BiometricStatus { - AVAILABLE, - - /** The hardware is there, but no fingerprint (or other strong biometric) is enrolled yet. */ - NOT_ENROLLED, - - /** No suitable hardware, or it cannot be used right now. */ - UNAVAILABLE, - ; - - companion object { - /** Maps a `BiometricManager.canAuthenticate` result. */ - fun fromCanAuthenticate(result: Int): BiometricStatus = when (result) { - AndroidBiometricManager.BIOMETRIC_SUCCESS -> AVAILABLE - AndroidBiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> NOT_ENROLLED - else -> UNAVAILABLE - } - } -} diff --git a/app/src/main/java/com/pasich/encly/core/security/BiometricTexts.kt b/app/src/main/java/com/pasich/encly/core/security/BiometricTexts.kt new file mode 100644 index 00000000..dbd207d7 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/BiometricTexts.kt @@ -0,0 +1,79 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.biometric_prompt_general_subtitle +import com.pasich.encly.core.resources.biometric_prompt_general_title +import com.pasich.encly.core.resources.biometric_prompt_key_subtitle +import com.pasich.encly.core.resources.biometric_prompt_key_title +import com.pasich.encly.core.resources.biometric_prompt_settings_subtitle +import com.pasich.encly.core.resources.biometric_prompt_settings_title +import com.pasich.encly.core.resources.biometric_prompt_unlock_subtitle +import com.pasich.encly.core.resources.biometric_prompt_unlock_title +import com.pasich.encly.core.resources.biometric_prompt_use_pin +import com.pasich.encly.core.resources.biometric_unavailable +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.security.BiometricManager.PromptConfig +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString + +/** The texts of the system biometric prompts, as plain strings in the in-app language. */ +class BiometricTexts( + val unavailable: String, + val cancel: String, + val unlock: PromptConfig, + val masterKey: PromptConfig, + val settings: PromptConfig, + val general: PromptConfig, +) + +/** + * Holds the [BiometricTexts] for [KeystoreBiometricManager], which builds the system prompt outside any + * coroutine and so is handed finished strings instead of resolving resources itself. The activity calls + * [refresh] while the splash screen is up (and again after every language change, which recreates it). + */ +@SingleIn(AppScope::class) +@Inject +class BiometricTextSource { + @Volatile + private var texts: BiometricTexts? = null + + /** Reads the texts in the current language. */ + suspend fun refresh() { + texts = BiometricTexts( + unavailable = getString(Res.string.biometric_unavailable), + cancel = getString(Res.string.cancel), + unlock = PromptConfig( + title = getString(Res.string.biometric_prompt_unlock_title), + subtitle = getString(Res.string.biometric_prompt_unlock_subtitle), + negativeButtonText = getString(Res.string.biometric_prompt_use_pin), + ), + masterKey = PromptConfig( + title = getString(Res.string.biometric_prompt_key_title), + subtitle = getString(Res.string.biometric_prompt_key_subtitle), + ), + settings = PromptConfig( + title = getString(Res.string.biometric_prompt_settings_title), + subtitle = getString(Res.string.biometric_prompt_settings_subtitle), + ), + general = PromptConfig( + title = getString(Res.string.biometric_prompt_general_title), + subtitle = getString(Res.string.biometric_prompt_general_subtitle), + ), + ) + } + + /** Uses [texts] as they are (tests, or a caller that resolved them itself). */ + fun set(texts: BiometricTexts) { + this.texts = texts + } + + /** + * The texts of the last [refresh]. A process started by another entry point than MainActivity + * (the My Notes hand-off) may reach a prompt before any refresh: then they are read here, once, + * so the unlock never fails over a missing title. + */ + fun current(): BiometricTexts = texts ?: runBlocking { refresh() }.let { checkNotNull(texts) } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/DeviceLockWatcher.kt b/app/src/main/java/com/pasich/encly/core/security/DeviceLockWatcher.kt deleted file mode 100644 index 6948fa62..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/DeviceLockWatcher.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.pasich.encly.core.security - -import android.app.KeyguardManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -/** Screen-off and keyguard signals for [SessionLockManager]; an interface so tests can drive it. */ -interface DeviceLockWatcher { - /** Whether the keyguard is up and needs the user's credential. */ - fun isDeviceLocked(): Boolean - - /** Calls [onScreenOff] when the screen turns off, until the returned function is called. */ - fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit - - /** No device signals (JVM tests). */ - object None : DeviceLockWatcher { - override fun isDeviceLocked(): Boolean = false - override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit = {} - } -} - -@Singleton -class SystemDeviceLockWatcher @Inject constructor(@param:ApplicationContext private val context: Context) : - DeviceLockWatcher { - override fun isDeviceLocked(): Boolean = - context.getSystemService(KeyguardManager::class.java)?.isDeviceLocked == true - - override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit { - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_SCREEN_OFF) onScreenOff() - } - } - return try { - // A protected system broadcast: NOT_EXPORTED still receives it. - ContextCompat.registerReceiver( - context, - receiver, - IntentFilter(Intent.ACTION_SCREEN_OFF), - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - val stop: () -> Unit = { - try { - context.unregisterReceiver(receiver) - } catch (_: IllegalArgumentException) { - // Already unregistered. - } - } - stop - } catch (_: RuntimeException) { - {} - } - } -} 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 deleted file mode 100644 index c394f28b..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.pasich.encly.core.security - -import com.pasich.encly.core.security.PinSlots.AES_GCM -import com.pasich.encly.core.security.PinSlots.DEK_LENGTH -import com.pasich.encly.core.security.PinSlots.GCM_TAG_BYTES -import com.pasich.encly.core.security.PinSlots.GCM_TAG_LENGTH -import com.pasich.encly.core.security.PinSlots.IV_LENGTH -import com.pasich.encly.core.security.PinSlots.SLOT_VERSION -import java.security.GeneralSecurityException -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec - -/** - * The duress slot (`version ‖ iv ‖ AES-GCM_dkek(decoy DEK)`, bound to the PIN slot's salt) and - * the decoy key (the same DEK sealed under a sub-key of the real vault's DEK): both 61 bytes, - * the same as random filler, so the store has the same shape with or without a duress PIN. - */ -internal object DuressSlots { - private const val DECOY_SLOT_LENGTH = 1 + IV_LENGTH + DEK_LENGTH + GCM_TAG_BYTES - private val DECOY_AAD = "encly/pin/decoy-slot/v3".toByteArray(Charsets.UTF_8) - val DECOY_KEY_AAD = "encly/decoy/key/v1".toByteArray(Charsets.UTF_8) + byteArrayOf(SLOT_VERSION) - - /** The decoy key while its duress PIN is turned off (a new PIN slot killed the duress slot). */ - val DECOY_KEY_OFF_AAD = "encly/decoy/key/off/v1".toByteArray(Charsets.UTF_8) + byteArrayOf(SLOT_VERSION) - private val DECOY_KEY_SALT = "encly/decoy/key/salt/v1".toByteArray(Charsets.UTF_8) - private val DECOY_KEY_INFO = "encly/decoy/key/kek/v1".toByteArray(Charsets.UTF_8) - - /** A sub-key of the vault's DEK, so the SQLCipher key itself is never an AES-GCM key. */ - fun decoyKeyKek(primaryDek: ByteArray): ByteArray = - Hkdf.sha256(ikm = primaryDek, salt = DECOY_KEY_SALT, info = DECOY_KEY_INFO, length = DEK_LENGTH) - - /** `version ‖ iv ‖ AES-GCM(dek)`: the shape of the duress slot and the decoy key, 61 bytes. */ - fun sealDecoy(dek: ByteArray, kek: ByteArray, aad: ByteArray): ByteArray { - val cipher = Cipher.getInstance(AES_GCM) - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(kek, "AES")) - cipher.updateAAD(aad) - val encrypted = cipher.doFinal(dek) - return try { - byteArrayOf(SLOT_VERSION) + cipher.iv + encrypted - } finally { - SensitiveDataCleaner.clear(encrypted) - } - } - - fun openDecoy(sealed: ByteArray, kek: ByteArray, aad: ByteArray): ByteArray? { - if (sealed.size != DECOY_SLOT_LENGTH || sealed[0] != SLOT_VERSION) return null - val iv = sealed.copyOfRange(1, 1 + IV_LENGTH) - val ciphertext = sealed.copyOfRange(1 + IV_LENGTH, sealed.size) - return try { - val cipher = Cipher.getInstance(AES_GCM) - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(kek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, iv)) - cipher.updateAAD(aad) - cipher.doFinal(ciphertext) - } catch (_: GeneralSecurityException) { - null - } finally { - SensitiveDataCleaner.clear(iv) - SensitiveDataCleaner.clear(ciphertext) - } - } - - fun decoySlotAad(salt: ByteArray): ByteArray = DECOY_AAD + byteArrayOf(SLOT_VERSION) + salt - - /** Random bytes shaped like a duress slot or decoy key: nothing opens them. */ - fun randomSlot(): ByteArray = ByteArray(DECOY_SLOT_LENGTH).also { - SecureRandom().nextBytes(it) - it[0] = SLOT_VERSION - } -} diff --git a/app/src/main/java/com/pasich/encly/core/security/Hkdf.kt b/app/src/main/java/com/pasich/encly/core/security/Hkdf.kt deleted file mode 100644 index 8e191d72..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/Hkdf.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.pasich.encly.core.security - -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -/** - * HKDF-SHA256 (RFC 5869) for outputs of at most one hash block (32 bytes), which is all the - * vault and backup key hierarchy needs. The intermediate PRK is zeroized before returning; - * the caller owns (and must wipe) the returned key. - */ -internal object Hkdf { - private const val ALGORITHM = "HmacSHA256" - const val MAX_LENGTH = 32 - - fun sha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { - require(length in 1..MAX_LENGTH) - require(salt.isNotEmpty()) - val mac = Mac.getInstance(ALGORITHM) - mac.init(SecretKeySpec(salt, ALGORITHM)) - val prk = mac.doFinal(ikm) - return try { - mac.init(SecretKeySpec(prk, ALGORITHM)) - mac.update(info) - mac.update(0x01.toByte()) - val block = mac.doFinal() - try { - block.copyOf(length) - } finally { - SensitiveDataCleaner.clear(block) - } - } finally { - SensitiveDataCleaner.clear(prk) - } - } -} diff --git a/app/src/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt b/app/src/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt deleted file mode 100644 index 38b989a0..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.pasich.encly.core.security - -import android.content.SharedPreferences -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -/** - * "Strict keyboard privacy" (Settings → Security, off by default). When on, every text field - * presents itself to the keyboard as a visible-password field without suggestions (see - * SecureTextInputBoundary): compliant keyboards then offer no suggestions or cloud - * prediction and get no surrounding text. The cost is no autocorrect, and on some keyboards - * no emoji or voice input. - */ -@Singleton -class KeyboardPrivacy @Inject constructor(private val flags: SharedPreferences) { - private val _strict = MutableStateFlow(flags.getBoolean(STRICT_KEY, false)) - val strict: StateFlow = _strict.asStateFlow() - - fun setStrict(enabled: Boolean) { - flags.edit().putBoolean(STRICT_KEY, enabled).apply() - _strict.value = enabled - } - - private companion object { - const val STRICT_KEY = "keyboard_strict_privacy" - } -} diff --git a/app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt b/app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt new file mode 100644 index 00000000..0ada2846 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt @@ -0,0 +1,350 @@ +package com.pasich.encly.core.security + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import com.pasich.encly.core.di.ApplicationContext +import com.pasich.encly.core.security.BiometricManager.BiometricType +import com.pasich.encly.core.security.BiometricManager.PromptConfig +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import androidx.biometric.BiometricManager as AndroidBiometricManager + +/** + * Biometric v2 unlock slot. + * + * The wrapping key lives in AndroidKeyStore and is auth-per-use. The DEK can only be wrapped or + * unwrapped by the Cipher instance returned from a successful BIOMETRIC_STRONG CryptoObject + * prompt. A plain "prompt succeeded" boolean never releases the database key. + */ +@SingleIn(AppScope::class) +@Suppress("TooManyFunctions") // Centralizes the complete auth-bound biometric slot lifecycle. +@Inject +class KeystoreBiometricManager( + @param:ApplicationContext private val context: Context, + private val store: VaultStore, + private val texts: BiometricTextSource, +) : BiometricManager { + companion object { + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val KEY_ALIAS = "encly_biometric_wrap_v2" + private const val SLOT_KEY = BiometricManager.SLOT_PREFIX + "slot" + private const val GCM_TAG_LENGTH = 128 + private const val IV_LENGTH = 12 + private const val DEK_LENGTH = 32 + private const val AES_KEY_SIZE_BITS = 256 + private const val AUTH_PER_USE_SECONDS = 0 + } + + private val keyStore by lazy { + KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + } + + override fun isStrongBiometricAvailable(): Boolean = strongBiometricStatus() == BiometricStatus.AVAILABLE + + /** Whether strong biometrics can be used, or why not. */ + override fun strongBiometricStatus(): BiometricStatus = BiometricStatus.fromCanAuthenticate( + AndroidBiometricManager.from(context).canAuthenticate(AndroidBiometricManager.Authenticators.BIOMETRIC_STRONG), + ) + + override fun hasSlot(): Boolean = try { + keyStore.containsAlias(KEY_ALIAS) && store.contains(SLOT_KEY) + } catch (_: Exception) { + false + } + + override fun enroll(host: BiometricHost, dek: ByteArray, onResult: (Boolean) -> Unit) { + // A prompt over a stopped activity is never shown, and its copy would wait for the destroy. + val activity = host.activityOrNull()?.takeIf { + it.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && + isStrongBiometricAvailable() && + dek.size == DEK_LENGTH + } + if (activity == null) { + onResult(false) + return + } + + val pending = PendingDek(dek) + try { + pending.wipeWhenDestroyed(activity.lifecycle) + store.edit { remove(SLOT_KEY) } + deleteKeyOnly() + val key = generateAuthBoundKey() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + + promptWithCipher( + activity = activity, + type = BiometricType.SETTINGS_TOGGLE, + cipher = cipher, + onSuccess = { authenticatedCipher -> + val stored = try { + storeEnrolment(authenticatedCipher, pending) + } catch (_: Exception) { + false + } finally { + pending.wipe() + } + if (!stored) disable() + onResult(stored) + }, + onFailure = { + pending.wipe() + deleteKeyOnly() + onResult(false) + }, + ) + } catch (_: Exception) { + pending.wipe() + disable() + onResult(false) + } + } + + /** Wraps the pending DEK with the authenticated [cipher] and stores the slot; false when nothing was stored. */ + private fun storeEnrolment(cipher: Cipher, pending: PendingDek): Boolean { + // Null when the activity stopped meanwhile: the copy is gone, nothing is enrolled. + val encrypted = pending.use { cipher.doFinal(it) } ?: return false + val iv = cipher.iv + val combined = ByteArray(iv.size + encrypted.size).also { out -> + System.arraycopy(iv, 0, out, 0, iv.size) + System.arraycopy(encrypted, 0, out, iv.size, encrypted.size) + } + return try { + store.edit { putBytes(SLOT_KEY, combined) } + } finally { + SensitiveDataCleaner.clear(encrypted) + SensitiveDataCleaner.clear(combined) + } + } + + /** + * Returns the DEK only after a successful auth-bound CryptoObject operation. + * The caller owns the returned bytes and must zeroize them. + */ + @Suppress("ReturnCount") // The host check and the guards of the slot fail closed. + override fun unlock(host: BiometricHost, onResult: (ByteArray?) -> Unit) { + val activity = host.activityOrNull() + if (activity == null) { + onResult(null) + return + } + val combined = store.getBytes(SLOT_KEY) + if (!isStrongBiometricAvailable() || combined == null) { + combined?.let(SensitiveDataCleaner::clear) + onResult(null) + return + } + + try { + if (combined.size <= IV_LENGTH) { + SensitiveDataCleaner.clear(combined) + onResult(null) + return + } + + val iv = combined.copyOfRange(0, IV_LENGTH) + val ciphertext = combined.copyOfRange(IV_LENGTH, combined.size) + val key = getAuthBoundKey() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + key, + GCMParameterSpec(GCM_TAG_LENGTH, iv), + ) + + promptWithCipher( + activity = activity, + type = BiometricType.APP_UNLOCK, + cipher = cipher, + onSuccess = { authenticatedCipher -> + val dek = try { + authenticatedCipher.doFinal(ciphertext) + } catch (_: Exception) { + null + } finally { + SensitiveDataCleaner.clear(iv) + SensitiveDataCleaner.clear(ciphertext) + SensitiveDataCleaner.clear(combined) + } + onResult(dek) + }, + onFailure = { + SensitiveDataCleaner.clear(iv) + SensitiveDataCleaner.clear(ciphertext) + SensitiveDataCleaner.clear(combined) + onResult(null) + }, + ) + } catch (_: KeyPermanentlyInvalidatedException) { + disable() + onResult(null) + } catch (_: Exception) { + onResult(null) + } + } + + /** Generic re-auth prompt for settings actions that do not themselves release a key. */ + override fun authenticate( + host: BiometricHost, + type: BiometricManager.BiometricType, + callback: BiometricManager.BiometricCallback, + customConfig: BiometricManager.PromptConfig?, + ) { + val activity = host.activityOrNull() + if (activity == null) { + callback.onError(-1, texts.current().unavailable) + return + } + if (!isStrongBiometricAvailable()) { + callback.onError(-1, texts.current().unavailable) + return + } + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + callback.onSuccess() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if ( + errorCode == BiometricPrompt.ERROR_USER_CANCELED || + errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON + ) { + callback.onCancelled() + } else { + callback.onError(errorCode, errString.toString()) + } + } + + override fun onAuthenticationFailed() { + callback.onFailed() + } + }, + ) + prompt.authenticate(createPromptInfo(type, customConfig)) + } + + private fun promptWithCipher( + activity: FragmentActivity, + type: BiometricType, + cipher: Cipher, + onSuccess: (Cipher) -> Unit, + onFailure: () -> Unit, + ) { + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + val authenticatedCipher = result.cryptoObject?.cipher + if (authenticatedCipher != null) onSuccess(authenticatedCipher) else onFailure() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onFailure() + } + + override fun onAuthenticationFailed() { + // Keep the system prompt alive. A failed scan is not a terminal result. + } + }, + ) + prompt.authenticate( + createPromptInfo(type), + BiometricPrompt.CryptoObject(cipher), + ) + } + + /** + * The prompt texts are finished strings from [texts] (in the in-app language), so this class never + * resolves a resource. + */ + private fun createPromptInfo(type: BiometricType, customConfig: PromptConfig? = null): BiometricPrompt.PromptInfo { + val config = customConfig ?: when (type) { + BiometricType.APP_UNLOCK -> texts.current().unlock + BiometricType.MASTER_KEY_ACCESS -> texts.current().masterKey + BiometricType.SETTINGS_TOGGLE -> texts.current().settings + BiometricType.GENERAL -> texts.current().general + } + + val builder = BiometricPrompt.PromptInfo.Builder() + .setTitle(config.title) + .setSubtitle(config.subtitle) + .setNegativeButtonText( + config.negativeButtonText ?: texts.current().cancel, + ) + .setAllowedAuthenticators(AndroidBiometricManager.Authenticators.BIOMETRIC_STRONG) + + config.description?.let { builder.setDescription(it) } + return builder.build() + } + + private fun generateAuthBoundKey(): SecretKey { + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) + val builder = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(AES_KEY_SIZE_BITS) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + AUTH_PER_USE_SECONDS, + KeyProperties.AUTH_BIOMETRIC_STRONG, + ) + } else { + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(-1) + } + + generator.init(builder.build()) + return generator.generateKey() + } + + private fun getAuthBoundKey(): SecretKey { + val entry = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + ?: error("Biometric key is missing") + return entry.secretKey + } + + private fun deleteKeyOnly() { + try { + if (keyStore.containsAlias(KEY_ALIAS)) keyStore.deleteEntry(KEY_ALIAS) + } catch (_: Exception) { + } + } + + override fun disable() { + store.edit { removePrefix(BiometricManager.SLOT_PREFIX) } + deleteKeyOnly() + } +} + +/** Maps a `BiometricManager.canAuthenticate` result. */ +fun BiometricStatus.Companion.fromCanAuthenticate(result: Int): BiometricStatus = when (result) { + AndroidBiometricManager.BIOMETRIC_SUCCESS -> BiometricStatus.AVAILABLE + AndroidBiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricStatus.NOT_ENROLLED + else -> BiometricStatus.UNAVAILABLE +} diff --git a/app/src/main/java/com/pasich/encly/core/security/KeystorePinFactor.kt b/app/src/main/java/com/pasich/encly/core/security/KeystorePinFactor.kt new file mode 100644 index 00000000..8f0e086c --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/KeystorePinFactor.kt @@ -0,0 +1,144 @@ +package com.pasich.encly.core.security + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import com.pasich.encly.core.AppLogger +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import java.security.GeneralSecurityException +import java.security.KeyStore +import java.security.UnrecoverableKeyException +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey + +/** + * [PinHardwareFactor] on AndroidKeyStore: a non-exportable 256-bit HMAC key, in StrongBox + * when the device has one (falling back to the TEE), usable only while the device is unlocked + * (API 28+). + */ +// Keystore reports several failures (StrongBox missing, rejected parameters, provider errors) +// as RuntimeExceptions; each one is mapped to a PinFactorException here, never let through. +@Suppress("TooGenericExceptionCaught") +@SingleIn(AppScope::class) +@Inject +class KeystorePinFactor : PinHardwareFactor { + private val keyStore by lazy { KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } } + + override fun ensureKey(slot: PinKeySlot): Boolean { + val exists = try { + keyStore.containsAlias(alias(slot)) + } catch (e: GeneralSecurityException) { + throw PinFactorException(lost = false, cause = e) + } catch (e: RuntimeException) { + throw PinFactorException(lost = false, cause = e) + } + if (!exists) reset(slot) + return !exists + } + + override fun reset(slot: PinKeySlot) { + delete(slot) + try { + generate(slot) + } catch (e: GeneralSecurityException) { + throw PinFactorException(lost = false, cause = e) + } catch (e: RuntimeException) { + throw PinFactorException(lost = false, cause = e) + } + } + + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { + val key = try { + keyStore.getKey(alias(slot), null) as? SecretKey + } catch (e: UnrecoverableKeyException) { + throw PinFactorException(lost = true, cause = e) + } catch (e: GeneralSecurityException) { + throw PinFactorException(lost = false, cause = e) + } ?: throw PinFactorException(lost = true) + return try { + Mac.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256).run { + init(key) + doFinal(data) + } + } catch (e: KeyPermanentlyInvalidatedException) { + throw PinFactorException(lost = true, cause = e) + } catch (e: GeneralSecurityException) { + throw PinFactorException(lost = false, cause = e) + } catch (e: RuntimeException) { + // Keystore reports some failures as ProviderException. + throw PinFactorException(lost = false, cause = e) + } + } + + override fun delete(slot: PinKeySlot) { + val alias = alias(slot) + try { + if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) + } catch (e: GeneralSecurityException) { + AppLogger.w(TAG, "PIN factor key could not be deleted", e) + } catch (e: RuntimeException) { + AppLogger.w(TAG, "PIN factor key could not be deleted", e) + } + } + + /** + * StrongBox first; a device without one (or whose StrongBox lacks HMAC) falls back to the + * TEE. The unlocked-device requirement is dropped only if the platform rejects it outright. + */ + private fun generate(slot: PinKeySlot) { + val attempts = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(Options(strongBox = true, unlockedOnly = true)) + add(Options(strongBox = false, unlockedOnly = true)) + add(Options(strongBox = false, unlockedOnly = false)) + } + var last: Exception? = null + for (options in attempts) { + try { + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_PROVIDER).run { + init(spec(alias(slot), options)) + generateKey() + } + return + } catch (e: GeneralSecurityException) { + last = e + } catch (e: RuntimeException) { + // StrongBoxUnavailableException, and rejected parameters on some devices, are + // ProviderExceptions. + last = e + } + delete(slot) + } + throw last ?: error("no key generated") + } + + private fun spec(alias: String, options: Options): KeyGenParameterSpec { + val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) + .setKeySize(HMAC_KEY_BITS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(options.strongBox) + builder.setUnlockedDeviceRequired(options.unlockedOnly) + } + return builder.build() + } + + private data class Options(val strongBox: Boolean, val unlockedOnly: Boolean) + + private companion object { + const val TAG = "KeystorePinFactor" + const val KEYSTORE_PROVIDER = "AndroidKeyStore" + + /** Slot A keeps the alias every vault has used since v3, so existing installs are untouched. */ + const val KEY_ALIAS_A = "encly_pin_factor_v3" + const val KEY_ALIAS_B = "encly_pin_factor_v3b" + const val HMAC_KEY_BITS = 256 + + fun alias(slot: PinKeySlot): String = when (slot) { + PinKeySlot.A -> KEY_ALIAS_A + PinKeySlot.B -> KEY_ALIAS_B + } + } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/LockoutClock.kt b/app/src/main/java/com/pasich/encly/core/security/LockoutClock.kt deleted file mode 100644 index 4a970b1e..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/LockoutClock.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.pasich.encly.core.security - -import android.content.Context -import android.os.SystemClock -import android.provider.Settings -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Time for the PIN lockout. Monotonic, so changing the wall clock does not end a lockout. - * `elapsedRealtime` restarts at boot, so the lockout also needs to know which boot it is in. - */ -interface LockoutClock { - /** Milliseconds since boot, deep sleep included. */ - fun elapsedRealtime(): Long - - /** Changes on every reboot; -1 when unknown. */ - fun bootCount(): Int -} - -@Singleton -class SystemLockoutClock @Inject constructor(@param:ApplicationContext private val context: Context) : LockoutClock { - override fun elapsedRealtime(): Long = SystemClock.elapsedRealtime() - - override fun bootCount(): Int = try { - Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1) - } catch (_: RuntimeException) { - -1 - } -} - -/** - * A monotonic clock without Android (JVM unit tests). Unlike `elapsedRealtime` it may not - * count deep sleep, so the app itself always gets [SystemLockoutClock] through Hilt. - */ -object JvmMonotonicClock : LockoutClock { - private const val NANOS_PER_MILLI = 1_000_000L - - override fun elapsedRealtime(): Long = System.nanoTime() / NANOS_PER_MILLI - - override fun bootCount(): Int = -1 -} diff --git a/app/src/main/java/com/pasich/encly/core/security/Mnemonic.kt b/app/src/main/java/com/pasich/encly/core/security/Mnemonic.kt deleted file mode 100644 index 87618b45..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/Mnemonic.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.pasich.encly.core.security - -import java.nio.CharBuffer -import java.security.MessageDigest - -/** - * The one normalization every recovery-phrase key goes through: the vault's recovery KEK and - * the backup root both hash exactly these bytes, so a phrase that opens one opens the other. - */ -internal object Mnemonic { - /** Lower-cases and collapses any whitespace between words to a single space. */ - fun normalize(words: CharArray): CharArray { - val out = CharArray(words.size) - var length = 0 - var pendingSpace = false - for (c in words) { - if (c.isWhitespace()) { - pendingSpace = length > 0 - } else { - if (pendingSpace) { - out[length++] = ' ' - pendingSpace = false - } - out[length++] = c.lowercaseChar() - } - } - return try { - out.copyOf(length) - } finally { - SensitiveDataCleaner.clear(out) - } - } - - /** SHA-256 of the normalized words in UTF-8. No String copy of the words is made. */ - fun seedHash(words: CharArray): ByteArray { - val normalized = normalize(words) - val utf8 = utf8(normalized) - SensitiveDataCleaner.clear(normalized) - return try { - MessageDigest.getInstance("SHA-256").digest(utf8) - } finally { - SensitiveDataCleaner.clear(utf8) - } - } - - private fun utf8(chars: CharArray): ByteArray { - val buffer = Charsets.UTF_8.encode(CharBuffer.wrap(chars)) - val bytes = ByteArray(buffer.remaining()) - buffer.get(bytes) - if (buffer.hasArray()) SensitiveDataCleaner.clear(buffer.array()) - return bytes - } -} diff --git a/app/src/main/java/com/pasich/encly/core/security/PendingDek.kt b/app/src/main/java/com/pasich/encly/core/security/PendingDek.kt new file mode 100644 index 00000000..d084b64e --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/PendingDek.kt @@ -0,0 +1,39 @@ +package com.pasich.encly.core.security + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver + +/** + * The copy of the DEK an enrolment holds while its prompt is open. It is wiped on every way out: the prompt's + * answer, a failure, and an activity that is destroyed, because androidx.biometric drops the answer of a + * prompt whose activity is gone and then nothing else would ever clear it. A stop does not wipe it: some + * devices show the prompt over a stopped activity and answer after the stop. A late answer finds it wiped + * ([use] gives null) and enrols nothing. + * + * Main thread only, as the prompt's callbacks and the lifecycle. + */ +internal class PendingDek(source: ByteArray) { + private var dek: ByteArray? = source.copyOf() + private var detach: (() -> Unit)? = null + + /** Runs [block] on the key, or gives null when it was wiped already. */ + fun use(block: (ByteArray) -> T): T? = dek?.let(block) + + /** Wipes the copy, and stops watching the lifecycle. */ + fun wipe() { + dek?.let(SensitiveDataCleaner::clear) + dek = null + detach?.invoke() + detach = null + } + + /** Wipes the copy when [lifecycle] reaches ON_DESTROY. */ + fun wipeWhenDestroyed(lifecycle: Lifecycle) { + if (dek == null) return + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_DESTROY) wipe() + } + lifecycle.addObserver(observer) + detach = { lifecycle.removeObserver(observer) } + } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt b/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt deleted file mode 100644 index 5042e4d3..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.pasich.encly.core.security - -import android.os.Build -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyPermanentlyInvalidatedException -import android.security.keystore.KeyProperties -import com.pasich.encly.core.AppLogger -import java.security.GeneralSecurityException -import java.security.KeyStore -import java.security.UnrecoverableKeyException -import javax.crypto.KeyGenerator -import javax.crypto.Mac -import javax.crypto.SecretKey -import javax.inject.Inject -import javax.inject.Singleton - -/** - * The two places the device-bound PIN key can live. [A] is the one every vault starts with - * (and the only one older builds know); a wipe-PIN unlock moves the PIN slot to the other one - * and deletes the old key, so the erased slots can never be opened again (crypto-erase). The - * vault records which one is active. - */ -enum class PinKeySlot { - A, - B, - ; - - val other: PinKeySlot get() = if (this == A) B else A -} - -/** - * The device-bound half of the PIN key: an HMAC-SHA256 whose key never leaves this device's - * secure hardware. Mixed into the PIN KEK, it makes every PIN guess run here, on the - * Keystore, instead of on an attacker's GPU against a copied slot. An interface so JVM tests - * can replace the Keystore. - */ -interface PinHardwareFactor { - /** - * Creates the key in [slot] unless it already exists. True when it had to be created: any - * slot sealed with a key that was there before (a key the system or a backup restore deleted) - * can no longer be opened. - */ - @Throws(PinFactorException::class) - fun ensureKey(slot: PinKeySlot): Boolean - - /** Replaces the key in [slot] with a new one; slots sealed with the old one can no longer be opened. */ - @Throws(PinFactorException::class) - fun reset(slot: PinKeySlot) - - /** HMAC-SHA256 of [data] under the key in [slot]. The caller wipes the result. */ - @Throws(PinFactorException::class) - fun mac(slot: PinKeySlot, data: ByteArray): ByteArray - - /** Deletes the key in [slot], if any. Never throws. */ - fun delete(slot: PinKeySlot) -} - -/** - * The factor failed. [lost] means the key is gone for good (deleted, or invalidated by the - * system): the PIN slot can never be opened again on this device and only the recovery phrase - * (or the biometric slot) still unlocks. Otherwise the failure may be transient. - */ -class PinFactorException(val lost: Boolean, cause: Throwable? = null) : Exception(cause) - -/** - * [PinHardwareFactor] on AndroidKeyStore: a non-exportable 256-bit HMAC key, in StrongBox - * when the device has one (falling back to the TEE), usable only while the device is unlocked - * (API 28+). - */ -// Keystore reports several failures (StrongBox missing, rejected parameters, provider errors) -// as RuntimeExceptions; each one is mapped to a PinFactorException here, never let through. -@Suppress("TooGenericExceptionCaught") -@Singleton -class KeystorePinFactor @Inject constructor() : PinHardwareFactor { - private val keyStore by lazy { KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } } - - override fun ensureKey(slot: PinKeySlot): Boolean { - val exists = try { - keyStore.containsAlias(alias(slot)) - } catch (e: GeneralSecurityException) { - throw PinFactorException(lost = false, cause = e) - } catch (e: RuntimeException) { - throw PinFactorException(lost = false, cause = e) - } - if (!exists) reset(slot) - return !exists - } - - override fun reset(slot: PinKeySlot) { - delete(slot) - try { - generate(slot) - } catch (e: GeneralSecurityException) { - throw PinFactorException(lost = false, cause = e) - } catch (e: RuntimeException) { - throw PinFactorException(lost = false, cause = e) - } - } - - override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { - val key = try { - keyStore.getKey(alias(slot), null) as? SecretKey - } catch (e: UnrecoverableKeyException) { - throw PinFactorException(lost = true, cause = e) - } catch (e: GeneralSecurityException) { - throw PinFactorException(lost = false, cause = e) - } ?: throw PinFactorException(lost = true) - return try { - Mac.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256).run { - init(key) - doFinal(data) - } - } catch (e: KeyPermanentlyInvalidatedException) { - throw PinFactorException(lost = true, cause = e) - } catch (e: GeneralSecurityException) { - throw PinFactorException(lost = false, cause = e) - } catch (e: RuntimeException) { - // Keystore reports some failures as ProviderException. - throw PinFactorException(lost = false, cause = e) - } - } - - override fun delete(slot: PinKeySlot) { - val alias = alias(slot) - try { - if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) - } catch (e: GeneralSecurityException) { - AppLogger.w(TAG, "PIN factor key could not be deleted", e) - } catch (e: RuntimeException) { - AppLogger.w(TAG, "PIN factor key could not be deleted", e) - } - } - - /** - * StrongBox first; a device without one (or whose StrongBox lacks HMAC) falls back to the - * TEE. The unlocked-device requirement is dropped only if the platform rejects it outright. - */ - private fun generate(slot: PinKeySlot) { - val attempts = buildList { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(Options(strongBox = true, unlockedOnly = true)) - add(Options(strongBox = false, unlockedOnly = true)) - add(Options(strongBox = false, unlockedOnly = false)) - } - var last: Exception? = null - for (options in attempts) { - try { - KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_PROVIDER).run { - init(spec(alias(slot), options)) - generateKey() - } - return - } catch (e: GeneralSecurityException) { - last = e - } catch (e: RuntimeException) { - // StrongBoxUnavailableException, and rejected parameters on some devices, are - // ProviderExceptions. - last = e - } - delete(slot) - } - throw last ?: error("no key generated") - } - - private fun spec(alias: String, options: Options): KeyGenParameterSpec { - val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) - .setKeySize(HMAC_KEY_BITS) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - builder.setIsStrongBoxBacked(options.strongBox) - builder.setUnlockedDeviceRequired(options.unlockedOnly) - } - return builder.build() - } - - private data class Options(val strongBox: Boolean, val unlockedOnly: Boolean) - - private companion object { - const val TAG = "KeystorePinFactor" - const val KEYSTORE_PROVIDER = "AndroidKeyStore" - - /** Slot A keeps the alias every vault has used since v3, so existing installs are untouched. */ - const val KEY_ALIAS_A = "encly_pin_factor_v3" - const val KEY_ALIAS_B = "encly_pin_factor_v3b" - const val HMAC_KEY_BITS = 256 - - fun alias(slot: PinKeySlot): String = when (slot) { - PinKeySlot.A -> KEY_ALIAS_A - PinKeySlot.B -> KEY_ALIAS_B - } - } -} 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 deleted file mode 100644 index 3561791c..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/PinSlots.kt +++ /dev/null @@ -1,120 +0,0 @@ -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/RecoveryWords.kt b/app/src/main/java/com/pasich/encly/core/security/RecoveryWords.kt deleted file mode 100644 index d3ec011f..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/RecoveryWords.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.pasich.encly.core.security - -import cash.z.ecc.android.bip39.Mnemonics -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode - -/** The English BIP39 wordlist the recovery phrase is drawn from, for checking typed words. */ -object RecoveryWords { - /** Words in a recovery phrase. */ - const val COUNT = 12 - - /** Sorted, as BIP39 publishes it. */ - private val words: List by lazy { Mnemonics.getCachedWords(Mnemonics.DEFAULT_LANGUAGE_CODE) } - private val wordSet: Set by lazy { words.toHashSet() } - - fun isWord(word: String): Boolean = word in wordSet - - /** Whether some word starts with [prefix]; an empty prefix always does. */ - fun isPrefix(prefix: String): Boolean { - if (prefix.isEmpty()) return true - val index = words.binarySearch(prefix) - return index >= 0 || words.getOrNull(-index - 1)?.startsWith(prefix) == true - } - - /** Whether [phrase] (lower case, single spaces) is a valid mnemonic, checksum included. */ - fun isValidPhrase(phrase: CharArray): Boolean = try { - MnemonicCode(phrase).validate() - true - } catch (_: Exception) { - false - } -} 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 deleted file mode 100644 index 7146b8bf..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ /dev/null @@ -1,703 +0,0 @@ -package com.pasich.encly.core.security - -import android.content.SharedPreferences -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 -import java.util.concurrent.atomic.AtomicLong -import javax.inject.Inject -import javax.inject.Singleton - -enum class InitialStatus { - NO, - MAIN, - ONBOARDING, - LOSS_DATABASE, - LOSS_CRYPTO, - - /** - * An encrypted database without any v2 vault metadata: data written by Encly 1.x, which - * 2.0 cannot open (there is no migration). Wiped only after an explicit confirmation. - */ - LEGACY_VAULT, - AUTH, - SETUP_AUTH, -} - -enum class VaultUnlockResult { - SUCCESS, - INVALID_CREDENTIAL, - - /** A PIN lockout is running; the PIN was not checked. */ - LOCKED_OUT, - - /** - * The device-bound half of the PIN key is gone (Keystore reset, key invalidated): the PIN - * can no longer unlock on this device. The recovery phrase (or fingerprint) still can. - */ - PIN_KEY_LOST, - DB_ERROR, -} - -/** - * Security coordinator for the v2 vault. - * - * The database key is never re-derived from locally stored seed material. Each unlock factor - * must unwrap the same random DEK, after which the DEK opens SQLCipher. - */ -@Singleton -class SecurityManager @Inject constructor( - private val appFlags: SharedPreferences, - private val vaultStore: VaultStore, - private val seedPhraseManager: SeedPhraseManager, - private val secureDatabaseManager: SecureDatabaseManager, - private val authenticationManager: AuthenticationManager, - private val biometricManager: BiometricManager, - /** Hilt always passes the real one; the default only spares tests that never erase. */ - private val handoffStaging: HandoffStagingSweeper = HandoffStagingSweeper {}, -) { - companion object { - private const val TAG = "SecurityManager" - private const val ONBOARDING_SHOWN_KEY = "onboarding_shown_v3" - private const val DEK_LENGTH = 32 - } - - @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. - @Volatile - private var sessionUnlockedWithRecovery = false - - // Serializes what creates, opens or deletes the database file while a wipe-PIN erase is - // pending: an unlock that replaces the database, and the erase's finish at startup (which a - // recreated activity runs again while an unlock may be in the middle of creating the new one). - private val databaseLock = Any() - - // The erase's finish at startup runs once per process: later calls (a rotated activity, the - // My Notes hand-off) find the vault already in use. - private val startupFinished = AtomicBoolean(false) - - // 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 - - /** - * Never throws: vault storage that cannot be read (a damaged state file, a storage or - * Keystore failure) routes to the damaged-vault screen instead of crash-looping the app. - */ - @Suppress("TooGenericExceptionCaught") // Any startup failure must route, never crash-loop. - fun resolveInitialStatus(): InitialStatus { - // A recreated activity (rotation, dark mode, font size) asks again while the vault is - // open in this process: it stays open. Only a vault that is not open starts locked. - if (isSessionOpen()) return InitialStatus.MAIN.also { securityStatus = it } - val status = try { - initializeSecurity() - } catch (e: Exception) { - AppLogger.e(TAG, "Vault state could not be read", e) - InitialStatus.LOSS_CRYPTO - } - securityStatus = status - return status - } - - private fun initializeSecurity(): InitialStatus { - if (vaultStore.isCorrupt()) return InitialStatus.LOSS_CRYPTO - if (!isOnboardingShown()) return uncommittedVaultStatus() - // Vaults from before the wipe PIN get their decoy wipe slot; no PIN needed, nothing shown. - authenticationManager.ensureWipeSlot() - if (startupFinished.compareAndSet(false, true)) { - // A key left by an erase the process died in before it was recorded. - authenticationManager.deleteStrayPinKey() - // A wipe-PIN erase the process died in is finished before anything else looks at the vault. - completePendingWipe() - } - if (!seedPhraseManager.verificationKeyData()) return InitialStatus.LOSS_CRYPTO - // An erase that has not created the empty database yet does so at the next PIN unlock. - if (!secureDatabaseManager.hasEncryptedDatabase() && authenticationManager.pendingWipe() == null) { - return InitialStatus.LOSS_DATABASE - } - - // A lost PIN slot is recoverable only when the user explicitly created a recovery slot. - // The lock screen can unwrap the DEK with that seed; once unlocked, Settings can create - // a fresh PIN around the live session DEK. - if (!authenticationManager.hasPinSlot() && !seedPhraseManager.hasRecoverySeed()) { - return InitialStatus.LOSS_CRYPTO - } - return InitialStatus.AUTH - } - - /** - * No committed vault. Onboarding creates a new vault and deletes database.db, so it is only - * safe when no user data can exist yet. An encrypted database with no vault metadata is a - * pre-2.0 (v1) vault: 2.0 cannot open it and does not migrate it, so it is only deleted - * after the explicit confirmation on the older-version screen. - */ - private fun uncommittedVaultStatus(): InitialStatus = if (secureDatabaseManager.hasEncryptedDatabase() && - !seedPhraseManager.hasStoredSeed() - ) { - InitialStatus.LEGACY_VAULT - } else { - InitialStatus.ONBOARDING - } - - /** True when first-run setup may (re)create the vault without destroying user data. */ - private fun isSafeToCreateVault(): Boolean = - !isOnboardingShown() && uncommittedVaultStatus() == InitialStatus.ONBOARDING - - fun authStrategy(): AuthStrategy = authenticationManager.isAuthStrategy() - - fun isBiometricEnabled(): Boolean = authenticationManager.isBiometricEnabled() && biometricManager.hasSlot() - - fun biometricAvailable(): Boolean = biometricManager.isStrongBiometricAvailable() - - /** - * Whether a re-authentication offers the fingerprint: biometric unlock is on and usable, and the - * open vault is the real one (a fingerprint belongs to it and never confirms anything in the - * decoy vault, so the option is not shown there either). - */ - fun canReauthWithBiometric(): Boolean = isBiometricEnabled() && biometricAvailable() && !isDecoySession() - - fun biometricStatus(): BiometricStatus = biometricManager.strongBiometricStatus() - - fun hasRecoverySeed(): Boolean = seedPhraseManager.hasRecoverySeed() - - /** - * True while the open session is the decoy vault the duress PIN opened. Nothing on screen - * may say so: Settings looks the same, and this layer refuses what belongs to the real vault. - */ - fun isDecoySession(): Boolean = sessionDek != null && sessionVault == VaultFile.DECOY - - /** Sets a new PIN slot around the live DEK. [pin] is wiped. */ - fun configurePin(pin: CharArray): Boolean = 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 change = try { - if (dek == null) PinChange.FAILED else authenticationManager.changePin(pin, dek) - } finally { - dek?.let(SensitiveDataCleaner::clear) - SensitiveDataCleaner.clear(pin) - } - 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 && !isDecoySession() - - /** - * One PIN attempt from the lock screen; [pin] is wiped. The wipe PIN reports - * [VaultUnlockResult.SUCCESS] like the PIN, with the new, empty vault open: phases 1 and 2 - * of the erase ran in [AuthenticationManager.unlockWithPin], and [unlockWithRawKey] replaces - * the old database with an empty one. The rest of the erase is left for - * [completePendingWipe], which the caller runs once the vault is shown (or the next start - * does), so the wipe PIN does not wait for it. - */ - fun unlockWithPin(pin: CharArray): VaultUnlockResult { - val attempt = try { - authenticationManager.unlockWithPin(pin) - } finally { - SensitiveDataCleaner.clear(pin) - } - return when (attempt) { - is PinUnlock.Success -> try { - if (unlockWithRawKey(attempt.dek)) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR - } finally { - SensitiveDataCleaner.clear(attempt.dek) - } - - is PinUnlock.Erased -> try { - if (unlockWithRawKey(attempt.dek)) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR - } finally { - 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 - - PinUnlock.KeyLost -> VaultUnlockResult.PIN_KEY_LOST - - PinUnlock.Failed -> VaultUnlockResult.DB_ERROR - } - } - - fun unlockWithSeed(phrase: CharArray): VaultUnlockResult { - val dek = seedPhraseManager.unlockWithSeed(phrase) - ?: return VaultUnlockResult.INVALID_CREDENTIAL - val opened = try { - unlockWithRawKey(dek) - } finally { - SensitiveDataCleaner.clear(dek) - } - sessionUnlockedWithRecovery = opened - return if (opened) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR - } - - fun requestBiometricKey(activity: FragmentActivity, onResult: (ByteArray?) -> Unit) { - biometricManager.unlock(activity, onResult) - } - - fun enrollBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { - // The biometric slot opens the real vault only. - val dek = if (isDecoySession()) null else currentKeyCopy() - if (dek == null) { - onResult(false) - return - } - - biometricManager.enroll(activity, dek) { ok -> - authenticationManager.markBiometricEnabled(ok) - onResult(ok) - } - SensitiveDataCleaner.clear(dek) - } - - 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, - object : BiometricManager.BiometricCallback { - override fun onSuccess() = onResult(true) - override fun onError(errorCode: Int, errorMessage: String) = onResult(false) - override fun onFailed() = Unit - override fun onCancelled() = onResult(false) - }, - ) - } - - // --- encrypted backups -------------------------------------------------------------- - - /** True when backups of this vault can be sealed to its recovery phrase without asking. */ - fun hasBackupKey(): Boolean = seedPhraseManager.hasBackupKey() - - fun isValidRecoveryPhrase(phrase: CharArray): Boolean = seedPhraseManager.isValidMnemonic(phrase) - - /** - * The seed-derived backup root of the unlocked vault, or null when the session is locked - * or the vault has no backup-key slot. The caller must wipe the returned key. - */ - fun copyBackupRootKey(): ByteArray? { - val dek = primarySessionKeyCopy() ?: return null - return try { - seedPhraseManager.unwrapBackupRoot(dek) - } finally { - SensitiveDataCleaner.clear(dek) - } - } - - /** - * Adds a recovery seed (and backup key) to the unlocked vault, which then behaves like one - * created in user-managed mode. See [SeedPhraseManager.addRecoverySeed]. - */ - fun addRecoverySeed(phrase: CharArray): Boolean { - val dek = primarySessionKeyCopy() ?: return false - return try { - seedPhraseManager.addRecoverySeed(phrase, dek) - } finally { - SensitiveDataCleaner.clear(dek) - } - } - - /** - * Replaces the recovery phrase of the unlocked vault with [phrase] (see - * [SeedPhraseManager.replaceRecoverySeed]). The old words stop opening this vault; backups - * sealed to them still need them. The caller wipes [phrase]. - */ - fun replaceRecoverySeed(phrase: CharArray): Boolean { - val dek = primarySessionKeyCopy() ?: return false - return try { - seedPhraseManager.replaceRecoverySeed(phrase, dek) - } finally { - SensitiveDataCleaner.clear(dek) - } - } - - /** Adds the backup-key slot to an unlocked recovery-seed vault; [phrase] must be its seed. */ - fun createBackupKey(phrase: CharArray): Boolean { - val dek = primarySessionKeyCopy() ?: return false - return try { - seedPhraseManager.createBackupKey(phrase, dek) - } finally { - SensitiveDataCleaner.clear(dek) - } - } - - fun pinLockoutRemainingMillis(): Long = authenticationManager.remainingLockoutMillis() - - /** - * 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, decoy = isDecoySession()) - } finally { - SensitiveDataCleaner.clear(pin) - } - - fun verifySeed(phrase: CharArray): Boolean = seedPhraseManager.verifyMnemonic(phrase) - - /** - * Opens SQLCipher with an already unwrapped v2 DEK. - * Caller retains ownership of [dek] and should wipe it after this call. - * - * While a wipe-PIN erase still has to create the empty database ([WipeStage.DATABASE]), - * whatever is left of the old one is deleted first and the new one is created with [dek]: - * only the new PIN slot can still produce a DEK then. Phase 3 of the erase: the erased slots - * are already gone, so a kill from here on ends in an empty vault, which the next start or - * unlock finishes. - */ - fun unlockWithRawKey(dek: ByteArray, allowCreate: Boolean = false): Boolean { - if (dek.size != DEK_LENGTH) return false - return synchronized(databaseLock) { - val replaceDatabase = authenticationManager.pendingWipe() == WipeStage.DATABASE - if (replaceDatabase) secureDatabaseManager.wipe() - val opened = secureDatabaseManager.unlockDatabase(dek, allowCreate = allowCreate || replaceDatabase) - val ok = opened && (!replaceDatabase || recordErasedDatabase()) - if (ok) { - 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 - } - } - - /** - * The empty database of an erase was created: records it before anything can be written to - * it. Unrecorded, the next start would take it for the old database and delete it, with - * everything added since; so when the store cannot be written the database is closed again - * and the unlock fails, and the next one creates it anew. - */ - private fun recordErasedDatabase(): Boolean { - if (!authenticationManager.markWipeDatabaseCreated()) { - AppLogger.w(TAG, "The erased vault's new database could not be recorded") - secureDatabaseManager.reset() - return false - } - eraseEpoch.incrementAndGet() - return true - } - - /** - * Finishes a wipe-PIN erase (see [WipeStage]); does nothing when none is pending. Safe to - * repeat: every step is a deletion. Runs at startup, and after an unlock that opened the - * empty database (off the unlock's path, see [unlockWithPin]). Theme, sorting and - * auto-lock settings are kept, as on any vault. - */ - fun completePendingWipe() { - synchronized(databaseLock) { - val stage = authenticationManager.pendingWipe() ?: return - // Before the empty database exists, whatever is left of the old one goes (never an open one). - if (stage == WipeStage.DATABASE) secureDatabaseManager.wipeIfClosed() - authenticationManager.deleteRetiredPinKey() - // Its slot went with the erase; this deletes the Keystore key behind it. - biometricManager.disable() - appFlags.edit().remove(BackupManager.LAST_EXPORT_KEY).commit() - // A My Notes hand-off a killed process left in the cache is plaintext of the old vault's time. - handoffStaging.sweep() - if (stage == WipeStage.CLEANUP) authenticationManager.clearPendingWipe() - } - } - - /** - * 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() - - // --- wipe PIN ----------------------------------------------------------------------- - - /** - * Sets (or replaces) the wipe PIN; only in an unlocked session, after the caller checked the - * PIN. [pin] is wiped. - */ - fun configureWipePin(pin: CharArray): WipePinChange = try { - 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 = primarySessionOpen() && authenticationManager.removeWipePin() - - // --- duress PIN (decoy vault) --------------------------------------------------------- - - /** - * Sets (or replaces) the duress PIN; only in a session of the real vault, after the caller - * checked the PIN. A replaced duress PIN opens the same decoy notes. [pin] is wiped. - */ - fun configureDuressPin(pin: CharArray): DuressPinChange { - val dek = primarySessionKeyCopy() - return try { - if (dek == null) { - DuressPinChange.FAILED - } else { - authenticationManager.configureDuressPin(pin, dek) { - secureDatabaseManager.wipeIfClosed(VaultFile.DECOY) - } - } - } finally { - dek?.let(SensitiveDataCleaner::clear) - SensitiveDataCleaner.clear(pin) - } - } - - /** - * Turns the duress PIN off and deletes the decoy vault with its notes; only in a session of - * the real vault, after the caller checked the PIN and the user confirmed. The real vault is - * not touched. The key goes first: a kill before the files are deleted leaves a database - * nothing can open, which the next duress PIN set replaces. - */ - fun removeDuressPin(): Boolean { - if (!primarySessionOpen()) return false - return authenticationManager.removeDuressPin() && secureDatabaseManager.wipeIfClosed(VaultFile.DECOY) - } - - /** - * Whether the real vault has a decoy vault (its duress PIN may have been turned off, see - * [duressPinTurnedOff]); false outside a session of the real vault. - */ - fun hasDuressPin(): Boolean = withPrimaryKey(authenticationManager::hasDecoyVault) - - /** Whether the real vault's duress PIN stopped working; false outside a session of the real vault. */ - fun duressPinTurnedOff(): Boolean = withPrimaryKey(authenticationManager::duressPinTurnedOff) - - private fun withPrimaryKey(check: (ByteArray) -> Boolean): Boolean { - val dek = primarySessionKeyCopy() ?: return false - return try { - check(dek) - } finally { - SensitiveDataCleaner.clear(dek) - } - } - - /** - * Completes first-run setup only after a PIN slot exists and the bootstrap DEK opens SQLCipher. - */ - fun finishInitialSetup(): Boolean = openInitialVault() && commitInitialSetup() - - /** - * First half of [finishInitialSetup]: opens (creating) the new vault with the bootstrap DEK - * once a PIN slot exists, without committing onboarding. Lets a staged restore be imported - * before the vault counts as set up; until [commitInitialSetup], a killed process simply - * starts onboarding over. - */ - fun openInitialVault(): Boolean { - val dek = if (authenticationManager.hasPinSlot()) { - seedPhraseManager.copyBootstrapKey() - } else { - null - } ?: return false - - return try { - unlockWithRawKey(dek, allowCreate = true) - } finally { - SensitiveDataCleaner.clear(dek) - } - } - - /** Second half of [finishInitialSetup]: commits onboarding and drops the bootstrap DEK. */ - fun commitInitialSetup(): Boolean { - val finalized = setOnboardingShown() - if (finalized) seedPhraseManager.clearBootstrapKey() - return finalized - } - - fun lock() { - secureDatabaseManager.reset() - clearSessionKey() - securityStatus = InitialStatus.AUTH - } - - /** - * Whether backgrounding must close the open vault. Not before onboarding is committed: the - * only vault open then is the one first-run setup just created, possibly still importing a - * restore that a re-lock would abort. Setup closes it itself if it finishes in the - * background (SessionLockManager.onUnlocked) or fails. - */ - fun isLockable(): Boolean = - isOnboardingShown() && (authenticationManager.hasPinSlot() || seedPhraseManager.hasRecoverySeed()) - - fun isDatabaseUnlocked(): Boolean = secureDatabaseManager.isDatabaseUnlocked() - - /** True while this process holds the vault open: the session key and the database. */ - private fun isSessionOpen(): Boolean = sessionDek != null && secureDatabaseManager.isDatabaseUnlocked() - - fun isOnboardingShow(): Boolean = !isOnboardingShown() - - private fun isOnboardingShown(): Boolean = appFlags.getBoolean(ONBOARDING_SHOWN_KEY, false) - - fun setOnboardingShown(): Boolean { - if (!seedPhraseManager.verificationKeyData() || !authenticationManager.hasPinSlot()) { - return false - } - return appFlags.edit() - .putBoolean(ONBOARDING_SHOWN_KEY, true) - .commit() - } - - fun generateMnemonicCode(): CharArray = seedPhraseManager.generateMnemonic().chars - - /** - * Starts a brand-new vault with no legacy state. First-run setup is intentionally - * destructive until onboarding has been finalized, because no user data is considered - * committed before a valid PIN slot exists. - */ - fun initializeNewVault(recoverySeed: CharArray?): Boolean { - // Refuse to overwrite a committed vault or a database nothing can open yet. - if (!isSafeToCreateVault()) return false - secureDatabaseManager.wipe() - clearSessionKey() - biometricManager.disable() - authenticationManager.wipe() - seedPhraseManager.wipe() - appFlags.edit().remove(ONBOARDING_SHOWN_KEY).commit() - // A damaged state file cannot be edited slot by slot; a new vault starts from none. - if (vaultStore.isCorrupt()) vaultStore.clear() - securityStatus = InitialStatus.ONBOARDING - return seedPhraseManager.initializeVault(recoverySeed) - } - - private fun currentKeyCopy(): ByteArray? = sessionDek?.copyOf() ?: seedPhraseManager.copyBootstrapKey() - - 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, where it - * changes nothing and returns false. - */ - fun wipeAndReset(): Boolean { - if (isDecoySession()) return false - secureDatabaseManager.wipe() - clearSessionKey() - biometricManager.disable() - authenticationManager.wipe() - seedPhraseManager.wipe() - // Also the way out of a damaged state file, which the per-slot wipes above cannot edit. - vaultStore.clear() - appFlags.edit().clear().commit() - securityStatus = InitialStatus.ONBOARDING - return true - } - - fun getSettingsAuth(): AuthSettings = AuthSettings( - authType = authenticationManager.getAuthType(), - isBiometricEnabled = isBiometricEnabled(), - isUserCreatedSeedKey = seedPhraseManager.hasRecoverySeed(), - hasPinSlot = authenticationManager.hasPinSlot(), - wipePinTurnedOff = authenticationManager.wipePinTurnedOff(), - hasDuressPin = hasDuressPin(), - duressPinTurnedOff = duressPinTurnedOff(), - ) -} - -/** - * The vault's security state as Settings shows it. [wipePinTurnedOff]: the PIN key was reset, - * so any wipe PIN stopped working (whether one was set is never known without it). - */ -data class AuthSettings( - val authType: AuthType, - val isBiometricEnabled: Boolean, - val isUserCreatedSeedKey: Boolean, - val hasPinSlot: Boolean = authType == AuthType.PIN, - val wipePinTurnedOff: Boolean = false, - /** The real vault has a duress PIN (known only in its own session). */ - val hasDuressPin: Boolean = false, - /** A new PIN slot turned the duress PIN off; setting it again keeps the notes. */ - val duressPinTurnedOff: Boolean = false, -) diff --git a/app/src/main/java/com/pasich/encly/core/security/SensitiveDataCleaner.kt b/app/src/main/java/com/pasich/encly/core/security/SensitiveDataCleaner.kt deleted file mode 100644 index aabb8ce5..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/SensitiveDataCleaner.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.pasich.encly.core.security - -object SensitiveDataCleaner { - fun clear(data: ByteArray) { - data.fill(0) - } - - fun clear(data: CharArray) { - data.fill(0.toChar()) - } -} 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 deleted file mode 100644 index 4828c702..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.pasich.encly.core.security - -import android.os.Handler -import android.os.Looper -import androidx.annotation.MainThread -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Whether the open session was closed. ViewModels that hold decrypted content observe it and - * drop that content at once: after a background re-lock the UI (and its navigation away from - * protected screens) only runs again once the app is back in front. - */ -interface VaultLockEvents { - /** True from the moment the vault is closed until the next unlock is published. */ - val locked: StateFlow -} - -/** For code that runs without a session manager (tests, previews): never locks. */ -object NeverLocked : VaultLockEvents { - override val locked: StateFlow = MutableStateFlow(false).asStateFlow() -} - -/** - * Auto-locks the app when it goes to the background. - * - * Registered against [androidx.lifecycle.ProcessLifecycleOwner] in [com.pasich.encly.MyApplication]. - * On [onStop] (the whole process backgrounded) it re-locks the encrypted database if a - * re-authentication strategy is configured and the session is currently unlocked, then flips - * [locked] so the UI navigates back to the lock screen the next time the app is foregrounded. - * - * Re-locking closes SQLCipher and zeroizes Encly's in-memory DEK copy. The next foreground - * unlock must unwrap the DEK again through the PIN, auth-bound biometric, or recovery slot. - * - * It waits the user's auto-lock delay first ([AutoLockPolicy], 15 s by default, "immediately" - * possible), and a backup import's system file picker (another app) gets its own short grace: - * see [allowSystemPicker]. Both are measured on `elapsedRealtime` (deep sleep counts), end - * the moment the screen turns off or the device locks, and are re-checked when the app comes - * back. - */ -@Singleton -@Suppress("TooManyFunctions") // The whole session lifecycle: lifecycle hooks, picker grace, unlock publishing. -class SessionLockManager @Inject constructor( - private val securityManager: SecurityManager, - private val deviceLock: DeviceLockWatcher = DeviceLockWatcher.None, - systemClock: LockoutClock = JvmMonotonicClock, - private val autoLock: AutoLockPolicy = AutoLockPolicy.Immediately, -) : DefaultLifecycleObserver, - VaultLockEvents { - - private val _locked = MutableStateFlow(false) - - /** Emits true when the app was re-locked in the background and must show the lock screen. */ - override val locked: StateFlow = _locked.asStateFlow() - - /** - * Counts published unlocks: each one starts a new session. A screen that was stopped while - * this changed (the vault was unlocked on another lock screen, such as the My Notes - * hand-off's) shows a session it never saw, possibly after an erase. - */ - @Volatile - var sessionGeneration = 0L - private set - - /** - * Whether the process is in the foreground (between ProcessLifecycleOwner ON_START and - * ON_STOP). An unlock that finishes after ON_STOP must not leave the vault open. - */ - @Volatile - private var foreground = false - - /** Milliseconds since boot, deep sleep included (`elapsedRealtime`); replaceable in tests. */ - internal var clock: () -> Long = systemClock::elapsedRealtime - - @Volatile - private var systemPickerRequestedAt = NONE - - @Volatile - private var suspendedAt = NONE - - /** How long the current suspension may last: the picker grace or the auto-lock delay. */ - @Volatile - private var awayLimitMs = 0L - - /** Set when the screen went off (or the keyguard came up) during a picker grace. */ - @Volatile - private var lockedWhileAway = false - - /** Schedules [action] on the main thread after a delay; returns a cancel function. */ - internal var timer: (delayMs: Long, action: () -> Unit) -> () -> Unit = { delayMs, action -> - val handler = Handler(Looper.getMainLooper()) - val runnable = Runnable(action) - handler.postDelayed(runnable, delayMs) - val cancel: () -> Unit = { handler.removeCallbacks(runnable) } - cancel - } - - private var cancelPickerTimeout: (() -> Unit)? = null - private var stopScreenOffWatch: (() -> Unit)? = null - - /** - * Announces that the app is about to open the system file picker (Storage Access Framework) - * to choose a backup to import. The picker is another app, so the process goes to the - * background; the next ON_STOP within [PICKER_LAUNCH_WINDOW_MS] therefore keeps the vault - * open instead of re-locking it, for at most [PICKER_MAX_AWAY_MS]. Turning the screen off - * or locking the device ends the grace at once. - * - * Export does not need this: its file is sealed before the picker opens. - */ - @MainThread - fun allowSystemPicker() { - systemPickerRequestedAt = clock() - } - - /** The picker returned (or never opened): no later ON_STOP gets the grace. */ - @MainThread - fun endSystemPicker() { - systemPickerRequestedAt = NONE - endSuspension() - } - - /** - * Startup found a committed vault that is not open. Starts [locked], so any screen that a - * restored back stack (process death) or an intent would open is replaced by the lock - * screen before it can show anything. - */ - @MainThread - fun requireUnlock() { - _locked.value = true - } - - override fun onStart(owner: LifecycleOwner) { - foreground = true - val stoppedAt = suspendedAt - val lockedAway = lockedWhileAway - endSuspension() - if (stoppedAt == NONE) return - if (graceBroken(stoppedAt, lockedAway) && isOpenAndLockable()) relock() - } - - override fun onStop(owner: LifecycleOwner) { - foreground = false - val requestedAt = systemPickerRequestedAt - systemPickerRequestedAt = NONE - val now = clock() - if (requestedAt != NONE && now - requestedAt in 0..PICKER_LAUNCH_WINDOW_MS) { - suspend(now, PICKER_MAX_AWAY_MS) - return - } - if (!isOpenAndLockable()) return - val delay = autoLock.delayMillis - if (delay > 0) suspend(now, delay) else relock() - } - - /** - * Keeps the vault open for up to [limitMs] while the app is away (the picker grace, or the - * auto-lock delay). The screen going off closes it right away, even if the app never comes - * back; the timer closes it when the time runs out. - */ - private fun suspend(now: Long, limitMs: Long) { - suspendedAt = now - awayLimitMs = limitMs - lockedWhileAway = false - stopScreenOffWatch = deviceLock.watchScreenOff { onAwayTimeout(screenOff = true) } - cancelPickerTimeout = timer(limitMs) { onAwayTimeout(screenOff = false) } - } - - /** 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() - - private fun onAwayTimeout(screenOff: Boolean) { - if (foreground || suspendedAt == NONE) return - if (screenOff) lockedWhileAway = true - endSuspension() - if (isOpenAndLockable()) relock() - } - - private fun endSuspension() { - suspendedAt = NONE - lockedWhileAway = false - cancelPickerTimeout?.invoke() - cancelPickerTimeout = null - stopScreenOffWatch?.invoke() - stopScreenOffWatch = null - } - - /** - * Publishes a completed unlock (PIN, biometric, recovery seed, first-run setup or v1 - * upgrade). Call on the main thread, where ON_START/ON_STOP are delivered too, right - * after the vault was opened. - * - * PBKDF2 and the SQLCipher open run off the main thread and can finish after the app was - * sent to the background, when [onStop] already ran and found nothing to close. In that - * case the vault is closed again at once, [locked] stays true and this returns false: - * the caller must not navigate into protected content. - */ - @MainThread - fun onUnlocked(): Boolean { - if (!foreground) { - relock() - return false - } - sessionGeneration++ - _locked.value = false - return true - } - - /** - * An unlock finished for a screen that no longer exists (its ViewModel was cleared while - * the key was being checked). Nobody will navigate into the vault, so it is closed again. - */ - @MainThread - fun onUnlockAbandoned() { - if (isOpenAndLockable()) relock() - } - - /** - * "Lock now" on the notes screen: closes the open vault exactly as backgrounding does, so the - * lock screen follows. Nothing happens before onboarding is committed or with no vault open. - */ - @MainThread - fun lockNow() { - if (isOpenAndLockable()) relock() - } - - private fun isOpenAndLockable(): Boolean = securityManager.isLockable() && securityManager.isDatabaseUnlocked() - - private fun relock() { - securityManager.lock() - _locked.value = true - } - - private companion object { - const val NONE = Long.MIN_VALUE - const val PICKER_LAUNCH_WINDOW_MS = 5_000L - const val PICKER_MAX_AWAY_MS = 60_000L - } -} diff --git a/app/src/main/java/com/pasich/encly/core/security/SystemDeviceLockWatcher.kt b/app/src/main/java/com/pasich/encly/core/security/SystemDeviceLockWatcher.kt new file mode 100644 index 00000000..a69bfa88 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/SystemDeviceLockWatcher.kt @@ -0,0 +1,47 @@ +package com.pasich.encly.core.security + +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +/** [DeviceLockWatcher] on the KeyguardManager and the screen-off broadcast. */ +@SingleIn(AppScope::class) +@Inject +class SystemDeviceLockWatcher(@param:ApplicationContext private val context: Context) : DeviceLockWatcher { + override fun isDeviceLocked(): Boolean = + context.getSystemService(KeyguardManager::class.java)?.isDeviceLocked == true + + override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_SCREEN_OFF) onScreenOff() + } + } + return try { + // A protected system broadcast: NOT_EXPORTED still receives it. + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(Intent.ACTION_SCREEN_OFF), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + val stop: () -> Unit = { + try { + context.unregisterReceiver(receiver) + } catch (_: IllegalArgumentException) { + // Already unregistered. + } + } + stop + } catch (_: RuntimeException) { + {} + } + } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/SystemLockoutClock.kt b/app/src/main/java/com/pasich/encly/core/security/SystemLockoutClock.kt new file mode 100644 index 00000000..c0368604 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/SystemLockoutClock.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.core.security + +import android.content.Context +import android.os.SystemClock +import android.provider.Settings +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +/** [LockoutClock] on `SystemClock.elapsedRealtime` and the system's boot counter. */ +@SingleIn(AppScope::class) +@Inject +class SystemLockoutClock(@param:ApplicationContext private val context: Context) : LockoutClock { + override fun elapsedRealtime(): Long = SystemClock.elapsedRealtime() + + override fun bootCount(): Int = try { + Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1) + } catch (_: RuntimeException) { + -1 + } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt b/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt deleted file mode 100644 index 65805572..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.pasich.encly.core.security - -import java.io.ByteArrayOutputStream -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.nio.ByteBuffer -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.security.MessageDigest - -/** - * The vault's key slots and PIN lockout state, in one app-private file. - * - * Every secret stored here is already an AES-256-GCM envelope (PIN, recovery, backup-key and - * biometric slots), so the file needs no second encryption layer and no Keystore key to be - * read: unlike EncryptedSharedPreferences, a broken Keystore/Tink keyset can not make it - * unreadable. What it does need is to never be half-written and to notice damage: - * - * - every change rewrites the whole file to a temp file, fsyncs it and renames it over the - * old one (an atomic replace on the same filesystem), so a crash leaves either the old or - * the new state, never a mix; - * - the file ends with a SHA-256 of its content. A file that fails the check is reported by - * [isCorrupt] and reads as empty (fail closed: no slot, nothing unlocks), writes are - * refused, and only [clear] (the damaged-vault wipe) starts over. - * - * The digest detects accidental damage only; tampering is caught by each envelope's GCM tag. - * Values are copied in and out, and the caller wipes what it gets. - */ -@Suppress("TooManyFunctions") // Typed getters/setters of one small key-value file. -class VaultStore(private val file: File) { - private val lock = Any() - - // Guarded by [lock]. Null until the first read. - private var cache: Map? = null - private var corrupt = false - - /** True when the file exists but could not be read back intact. */ - fun isCorrupt(): Boolean = synchronized(lock) { - load() - corrupt - } - - fun contains(key: String): Boolean = synchronized(lock) { load().containsKey(key) } - - /** The keys present; for tests that compare what a store reveals by its shape. */ - internal fun keys(): Set = synchronized(lock) { load().keys.toSet() } - - fun getBytes(key: String): ByteArray? = synchronized(lock) { load()[key]?.copyOf() } - - fun getInt(key: String, default: Int): Int = synchronized(lock) { - load()[key]?.takeIf { it.size == Int.SIZE_BYTES }?.let { ByteBuffer.wrap(it).int } ?: default - } - - fun getLong(key: String, default: Long): Long = synchronized(lock) { - load()[key]?.takeIf { it.size == Long.SIZE_BYTES }?.let { ByteBuffer.wrap(it).long } ?: default - } - - fun getBoolean(key: String, default: Boolean): Boolean = synchronized(lock) { - load()[key]?.takeIf { it.size == BOOLEAN_BYTES }?.let { it[0] == TRUE } ?: default - } - - /** - * Applies every change made in [block] in one atomic, durable write. Returns false (and - * changes nothing) when the file is damaged or cannot be written. - */ - fun edit(block: Editor.() -> Unit): Boolean = synchronized(lock) { - val current = load() - if (corrupt) return false - val next = LinkedHashMap(current) - Editor(next).block() - val written = try { - write(next) - true - } catch (_: IOException) { - false - } - if (written) { - cache = next - // Values dropped or replaced by this edit are no longer referenced. - current.forEach { (key, value) -> if (next[key] !== value) SensitiveDataCleaner.clear(value) } - } else { - next.forEach { (key, value) -> if (current[key] !== value) SensitiveDataCleaner.clear(value) } - } - written - } - - /** Deletes the file (damaged or not) and forgets everything. */ - fun clear(): Boolean = synchronized(lock) { - cache?.values?.forEach(SensitiveDataCleaner::clear) - cache = emptyMap() - corrupt = false - tempFile().delete() - !file.exists() || file.delete() - } - - class Editor internal constructor(private val values: MutableMap) { - /** Stores a copy of [value]; the caller keeps (and wipes) its own. */ - fun putBytes(key: String, value: ByteArray) { - values[key] = value.copyOf() - } - - fun putInt(key: String, value: Int) { - values[key] = ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value).array() - } - - fun putLong(key: String, value: Long) { - values[key] = ByteBuffer.allocate(Long.SIZE_BYTES).putLong(value).array() - } - - fun putBoolean(key: String, value: Boolean) { - values[key] = byteArrayOf(if (value) TRUE else FALSE) - } - - fun remove(key: String) { - values.remove(key) - } - - /** Removes every key starting with [prefix]. */ - fun removePrefix(prefix: String) { - values.keys.removeAll { it.startsWith(prefix) } - } - } - - private fun load(): Map { - cache?.let { return it } - // A temp file left by a write that never reached its rename is never the state. - tempFile().delete() - val loaded = if (!file.exists()) { - emptyMap() - } else { - try { - parse(file.readBytes()) - } catch (_: IOException) { - null - } - } - corrupt = loaded == null - return (loaded ?: emptyMap()).also { cache = it } - } - - private fun write(values: Map) { - val bytes = serialize(values) - file.parentFile?.mkdirs() - val temp = tempFile() - try { - FileOutputStream(temp).use { out -> - out.write(bytes) - out.flush() - out.fd.sync() - } - beforeRename?.invoke() - try { - Files.move( - temp.toPath(), - file.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (_: AtomicMoveNotSupportedException) { - if (!temp.renameTo(file)) throw IOException("rename failed") - } - } catch (e: IOException) { - temp.delete() - throw e - } finally { - SensitiveDataCleaner.clear(bytes) - } - } - - private fun tempFile() = File(file.path + TEMP_SUFFIX) - - /** Test hook: runs after the temp file is durable and before it replaces the real one. */ - internal var beforeRename: (() -> Unit)? = null - - private companion object { - const val TEMP_SUFFIX = ".tmp" - const val DIGEST_LENGTH = 32 - const val MAX_ENTRIES = 256 - const val MAX_VALUE_LENGTH = 64 * 1024 - const val BOOLEAN_BYTES = 1 - const val TRUE: Byte = 1 - const val FALSE: Byte = 0 - val MAGIC = "ENCLYVS3".toByteArray(Charsets.US_ASCII) - - fun serialize(values: Map): ByteArray { - val buffer = ByteArrayOutputStream() - DataOutputStream(buffer).use { out -> - out.write(MAGIC) - out.writeInt(values.size) - values.forEach { (key, value) -> - out.writeUTF(key) - out.writeInt(value.size) - out.write(value) - } - } - val body = buffer.toByteArray() - val digest = MessageDigest.getInstance("SHA-256").digest(body) - return try { - body + digest - } finally { - SensitiveDataCleaner.clear(body) - } - } - - /** The entries of [bytes], or null when it is not an intact store file. */ - @Suppress("ReturnCount") // Each structural check fails closed. - fun parse(bytes: ByteArray): Map? { - try { - if (bytes.size < MAGIC.size + Int.SIZE_BYTES + DIGEST_LENGTH) return null - val bodyLength = bytes.size - DIGEST_LENGTH - val expected = MessageDigest.getInstance("SHA-256").apply { update(bytes, 0, bodyLength) }.digest() - if (!MessageDigest.isEqual(expected, bytes.copyOfRange(bodyLength, bytes.size))) return null - val input = DataInputStream(bytes.inputStream(0, bodyLength)) - val magic = ByteArray(MAGIC.size).also(input::readFully) - if (!magic.contentEquals(MAGIC)) return null - val count = input.readInt() - if (count !in 0..MAX_ENTRIES) return null - val values = LinkedHashMap(count) - repeat(count) { - val key = input.readUTF() - val length = input.readInt() - if (length !in 0..MAX_VALUE_LENGTH) return null - values[key] = ByteArray(length).also(input::readFully) - } - return if (input.available() == 0) values else null - } catch (_: IOException) { - return null - } finally { - SensitiveDataCleaner.clear(bytes) - } - } - } -} 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 deleted file mode 100644 index af4b7a39..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.pasich.encly.core.security.cipher - -import android.content.Context -import java.io.File -import java.io.FileInputStream - -/** - * Detects whether the app database file is missing, plaintext, or SQLCipher-encrypted. - * - * A plaintext SQLite database starts with the 16-byte magic header - * "SQLite format 3\0" (ending in NUL); SQLCipher encrypts the header too, so an encrypted database - * will not match. Inspecting the header keeps this check independent of any SQLCipher - * runtime API. - */ -object SQLCipherUtils { - - enum class State { DOES_NOT_EXIST, UNENCRYPTED, ENCRYPTED } - - fun getDatabaseState(context: Context, dbName: String?): State = getDatabaseState(context.getDatabasePath(dbName)) - - private fun getDatabaseState(dbPath: File): State { - if (!dbPath.exists()) return State.DOES_NOT_EXIST - return try { - val header = ByteArray(SQLITE_MAGIC.size) - val read = FileInputStream(dbPath).use { it.read(header) } - if (read == SQLITE_MAGIC.size && header.contentEquals(SQLITE_MAGIC)) { - State.UNENCRYPTED - } else { - State.ENCRYPTED - } - } catch (_: Exception) { - State.ENCRYPTED - } - } - - private val SQLITE_MAGIC = "SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) -} diff --git a/app/src/main/java/com/pasich/encly/core/serialization/BlockConverter.kt b/app/src/main/java/com/pasich/encly/core/serialization/BlockConverter.kt deleted file mode 100644 index 9a38edae..00000000 --- a/app/src/main/java/com/pasich/encly/core/serialization/BlockConverter.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.pasich.encly.core.serialization - -import com.google.gson.GsonBuilder -import com.google.gson.JsonParseException -import com.google.gson.reflect.TypeToken -import com.pasich.encly.core.AppLogger -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.hasContent - -/** - * Converter for transforming blocks to JSON and back. - * Uses custom serializers and deserializers. - */ -object BlockConverter { - // Create a Gson instance with custom serialization adapters - private val gson by lazy { - GsonBuilder() - .registerTypeAdapter(Block::class.java, BlockSerializer()) - .registerTypeAdapter(Block::class.java, BlockDeserializer()) - .setPrettyPrinting() - .create() - } - - /** - * Converts a list of blocks into a JSON representation. Blocks without content - * ([hasContent]) are left out, except an empty paragraph between other blocks: it is a blank - * line the user added on purpose and must survive closing and reopening the note. Blank - * blocks at the end (the placeholder paragraph a fresh note starts with) are dropped. - * @param blocks the list of blocks to serialize - * @return the JSON string - */ - fun blocksToJson(blocks: List): String { - val type = object : TypeToken>() {}.type - val kept = blocks.dropLastWhile { !it.hasContent() }.filter { it.hasContent() || it is Block.TextBlock } - return gson.toJson(kept, type) - } - - /** - * Converts JSON into a list of blocks. - * @param json the JSON string to deserialize - * @return the list of blocks; empty for an empty string - * @throws JsonParseException for malformed JSON or a block this version does not know - * (for example one written by a newer version); use [jsonToBlocksOrNull] to get null instead - */ - fun jsonToBlocks(json: String): List { - if (json.isEmpty()) return emptyList() - val type = object : TypeToken>() {}.type - // `null` array entries deserialize to null elements; drop them. - return gson.fromJson>(json, type).orEmpty().filterIsInstance() - } - - /** - * Like [jsonToBlocks], but returns null when [json] cannot be read, so one unreadable note - * cannot crash a screen that lists or searches many notes. - */ - fun jsonToBlocksOrNull(json: String): List? = try { - jsonToBlocks(json) - } catch (e: JsonParseException) { - logUnreadable(e) - } catch (e: IllegalStateException) { - // Gson's JsonElement.getAsJsonObject/getAsString on the wrong element type - logUnreadable(e) - } catch (e: UnsupportedOperationException) { - logUnreadable(e) - } catch (e: IllegalArgumentException) { - // BlockType.valueOf, number parsing - logUnreadable(e) - } catch (e: ClassCastException) { - logUnreadable(e) - } - - private fun logUnreadable(e: Exception): List? { - AppLogger.w("BlockConverter", "Unreadable note content: ${e.javaClass.simpleName}") - return null - } -} diff --git a/app/src/main/java/com/pasich/encly/core/serialization/BlockSerialization.kt b/app/src/main/java/com/pasich/encly/core/serialization/BlockSerialization.kt deleted file mode 100644 index 378e5795..00000000 --- a/app/src/main/java/com/pasich/encly/core/serialization/BlockSerialization.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.pasich.encly.core.serialization - -import com.google.gson.JsonArray -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParseException -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -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 java.lang.reflect.Type - -class BlockDeserializer : JsonDeserializer { - override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Block { - val jsonObject = json?.asJsonObject ?: throw JsonParseException("Invalid JSON") - - val blockType = - jsonObject.get("blockType")?.asString ?: throw JsonParseException("Missing blockType") - - return when (blockType) { - "TEXT" -> Block.TextBlock( - text = MutableStateFlow(jsonObject.get("text")?.asString ?: ""), - ) - - "H1", "H2", "H3", "H4" -> Block.HBlock( - text = MutableStateFlow(jsonObject.get("text")?.asString ?: ""), - blockType = BlockType.valueOf(blockType), - ) - - "QUOTE" -> Block.QuoteBlock( - text = MutableStateFlow(jsonObject.get("text")?.asString ?: ""), - ) - - "LINK" -> Block.LinkBlock( - block = MutableStateFlow( - LinkDataBlock( - url = jsonObject.getAsJsonObject("block")?.get("url")?.asString ?: "", - title = jsonObject.getAsJsonObject("block")?.get("title")?.asString ?: "", - imageUrl = jsonObject.getAsJsonObject("block")?.get("imageUrl")?.asString - ?: "", - ), - ), - ) - - "SEPARATOR" -> Block.SeparatorBlock() - - // "LIST" kept as a legacy alias for checklists. - "LIST", "LIST_CHECK", "LIST_NUMBER", "LIST_BULLET" -> Block.ListBlock( - items = MutableStateFlow( - jsonObject.getAsJsonArray("items") - ?.map { item -> - val itemObject = item.asJsonObject - ItemListBlock( - value = itemObject.get("value")?.asString ?: "", - isCheck = itemObject.get("isCheck")?.asBoolean ?: false, - ) - } ?: listOf(ItemListBlock("")), - ), - blockType = when (blockType) { - "LIST_NUMBER" -> BlockType.LIST_NUMBER - "LIST_BULLET" -> BlockType.LIST_BULLET - else -> BlockType.LIST_CHECK - }, - // Optional, absent in notes written before checklists could be locked. - locked = MutableStateFlow(jsonObject.get("isLocked")?.asBoolean ?: false), - ) - - else -> throw JsonParseException("Unknown block type: $blockType") - } - } -} - -class BlockSerializer : JsonSerializer { - override fun serialize(src: Block?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { - if (src == null) throw IllegalArgumentException("Block cannot be null") - - val jsonObject = JsonObject() - - when (src) { - is Block.TextBlock -> { - jsonObject.addProperty("blockType", "TEXT") - jsonObject.addProperty("text", src.text.value) - } - - is Block.HBlock -> { - // Preserve the heading level (H1..H4) instead of collapsing to H1. - jsonObject.addProperty("blockType", src.blockType.name) - jsonObject.addProperty("text", src.text.value) - } - - is Block.QuoteBlock -> { - jsonObject.addProperty("blockType", "QUOTE") - jsonObject.addProperty("text", src.text.value) - } - - is Block.LinkBlock -> { - jsonObject.addProperty("blockType", "LINK") - val linkObject = JsonObject() - linkObject.addProperty("url", src.block.value.url) - linkObject.addProperty("title", src.block.value.title) - linkObject.addProperty("imageUrl", src.block.value.imageUrl) - jsonObject.add("block", linkObject) - } - - is Block.SeparatorBlock -> { - jsonObject.addProperty("blockType", "SEPARATOR") - } - - is Block.ListBlock -> { - // Drop empty list items - val nonEmptyItems = src.items.value.filter { it.value.isNotBlank() } - - // Preserve the list kind (numbered, bulleted or checklist). - jsonObject.addProperty("blockType", src.blockType.name) - // Written field by field, not through Gson reflection, so the stored key names - // do not depend on R8 keeping ItemListBlock's field names. - val itemsArray = JsonArray() - nonEmptyItems.forEach { item -> - itemsArray.add( - JsonObject().apply { - addProperty("value", item.value) - addProperty("isCheck", item.isCheck) - }, - ) - } - jsonObject.add("items", itemsArray) - // Written only when set, so a note without a locked list is stored as before. An older - // app reads the other keys and ignores this one: it opens the list unlocked and drops - // the lock the next time it saves the note. - if (src.locked.value) jsonObject.addProperty("isLocked", true) - } - } - - return jsonObject - } -} diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupDocuments.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupDocuments.kt deleted file mode 100644 index d34746c2..00000000 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupDocuments.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.pasich.encly.data.backup - -import android.content.Context -import android.net.Uri -import android.provider.DocumentsContract -import android.provider.OpenableColumns -import com.pasich.encly.core.backup.BackupError -import com.pasich.encly.core.backup.BackupException -import com.pasich.encly.core.backup.BackupFormat -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.Closeable -import java.io.IOException -import java.io.InputStream -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Reads and writes backup files at a Storage Access Framework [Uri] the user picked. Only the - * already encrypted container passes through here: no plaintext and no temp file is ever - * written, and no storage permission is needed. - */ -@Singleton -class BackupDocuments @Inject constructor(@param:ApplicationContext private val context: Context) { - fun read(uri: Uri): ByteArray = io { - context.contentResolver.openInputStream(uri).orFail().use { readCapped(it) } - } - - /** The document's display name, for showing which file was picked; null when unknown. */ - fun displayName(uri: Uri): String? = runCatching { - context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { - if (it.moveToFirst()) it.getString(0) else null - } - }.getOrNull() - - /** - * Writes [file] to a document the picker just created, then reads it back to check it. - * - * An existing, non-empty document is refused with [BackupError.TARGET_EXISTS] instead of - * being overwritten: truncating it first would destroy the previous backup whenever the new - * write then fails (storage full, provider error, revoked grant). On any failure the - * incomplete new document is deleted where the provider allows it. - */ - fun write(uri: Uri, file: ByteArray) = io { - val resolver = context.contentResolver - if (hasContent(uri)) throw BackupException(BackupError.TARGET_EXISTS) - var complete = false - try { - // "wt": the document is empty, but some providers need an explicit truncate mode. - resolver.openOutputStream(uri, "wt").orFail().use { it.write(file) } - val written = resolver.openInputStream(uri).orFail().use { readCapped(it) } - if (!written.contentEquals(file)) throw BackupException(BackupError.IO) - complete = true - } finally { - if (!complete) discard(uri) - } - } - - private fun hasContent(uri: Uri): Boolean = - context.contentResolver.openInputStream(uri)?.use { it.read() >= 0 } ?: false - - /** Best effort: a provider may not support deleting, and the write error is what matters. */ - private fun discard(uri: Uri) { - runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) } - } - - private fun T?.orFail(): T = this ?: throw BackupException(BackupError.IO) - - /** - * Maps every provider failure to [BackupError.IO]. Third-party DocumentsProviders also throw - * RuntimeExceptions across Binder (IllegalArgumentException "Unknown URI", unsupported mode, - * IllegalStateException...). Their messages are dropped: they can quote the document path. - */ - private inline fun io(block: () -> T): T = try { - block() - } catch (_: IOException) { - throw BackupException(BackupError.IO) - } catch (_: SecurityException) { - // The picked document is no longer accessible (e.g. its provider revoked the grant). - throw BackupException(BackupError.IO) - } catch (_: RuntimeException) { - throw BackupException(BackupError.IO) - } - - internal companion object { - private const val BUFFER_BYTES = 64 * 1024 - - /** - * Reads one backup file of at most [limit] bytes. The buffer is sized from the header's - * ciphertext length, so the file is held in memory exactly once. Anything that is not a - * backup, declares more than [limit] bytes, or runs past its declared end is rejected - * without reading the rest; a file shorter than declared is returned for - * [BackupFormat.parse] to report as truncated. - */ - fun readCapped(input: InputStream, limit: Int = BackupFormat.MAX_FILE_BYTES): ByteArray { - val head = ByteArray(BackupFormat.HEADER_LENGTH) - val headRead = fill(input, head, 0) - val file = if (headRead < head.size) { - head.copyOf(headRead) - } else { - val header = BackupFormat.parseHeader(head, limit.toLong()) - head.copyOf(BackupFormat.HEADER_LENGTH + header.ciphertextLength) - } - val filled = fill(input, file, headRead) - return when { - filled < file.size -> file.copyOf(filled) - input.read() >= 0 -> throw BackupException(BackupError.CORRUPTED) - else -> file - } - } - - /** Reads into [target] from [from] until it is full or the stream ends; returns the end. */ - private fun fill(input: InputStream, target: ByteArray, from: Int): Int { - var position = from - while (position < target.size) { - val read = input.read(target, position, minOf(BUFFER_BYTES, target.size - position)) - if (read < 0) break - position += read - } - return position - } - } -} 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 deleted file mode 100644 index d8c9c596..00000000 --- a/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt +++ /dev/null @@ -1,66 +0,0 @@ -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 -import com.pasich.encly.data.model.Tag -import com.pasich.encly.data.model.Task -import javax.inject.Inject - -/** [VaultDataStore] over a single SQLCipher/Room opening. */ -@Suppress("TooManyFunctions") // One function per vault table operation of the contract. -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 snapshotOfNote(noteId: Long): VaultSnapshot = database().let { db -> - db.withTransaction { - val note = db.notesDao().getNoteById(noteId) - val tag = note?.tagId?.let { db.backupDao().tagById(it) } - VaultSnapshot(listOfNotNull(note), listOfNotNull(tag), emptyList()) - } - } - - override suspend fun inTransaction(block: suspend () -> R): R = database().withTransaction { block() } - - override suspend fun deleteAll() { - 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() - } - - override suspend fun insertTag(tag: Tag): Long = database().backupDao().insertTag(tag) - - override suspend fun insertNote(note: Note): Long = database().backupDao().insertNote(note) - - override suspend fun insertTask(task: Task): Long = database().backupDao().insertTask(task) - - 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 deleted file mode 100644 index d56e9b53..00000000 --- a/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.pasich.encly.data.backup - -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.Tag -import com.pasich.encly.data.model.Task - -/** Every note (trash included), tag, task and sub-task in the vault, as stored. */ -data class VaultSnapshot( - val notes: List, - val tags: List, - val tasks: List, - val subtasks: List = emptyList(), -) - -/** - * The narrow storage contract encrypted export/import needs. The Room implementation is - * [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 - - /** Only note [noteId] and its tag: for exporting one note without reading the whole vault. */ - suspend fun snapshotOfNote(noteId: Long): VaultSnapshot = snapshot().let { all -> - val note = all.notes.firstOrNull { it.id == noteId } - VaultSnapshot(listOfNotNull(note), all.tags.filter { it.id == note?.tagId }, emptyList()) - } - - /** Runs [block] atomically: if it throws, none of its writes are kept. */ - suspend fun inTransaction(block: suspend () -> R): R - - suspend fun deleteAll() - - suspend fun insertTag(tag: Tag): Long - - suspend fun insertNote(note: Note): Long - - suspend fun insertTask(task: Task): Long - - suspend fun insertSubtask(subtask: Subtask): Long -} diff --git a/app/src/main/java/com/pasich/encly/data/database/AppDatabase.kt b/app/src/main/java/com/pasich/encly/data/database/AppDatabase.kt deleted file mode 100644 index 694d2266..00000000 --- a/app/src/main/java/com/pasich/encly/data/database/AppDatabase.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.pasich.encly.data.database - -import androidx.room.Database -import androidx.room.RoomDatabase -import com.pasich.encly.data.database.dao.BackupDao -import com.pasich.encly.data.database.dao.NotesDao -import com.pasich.encly.data.database.dao.TagsDao -import com.pasich.encly.data.database.dao.TasksDao -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.Tag -import com.pasich.encly.data.model.Task - -/** Room schema version. Every bump needs a migration and an exported schema in app/schemas. */ -const val DB_VERSION = 6 - -@Database( - entities = [Note::class, Tag::class, Task::class, Subtask::class], - version = DB_VERSION, - exportSchema = true, -) -abstract class AppDatabase : RoomDatabase() { - abstract fun notesDao(): NotesDao - abstract fun tagsDao(): TagsDao - abstract fun tasksDao(): TasksDao - abstract fun backupDao(): BackupDao -} 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 deleted file mode 100644 index 5a0c57e5..00000000 --- a/app/src/main/java/com/pasich/encly/data/database/DatabaseProvider.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.pasich.encly.data.database - -/** 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 (the other vault's, - * real or decoy) is now open. - */ - fun getDatabase(lease: DatabaseLease): AppDatabase -} diff --git a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt deleted file mode 100644 index fb735f05..00000000 --- a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.pasich.encly.data.database - -import android.content.Context -import com.pasich.encly.core.AppLogger -import com.pasich.encly.core.security.SensitiveDataCleaner -import com.pasich.encly.core.security.cipher.SQLCipherUtils -import dagger.hilt.android.qualifiers.ApplicationContext -import net.zetetic.database.Logger -import net.zetetic.database.NoopTarget -import net.zetetic.database.sqlcipher.SQLiteConnection -import net.zetetic.database.sqlcipher.SQLiteDatabaseHook -import net.zetetic.database.sqlcipher.SupportOpenHelperFactory -import java.io.File -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 (and the first opening of a decoy vault). - */ -@Singleton -class SecureDatabaseManager @Inject constructor(@param:ApplicationContext private val context: Context) : - DatabaseProvider { - private var database: AppDatabase? = null - private var isUnlocked = false - private var lease: DatabaseLease? = null - private var openFile: VaultFile? = null - - /** - * The passphrase buffer handed to Room's [SupportOpenHelperFactory]. SQLCipher keeps this - * exact array (no copy) in its open-helper configuration and re-keys every new pool - * connection from it (WAL readers, reopen after a pool reset), so it must stay intact for - * as long as [database] is open and is zeroed only after [reset] closes it. - */ - private var roomPassphrase: ByteArray? = null - - fun isDatabaseUnlocked(): Boolean = isUnlocked - - fun hasEncryptedDatabase(file: VaultFile = VaultFile.PRIMARY): Boolean = - SQLCipherUtils.getDatabaseState(context, file.fileName) == 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") - - /** - * Opens the vault with the raw 32-byte [dek]; the caller keeps (and wipes) [dek]. - * - * The key reaches SQLCipher in its raw-key form (`x'<64 hex>'`), so SQLCipher uses it as - * 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) 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, file: VaultFile): Boolean { - if (dek.size != DEK_LENGTH) return false - val passphrase = rawKeyPassphrase(dek) - if (isUnlocked) { - return try { - openFile == file && roomPassphrase?.let { MessageDigest.isEqual(it, passphrase) } == true - } finally { - SensitiveDataCleaner.clear(passphrase) - } - } - ensureNativeLoaded() - - var openedDatabase: AppDatabase? = null - return try { - 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. - SQLCipherUtils.State.UNENCRYPTED -> return false - - SQLCipherUtils.State.ENCRYPTED -> Unit - } - - // 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, file.fileName) - .openHelperFactory(SupportOpenHelperFactory(passphrase, VaultConnectionHook, false)) - .build() - openedDatabase = opened - - // One open, which is also the key check: a wrong key, a damaged file or a failed - // migration throws here, before the database is published as unlocked. - opened.openHelper.writableDatabase - - database = opened - roomPassphrase = passphrase - isUnlocked = true - openFile = file - lease = DatabaseLease() - true - } catch (e: Exception) { - openedDatabase?.close() - database = null - isUnlocked = false - openFile = null - lease = null - AppLogger.e(TAG, "Failed to unlock the database", e) - false - } finally { - // Only a failed open may wipe the buffer here; on success it is owned by the - // live factory until reset(). - if (roomPassphrase !== passphrase) SensitiveDataCleaner.clear(passphrase) - } - } - - private fun deleteDatabaseFiles(file: VaultFile) { - try { - 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) - } - } - - @Synchronized - fun reset() { - lease = null - database?.close() - database = null - isUnlocked = false - openFile = null - roomPassphrase?.let(SensitiveDataCleaner::clear) - roomPassphrase = null - } - - /** Closes and deletes both databases: the real vault's and the decoy vault's. */ - @Synchronized - fun wipe() { - reset() - 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(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 DEK_LENGTH = 32 - private val HEX = "0123456789abcdef".toCharArray() - - /** - * `x''` as ASCII bytes: SQLCipher's raw-key syntax. The caller wipes the result. - * Built byte by byte so no String copy of the key exists. - */ - internal fun rawKeyPassphrase(dek: ByteArray): ByteArray { - val out = ByteArray(dek.size * 2 + RAW_KEY_OVERHEAD) - out[0] = 'x'.code.toByte() - out[1] = '\''.code.toByte() - dek.forEachIndexed { index, byte -> - val value = byte.toInt() and BYTE_MASK - out[RAW_KEY_PREFIX + index * 2] = HEX[value ushr NIBBLE_BITS].code.toByte() - out[RAW_KEY_PREFIX + index * 2 + 1] = HEX[value and NIBBLE_MASK].code.toByte() - } - out[out.size - 1] = '\''.code.toByte() - return out - } - - private const val RAW_KEY_OVERHEAD = 3 - private const val RAW_KEY_PREFIX = 2 - private const val BYTE_MASK = 0xFF - private const val NIBBLE_BITS = 4 - private const val NIBBLE_MASK = 0x0F - - // Loaded on first use rather than in a static initializer, so the class can be - // constructed (and mocked in JVM tests) without the native library. - private val nativeLoaded by lazy { - System.loadLibrary("sqlcipher") - // SQLCipher's Java client logs to Logcat by default. Encly's vault layer - // keeps third-party database diagnostics out of system-visible logs too. - Logger.setTarget(NoopTarget()) - true - } - - private fun ensureNativeLoaded() { - check(nativeLoaded) - } - } -} - -/** - * Per-connection SQLCipher settings, both best effort (a build without them must still open - * the vault): - * - before keying, native logging off (`cipher_log_level = NONE`): SQLCipher's own log lines - * can name the database and describe key or page errors, and must not reach logcat. The Java - * logger is already a no-op (see [SecureDatabaseManager]). - * - after keying, memory security on (`cipher_memory_security`): freed memory is zeroed. - */ -@Suppress("TooGenericExceptionCaught") // Best effort: never fail the open over a pragma. -private object VaultConnectionHook : SQLiteDatabaseHook { - override fun preKey(connection: SQLiteConnection) { - pragma(connection, "PRAGMA cipher_log_level = NONE") - } - - override fun postKey(connection: SQLiteConnection) { - pragma(connection, "PRAGMA cipher_memory_security = ON") - } - - /** Some pragmas answer with a row, some with none; either is fine. */ - private fun pragma(connection: SQLiteConnection, sql: String) { - try { - connection.executeForString(sql, null, null) - } catch (_: Exception) { - try { - connection.execute(sql, null, null) - } catch (e: Exception) { - AppLogger.w("SecureDatabaseManager", "SQLCipher pragma not applied", e) - } - } - } -} diff --git a/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt b/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt deleted file mode 100644 index cd398ce7..00000000 --- a/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.pasich.encly.data.database - -import android.content.Context -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -/** - * Schema pieces Room cannot express in entity annotations. - * - * Every note, tag, task and sub-task carries a stable `uid` (encrypted backups merge by it). - * Kotlin code never assigns one, so two triggers keep the invariant "uid is never blank" in the - * database itself: a blank uid is replaced with 128 random bits on insert, and an update that - * would blank an existing uid (an entity built without it) restores the old value. - */ -object VaultSchema { - /** The tables that got a `uid` in version 2. Fixed: later tables are created with one. */ - private val V2_UID_TABLES = listOf("notes", "tags", "tasks") - - private const val SUBTASKS = "subtasks" - - /** Every table with a `uid` and its triggers, in the current schema. */ - private val TABLES = V2_UID_TABLES + SUBTASKS - - private const val RANDOM_UID = "lower(hex(randomblob(16)))" - - /** Database version that dropped `tasks.reminderDate`. */ - private const val VERSION_WITHOUT_TASK_REMINDERS = 3 - - /** Database version that added the `subtasks` table. */ - private const val VERSION_WITH_SUBTASKS = 4 - - /** Database version that added pin, archive and colour to notes. */ - private const val VERSION_WITH_NOTE_ORGANIZING = 5 - - /** Database version that added a note's own text size and line spacing. */ - private const val VERSION_WITH_NOTE_TEXT_SETTINGS = 6 - - private fun triggers(table: String) = listOf( - "CREATE TRIGGER IF NOT EXISTS `${table}_uid_on_insert` AFTER INSERT ON `$table` " + - "WHEN NEW.uid = '' BEGIN " + - "UPDATE `$table` SET uid = $RANDOM_UID WHERE id = NEW.id; END", - "CREATE TRIGGER IF NOT EXISTS `${table}_uid_keep` AFTER UPDATE OF uid ON `$table` " + - "WHEN NEW.uid = '' AND OLD.uid <> '' BEGIN " + - "UPDATE `$table` SET uid = OLD.uid WHERE id = NEW.id; END", - ) - - fun createTriggers(db: SupportSQLiteDatabase) { - TABLES.flatMap(::triggers).forEach(db::execSQL) - } - - /** 1 -> 2: add the stable `uid` to notes, tags and tasks and back-fill existing rows. */ - val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - V2_UID_TABLES.forEach { table -> - db.execSQL("ALTER TABLE `$table` ADD COLUMN `uid` TEXT NOT NULL DEFAULT ''") - db.execSQL("UPDATE `$table` SET uid = $RANDOM_UID WHERE uid = ''") - db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_${table}_uid` ON `$table` (`uid`)") - } - // Only these tables exist at version 2; later tables get their triggers in their - // own migration. - V2_UID_TABLES.flatMap(::triggers).forEach(db::execSQL) - } - } - - /** - * 2 -> 3: drop `tasks.reminderDate` (task reminders were removed from the app). - * - * SQLite only has `DROP COLUMN` from 3.35 and Room's schema test runs on the platform - * SQLite, so the table is rebuilt instead. Dropping the old table drops its uid triggers - * and index; both are recreated on the new one. - */ - val MIGRATION_2_3 = object : Migration(2, VERSION_WITHOUT_TASK_REMINDERS) { - override fun migrate(db: SupportSQLiteDatabase) { - val columns = "`id`, `title`, `description`, `isCompleted`, `createdDate`, " + - "`completedDate`, `priority`, `categoryId`, `position`, `uid`" - db.execSQL( - "CREATE TABLE IF NOT EXISTS `tasks_new` (" + - "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, " + - "`description` TEXT, `isCompleted` INTEGER NOT NULL, `createdDate` INTEGER NOT NULL, " + - "`completedDate` INTEGER, `priority` INTEGER NOT NULL, `categoryId` INTEGER, " + - "`position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", - ) - db.execSQL("INSERT INTO `tasks_new` ($columns) SELECT $columns FROM `tasks`") - db.execSQL("DROP TABLE `tasks`") - db.execSQL("ALTER TABLE `tasks_new` RENAME TO `tasks`") - db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_uid` ON `tasks` (`uid`)") - triggers("tasks").forEach(db::execSQL) - } - } - - /** - * 3 -> 4: add the `subtasks` table (a task's checklist), with its index on `taskId`, the - * unique `uid` index and the uid triggers. Existing tasks simply have no sub-tasks. The - * statements match Room's export of version 4 (app/schemas), which the schema test checks. - */ - val MIGRATION_3_4 = object : Migration(VERSION_WITHOUT_TASK_REMINDERS, VERSION_WITH_SUBTASKS) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "CREATE TABLE IF NOT EXISTS `$SUBTASKS` (" + - "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `taskId` INTEGER NOT NULL, " + - "`title` TEXT NOT NULL, `isCompleted` INTEGER NOT NULL, `position` INTEGER NOT NULL, " + - "`uid` TEXT NOT NULL DEFAULT '', " + - "FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - ) - db.execSQL("CREATE INDEX IF NOT EXISTS `index_subtasks_taskId` ON `$SUBTASKS` (`taskId`)") - db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_subtasks_uid` ON `$SUBTASKS` (`uid`)") - triggers(SUBTASKS).forEach(db::execSQL) - } - } - - /** - * 4 -> 5: add `isPinned`, `isArchived` and `color` to notes. Plain `ADD COLUMN` with a - * default, so every existing note stays as it was: unpinned, not archived, no colour. The - * statements match Room's export of version 5 (app/schemas), which the schema test checks. - */ - val MIGRATION_4_5 = object : Migration(VERSION_WITH_SUBTASKS, VERSION_WITH_NOTE_ORGANIZING) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `notes` ADD COLUMN `isPinned` INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE `notes` ADD COLUMN `isArchived` INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE `notes` ADD COLUMN `color` TEXT NOT NULL DEFAULT 'none'") - } - } - - /** - * 5 -> 6: add a note's own `textSize` and `lineSpacing`. Both are nullable (null: the note - * follows the defaults of Settings), so every existing note stays as it was. Version 5 was - * already out on dev installs without them, so they are a step of their own: editing 5 in - * place would fail Room's identity check there. - */ - val MIGRATION_5_6 = object : Migration(VERSION_WITH_NOTE_ORGANIZING, VERSION_WITH_NOTE_TEXT_SETTINGS) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `notes` ADD COLUMN `textSize` INTEGER") - db.execSQL("ALTER TABLE `notes` ADD COLUMN `lineSpacing` INTEGER") - } - } - - /** - * The builder for the vault database: every migration and the trigger callback, and no - * destructive fallback. Room's default (`requireMigration`) makes a missing migration - * fail loudly instead of recreating the tables, which would erase every note. - */ - fun databaseBuilder(context: Context, name: String): RoomDatabase.Builder = - install(Room.databaseBuilder(context, AppDatabase::class.java, name)) - - /** - * Adds every migration and the trigger callback to a vault database builder. List each - * new migration here explicitly. - */ - fun install(builder: RoomDatabase.Builder): RoomDatabase.Builder = - builder.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) - .addCallback(CALLBACK) - - /** Installs the triggers on a freshly created database. */ - val CALLBACK = object : RoomDatabase.Callback() { - override fun onCreate(db: SupportSQLiteDatabase) { - createTriggers(db) - } - } -} diff --git a/app/src/main/java/com/pasich/encly/data/database/dao/BackupDao.kt b/app/src/main/java/com/pasich/encly/data/database/dao/BackupDao.kt deleted file mode 100644 index f6d12016..00000000 --- a/app/src/main/java/com/pasich/encly/data/database/dao/BackupDao.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.pasich.encly.data.database.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.Tag -import com.pasich.encly.data.model.Task - -/** - * Whole-vault reads and writes for encrypted export/import. Inserts ABORT on a uid clash so a - * bad import fails (and its transaction rolls back) instead of silently replacing a row. - */ -@Suppress("TooManyFunctions") // One read, insert and delete per vault table. -@Dao -interface BackupDao { - @Query("SELECT * FROM notes ORDER BY id") - suspend fun allNotes(): List - - @Query("SELECT * FROM tags ORDER BY id") - suspend fun allTags(): List - - @Query("SELECT * FROM tags WHERE id = :tagId") - suspend fun tagById(tagId: Long): Tag? - - @Query("SELECT * FROM tasks ORDER BY id") - suspend fun allTasks(): List - - @Query("SELECT * FROM subtasks ORDER BY taskId, position, id") - suspend fun allSubtasks(): List - - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insertNote(note: Note): Long - - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insertTag(tag: Tag): Long - - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insertTask(task: Task): Long - - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insertSubtask(subtask: Subtask): Long - - @Query("DELETE FROM notes") - suspend fun deleteAllNotes() - - @Query("DELETE FROM tags") - suspend fun deleteAllTags() - - @Query("DELETE FROM tasks") - suspend fun deleteAllTasks() - - @Query("DELETE FROM subtasks") - suspend fun deleteAllSubtasks() -} diff --git a/app/src/main/java/com/pasich/encly/data/database/dao/NotesDao.kt b/app/src/main/java/com/pasich/encly/data/database/dao/NotesDao.kt deleted file mode 100644 index 4b84fd56..00000000 --- a/app/src/main/java/com/pasich/encly/data/database/dao/NotesDao.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.pasich.encly.data.database.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag -import kotlinx.coroutines.flow.Flow - -@Suppress("TooManyFunctions") // Reads per list, and one write per note column. -@Dao -interface NotesDao { - - /** The notes of the main list: neither in the trash nor in the archive. */ - @Transaction - @Query("SELECT * FROM notes WHERE isTrash = 0 AND isArchived = 0") - fun getAllNotesWithTags(): Flow> - - /** The archive: archived notes that are not in the trash (a trashed archived note is in the trash). */ - @Transaction - @Query("SELECT * FROM notes WHERE isTrash = 0 AND isArchived = 1") - fun getArchivedNotesWithTags(): Flow> - - @Transaction - @Query( - """ - SELECT * FROM notes - WHERE (:tagId IS NULL AND tagId IS NULL OR tagId = :tagId) - AND isTrash = 0 AND isArchived = 0 - """, - ) - fun getNotesByTagId(tagId: Long?): Flow> - - /** The archived notes (not in the trash) of one tag: the way to a hidden tag's archived notes. */ - @Transaction - @Query("SELECT * FROM notes WHERE isTrash = 0 AND isArchived = 1 AND tagId = :tagId") - fun getArchivedNotesByTagId(tagId: Long): Flow> - - @Transaction - @Query("SELECT * FROM notes WHERE id = :noteId") - fun observeNoteWithTag(noteId: Long): Flow - - @Query("SELECT * FROM notes WHERE isTrash = 0 AND isArchived = 0") - fun getAllNotes(): Flow> - - @Query("SELECT * FROM notes WHERE isTrash = 1") - fun getTrashNotes(): Flow> - - @Query("SELECT * FROM notes WHERE id = :noteId") - suspend fun getNoteById(noteId: Long): Note? - - // Single-column writes: an action on one property must never write back the rest of a row - // that a screen read earlier (a stale tag, colour or pin). - @Query("UPDATE notes SET color = :color WHERE id = :noteId") - suspend fun updateColor(noteId: Long, color: String): Int - - @Query("UPDATE notes SET tagId = :tagId WHERE id = :noteId") - suspend fun updateTagId(noteId: Long, tagId: Long?): Int - - @Query("UPDATE notes SET description = :description WHERE id = :noteId") - suspend fun updateDescription(noteId: Long, description: String): Int - - /** An archived note is never pinned. */ - @Query("UPDATE notes SET isPinned = :pinned WHERE id = :noteId AND (:pinned = 0 OR isArchived = 0)") - suspend fun updatePinned(noteId: Long, pinned: Boolean): Int - - /** Archiving unpins in the same statement. */ - @Query( - "UPDATE notes SET isArchived = :archived, " + - "isPinned = CASE WHEN :archived = 1 THEN 0 ELSE isPinned END WHERE id = :noteId", - ) - suspend fun updateArchived(noteId: Long, archived: Boolean): Int - - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insertNote(note: Note): Long - - @Update - suspend fun updateNote(note: Note): Int - - @Query("DELETE FROM notes WHERE id = :noteId") - suspend fun deleteNoteById(noteId: Long): Int -} diff --git a/app/src/main/java/com/pasich/encly/data/database/dao/TagsDao.kt b/app/src/main/java/com/pasich/encly/data/database/dao/TagsDao.kt deleted file mode 100644 index 45c332c6..00000000 --- a/app/src/main/java/com/pasich/encly/data/database/dao/TagsDao.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.pasich.encly.data.database.dao - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import com.pasich.encly.data.model.Tag -import kotlinx.coroutines.flow.Flow - -@Dao -interface TagsDao { - @Query("SELECT * FROM tags") - fun getTags(): Flow> - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun addTags(tags: List): List - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun addTag(tag: Tag): Long - - @Update - suspend fun updateTag(tag: Tag): Int - - @Update - suspend fun updateTags(tags: List): Int - - @Delete - suspend fun deleteTag(tag: Tag): Int - - @Query("UPDATE notes SET tagId = NULL WHERE tagId = :tagId") - suspend fun detachNotesFromTag(tagId: Long): Int - - /** - * Deletes [tag] and, in the same transaction, makes its notes untagged, so no note keeps - * pointing at a tag that no longer exists (and hidden-tag notes do not linger hidden). - */ - @Transaction - suspend fun deleteTagDetachingNotes(tag: Tag): Int { - detachNotesFromTag(tag.id) - return deleteTag(tag) - } -} diff --git a/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt b/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt deleted file mode 100644 index 8dbcc0f4..00000000 --- a/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.pasich.encly.data.database.dao - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.Task -import kotlinx.coroutines.flow.Flow - -@Dao -interface TasksDao { - - @Query("SELECT * FROM tasks WHERE isCompleted = 0 ORDER BY priority DESC, createdDate DESC") - fun getAllActiveTasks(): Flow> - - @Query("SELECT * FROM tasks WHERE isCompleted = 1 ORDER BY completedDate DESC") - fun getAllCompletedTasks(): Flow> - - @Query("SELECT * FROM tasks ORDER BY isCompleted ASC, priority DESC, createdDate DESC") - fun getAllTasks(): Flow> - - @Query("SELECT COUNT(*) FROM tasks WHERE isCompleted = 0") - fun getActiveTasksCount(): Flow - - @Query("SELECT COUNT(*) FROM tasks WHERE isCompleted = 1") - fun getCompletedTasksCount(): Flow - - @Query("SELECT * FROM tasks WHERE id = :id") - suspend fun getTaskById(id: Long): Task? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertTask(task: Task): Long - - @Update - suspend fun updateTask(task: Task): Int - - @Query("UPDATE tasks SET isCompleted = :isCompleted, completedDate = :completedDate WHERE id = :id") - suspend fun updateTaskStatus(id: Long, isCompleted: Boolean, completedDate: Long?): Int - - @Delete - suspend fun deleteTask(task: Task): Int - - @Query("DELETE FROM tasks WHERE isCompleted = 1") - suspend fun deleteAllCompletedTasks(): Int - - @Query("DELETE FROM tasks WHERE id = :id") - suspend fun deleteTaskById(id: Long): Int - - @Query("SELECT * FROM subtasks WHERE taskId = :taskId ORDER BY position, id") - suspend fun getSubtasks(taskId: Long): List - - /** Every sub-task, grouped by task and in each task's order, for the Tasks list. */ - @Query("SELECT * FROM subtasks ORDER BY taskId, position, id") - fun observeSubtasks(): Flow> - - @Query("UPDATE subtasks SET isCompleted = :done WHERE id = :id") - suspend fun setSubtaskCompleted(id: Long, done: Boolean): Int - - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insertSubtask(subtask: Subtask): Long - - /** The position after the task's last sub-task; 0 when it has none. */ - @Query("SELECT COALESCE(MAX(position) + 1, 0) FROM subtasks WHERE taskId = :taskId") - suspend fun nextSubtaskPosition(taskId: Long): Int - - /** Adds a sub-task after the task's last one; its row id. */ - @Transaction - suspend fun appendSubtask(taskId: Long, title: String): Long = - insertSubtask(Subtask(taskId = taskId, title = title, position = nextSubtaskPosition(taskId))) - - @Query("UPDATE subtasks SET title = :title WHERE id = :id") - suspend fun renameSubtask(id: Long, title: String): Int - - @Query("DELETE FROM subtasks WHERE id = :id") - suspend fun deleteSubtaskById(id: Long): Int - - @Query("UPDATE subtasks SET position = position + 1 WHERE taskId = :taskId AND position >= :position") - suspend fun shiftSubtasksFrom(taskId: Long, position: Int): Int - - /** - * Puts a deleted sub-task back (undo) at its old position, with its uid: the rows from that - * position on move down one. It gets a new row id, as its old one may have been reused. - */ - @Transaction - suspend fun restoreSubtask(subtask: Subtask) { - shiftSubtasksFrom(subtask.taskId, subtask.position) - insertSubtask(subtask.copy(id = 0)) - } - - @Update - suspend fun updateSubtask(subtask: Subtask): Int - - @Query("DELETE FROM subtasks WHERE taskId = :taskId AND id NOT IN (:keepIds)") - suspend fun deleteSubtasksExcept(taskId: Long, keepIds: List): Int - - /** - * Makes [subtasks] the task's whole checklist, in this order: rows that are no longer in - * the list are deleted, the others are updated in place (keeping their id and uid), and - * new ones (id 0) are inserted. - */ - @Transaction - suspend fun replaceSubtasks(taskId: Long, subtasks: List) { - deleteSubtasksExcept(taskId, subtasks.map { it.id }.filter { it != 0L }) - subtasks.forEachIndexed { index, subtask -> - val row = subtask.copy(taskId = taskId, position = index) - // A row id that is gone (deleted meanwhile) is inserted again rather than lost. - if (row.id == 0L || updateSubtask(row) == 0) insertSubtask(row) - } - } - - /** Puts a deleted task back together with its sub-tasks (undo). */ - @Transaction - suspend fun restoreTask(task: Task, subtasks: List) { - insertTask(task) - subtasks.forEach { insertSubtask(it.copy(taskId = task.id)) } - } -} diff --git a/app/src/main/java/com/pasich/encly/data/di/LocalDataModule.kt b/app/src/main/java/com/pasich/encly/data/di/LocalDataModule.kt index 03e81db5..5e4247bb 100644 --- a/app/src/main/java/com/pasich/encly/data/di/LocalDataModule.kt +++ b/app/src/main/java/com/pasich/encly/data/di/LocalDataModule.kt @@ -4,20 +4,20 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn private val Context.dataStore by preferencesDataStore("my_notes_settings") -@Module -@InstallIn(SingletonComponent::class) +@ContributesTo(AppScope::class) +@BindingContainer object LocalDataModule { @Provides - @Singleton + @SingleIn(AppScope::class) fun provideDataStore(@ApplicationContext context: Context): DataStore = context.dataStore } diff --git a/app/src/main/java/com/pasich/encly/data/di/RepositoryModule.kt b/app/src/main/java/com/pasich/encly/data/di/RepositoryModule.kt deleted file mode 100644 index 35ba06da..00000000 --- a/app/src/main/java/com/pasich/encly/data/di/RepositoryModule.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.pasich.encly.data.di - -import com.pasich.encly.data.repository.NotesRepositoryImpl -import com.pasich.encly.data.repository.SettingsRepositoryImpl -import com.pasich.encly.data.repository.TagsRepositoryImpl -import com.pasich.encly.data.repository.TasksRepositoryImpl -import com.pasich.encly.domain.repository.NotesRepository -import com.pasich.encly.domain.repository.SettingsRepository -import com.pasich.encly.domain.repository.TagsRepository -import com.pasich.encly.domain.repository.TasksRepository -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -abstract class RepositoryModule { - @Binds - abstract fun bindNotesRepository(impl: NotesRepositoryImpl): NotesRepository - - @Binds - abstract fun bindTagsRepository(impl: TagsRepositoryImpl): TagsRepository - - @Binds - abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository - - @Binds - abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository -} diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt b/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt deleted file mode 100644 index a8fc8563..00000000 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.pasich.encly.data.export - -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 com.pasich.encly.dynamicBlocks.hasContent -import java.util.Locale - -/** - * A note's blocks as Markdown (CommonMark with GFM task lists), for the plaintext export. - * - * - Blocks are separated by a blank line, so a separator `---` can never turn the line above it - * into a heading. - * - Text is escaped so that what the user typed reads as typed: inline marks (`*`, `` ` ``, `[`, - * `<`, `\`, `~`, a word-edge `_`, an entity such as `&`) and whatever would start a block at - * the start of a line (`#`, `>`, `-`, `+`, `1.`, `===`, an indent of four spaces). - * - A line break inside a block is kept as a hard break (a trailing `\`). - * - A link is `[title](url)`. Only `http`, `https`, `mailto` and `tel` addresses become links; any - * other scheme (`javascript:`, `data:`) is written as inline code, so the file never carries a - * link that runs something when opened in a viewer. A link's preview image is never written: - * `![](...)` would make a viewer fetch it from the network. - */ -object MarkdownBlocks { - private const val NO_BREAK_SPACE = '\u00A0' - private val LINK_SCHEMES = setOf("http", "https", "mailto", "tel") - private val ENCODED_IN_LINK = mapOf( - ' ' to "%20", - '(' to "%28", - ')' to "%29", - '<' to "%3C", - '>' to "%3E", - '\\' to "%5C", - ) - - /** The Markdown of [blocks] (those without content are left out); empty when nothing is left. */ - fun render(blocks: List): String = - blocks.filter { it.hasContent() }.map(::renderBlock).filter { it.isNotEmpty() }.joinToString("\n\n") - - private fun renderBlock(block: Block): String = when (block) { - is Block.TextBlock -> paragraphs(block.text.value) - is Block.HBlock -> heading(block.blockType, block.text.value) - is Block.QuoteBlock -> quote(block.text.value) - is Block.LinkBlock -> link(block.block.value) - is Block.SeparatorBlock -> "---" - is Block.ListBlock -> list(block.blockType, block.items.value) - } - - private fun heading(type: BlockType, text: String): String { - // H1 to H4 are consecutive. - val level = type.ordinal - BlockType.H1.ordinal + 1 - // A heading is one line; a trailing "#" would be read as the closing sequence. - val escaped = MarkdownText.escapeInline(MarkdownText.oneLine(text)).trimEnd() - val line = if (escaped.endsWith("#")) escaped.dropLast(1) + "\\#" else escaped - return "${"#".repeat(level)} $line".trimEnd() - } - - private fun quote(text: String): String = MarkdownText.hardBreakLines(text).joinToString("\n") { line -> - if (line.isEmpty()) ">" else "> $line" - } - - private fun link(data: LinkDataBlock): String { - val url = data.url.trim() - val label = MarkdownText.oneLine(data.title).ifBlank { url } - val target = safeTarget(url) - if (target == null) return "`${MarkdownText.oneLine(url).replace('`', '\'')}`" - return "[${MarkdownText.escapeInline(label)}]($target)" - } - - /** [url] ready for `( … )`, or null for an address that must not become a link. */ - private fun safeTarget(url: String): String? { - val scheme = url.substringBefore(':', "").lowercase(Locale.ROOT) - val clean = url.filterNot { it.isISOControl() || it == NO_BREAK_SPACE } - return if (clean.isEmpty() || ':' !in clean || scheme !in LINK_SCHEMES) { - null - } else { - // The characters that would end or break the destination are percent-encoded. - clean.map { ENCODED_IN_LINK[it] ?: it.toString() }.joinToString("") - } - } - - private fun list(type: BlockType, items: List): String { - val shown = items.filter { it.value.isNotBlank() } - return shown.withIndex().joinToString("\n") { (index, item) -> - val marker = when (type) { - BlockType.LIST_NUMBER -> "${index + 1}. " - BlockType.LIST_CHECK -> if (item.isCheck) "- [x] " else "- [ ] " - else -> "- " - } - val lines = MarkdownText.hardBreakLines(item.value) - val indent = " ".repeat(marker.length) - (listOf(marker + lines.first()) + lines.drop(1).map { if (it.isEmpty()) "" else indent + it }) - .joinToString("\n") - } - } - - private fun paragraphs(text: String): String = MarkdownText.hardBreakLines(text).joinToString("\n") -} diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownDocuments.kt b/app/src/main/java/com/pasich/encly/data/export/MarkdownDocuments.kt deleted file mode 100644 index d8d4cf9c..00000000 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownDocuments.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.pasich.encly.data.export - -import android.content.Context -import android.net.Uri -import android.provider.DocumentsContract -import com.pasich.encly.core.backup.BackupError -import com.pasich.encly.core.backup.BackupException -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.IOException -import java.io.OutputStream -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Writes the plaintext (Markdown) export to a Storage Access Framework document the user just - * created in the system save dialog. The content is streamed straight into that document: no - * temp file, no cache or files directory, no share intent, and no storage permission. - */ -@Singleton -class MarkdownDocuments @Inject constructor(@param:ApplicationContext private val context: Context) { - - /** - * Streams what [block] writes into the document at [uri]. A document that already holds data - * is refused with [BackupError.TARGET_EXISTS] instead of being overwritten. On any failure the - * incomplete document is deleted where the provider allows it. Provider failures become - * [BackupError.IO] without their message, which can quote the document's path. - */ - fun write(uri: Uri, block: (OutputStream) -> Unit) = io { - if (hasContent(uri)) throw BackupException(BackupError.TARGET_EXISTS) - var complete = false - try { - // "wt": the document is empty, but some providers need an explicit truncate mode. - val stream = context.contentResolver.openOutputStream(uri, "wt") ?: throw BackupException(BackupError.IO) - stream.use(block) - complete = true - } finally { - if (!complete) discard(uri) - } - } - - /** - * Best effort: removes the empty document the picker created when nothing could be written to - * it. A document that holds data is never deleted: a provider may have handed back an existing - * file of the user's, and an unreadable one is left alone too. - */ - fun discard(uri: Uri) { - runCatching { if (!hasContent(uri)) DocumentsContract.deleteDocument(context.contentResolver, uri) } - } - - private fun hasContent(uri: Uri): Boolean = - context.contentResolver.openInputStream(uri)?.use { it.read() >= 0 } ?: false - - private inline fun io(block: () -> T): T = try { - block() - } catch (e: BackupException) { - throw e - } catch (_: IOException) { - throw BackupException(BackupError.IO) - } catch (_: SecurityException) { - // The picked document is no longer accessible (its provider revoked the grant). - throw BackupException(BackupError.IO) - } catch (_: RuntimeException) { - // Third-party providers throw RuntimeExceptions across Binder. - throw BackupException(BackupError.IO) - } -} diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownExport.kt b/app/src/main/java/com/pasich/encly/data/export/MarkdownExport.kt deleted file mode 100644 index fd08de0c..00000000 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownExport.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.pasich.encly.data.export - -import com.pasich.encly.data.backup.VaultSnapshot -import java.io.OutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream - -/** - * The Markdown export of what a vault holds, written straight to a stream (a document the user - * picked): nothing is buffered into a file, and nothing is added to the vault's data. Notes that - * are in the trash are left out; archived notes go to `archive/`. - * - * It exports the vault that is open, whichever it is: the decoy vault exports what it contains. - * Notes under a hidden tag are included, as they are in the vault. - */ -object MarkdownExport { - const val TASKS_FILE = "tasks.md" - const val ARCHIVE_FOLDER = "archive/" - const val MIME_NOTE = "text/markdown" - const val MIME_ZIP = "application/zip" - - /** Earliest time a zip entry can carry (the DOS epoch, 1980-01-01). */ - private const val ZIP_EPOCH_MILLIS = 315_532_800_000L - - /** Every note that is not in the trash and the tasks, as a zip of `.md` files. Closing [out] is the caller's. */ - fun writeAll(snapshot: VaultSnapshot, labels: MarkdownLabels, out: OutputStream) { - val zip = ZipOutputStream(out) - val tagNames = snapshot.tags.associate { it.id to it.nameTag } - val names = MarkdownFileNames(reserved = listOf(TASKS_FILE)) - zip.put(TASKS_FILE, null, MarkdownTasks.render(snapshot.tasks, snapshot.subtasks, snapshot.tags, labels)) - snapshot.notes.filterNot { it.isTrash }.forEach { note -> - val folder = if (note.isArchived) ARCHIVE_FOLDER else "" - val name = names.next(note.title, labels.untitled, folder) - zip.put(name, note.date, MarkdownNotes.render(note, tagNames[note.tagId], labels)) - } - zip.finish() - } - - /** The note [noteId] as one `.md` document; false when the vault has no such note (or it is in the trash). */ - fun writeNote(snapshot: VaultSnapshot, noteId: Long, labels: MarkdownLabels, out: OutputStream): Boolean { - val note = snapshot.notes.firstOrNull { it.id == noteId && !it.isTrash } ?: return false - val tagName = snapshot.tags.firstOrNull { it.id == note.tagId }?.nameTag - out.write(MarkdownNotes.render(note, tagName, labels).toByteArray(Charsets.UTF_8)) - return true - } - - private fun ZipOutputStream.put(name: String, time: Long?, text: String) { - val entry = ZipEntry(name) - if (time != null && time >= ZIP_EPOCH_MILLIS) entry.time = time - putNextEntry(entry) - write(text.toByteArray(Charsets.UTF_8)) - closeEntry() - } -} diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownFileNames.kt b/app/src/main/java/com/pasich/encly/data/export/MarkdownFileNames.kt deleted file mode 100644 index 2a494def..00000000 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownFileNames.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.pasich.encly.data.export - -import java.text.Normalizer -import java.util.Locale - -/** - * File names for the exported notes: safe on every file system and in a zip, and unique. - * - * A name is built from the note's title, so it is treated as untrusted text: it is normalized to - * NFC, loses control, format and separator characters (a right-to-left override can fake an - * extension), the characters Windows and the zip format reject (`/ \ : * ? " < > |`), leading - * dots (a hidden file, `..`) and trailing dots and spaces, avoids the device names Windows - * reserves, and is cut to a byte budget at a character boundary. Two names that differ only in - * case count as the same one, because macOS and Windows would overwrite one with the other. - */ -class MarkdownFileNames(reserved: Collection = emptyList(), private val maxBytes: Int = MAX_NAME_BYTES) { - private val taken = HashSet() - - init { - reserved.forEach { taken += key(it) } - } - - /** - * A unique `.md` for [title] inside [folder] (`""` or e.g. `"archive/"`); [fallback] if - * the title has no usable text. - */ - fun next(title: String, fallback: String, folder: String = ""): String { - val base = sanitize(title).ifEmpty { sanitize(fallback).ifEmpty { "note" } } - var candidate = fit(base, "") - var n = 1 - while (!taken.add(key(folder + candidate + EXTENSION))) { - n++ - candidate = fit(base, " ($n)") - } - return folder + candidate + EXTENSION - } - - /** [base] cut so that it, [suffix] and the extension fit the byte budget. */ - private fun fit(base: String, suffix: String): String { - val room = maxBytes - EXTENSION.length - suffix.toByteArray().size - return cutBytes(base, room.coerceAtLeast(1)).trimEnd('.', ' ') + suffix - } - - private fun key(path: String) = Normalizer.normalize(path, Normalizer.Form.NFC).lowercase(Locale.ROOT) - - companion object { - const val EXTENSION = ".md" - const val MAX_NAME_BYTES = 100 - private const val TAB = 0x09 - private const val LINE_FEED = 0x0A - private const val CARRIAGE_RETURN = 0x0D - private val FORBIDDEN = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|') - private val RESERVED = Regex("""^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$""", RegexOption.IGNORE_CASE) - - /** [title] reduced to characters that are safe in a file name; may be empty. */ - fun sanitize(title: String): String { - val text = Normalizer.normalize(title, Normalizer.Form.NFC) - val kept = StringBuilder(text.length) - var index = 0 - while (index < text.length) { - val cp = text.codePointAt(index) - index += Character.charCount(cp) - when { - Character.isISOControl(cp) && cp != TAB && cp != LINE_FEED && cp != CARRIAGE_RETURN -> Unit - Character.isSpaceChar(cp) || Character.isWhitespace(cp) -> kept.append(' ') - cp.toChar() in FORBIDDEN && cp < Character.MIN_SUPPLEMENTARY_CODE_POINT -> Unit - isPlain(cp) -> kept.appendCodePoint(cp) - } - } - val spaced = kept.toString().replace(Regex(" {2,}"), " ").trim().trimStart('.', ' ').trimEnd('.', ' ') - return if (RESERVED.matches(spaced.substringBefore('.').trim())) "_$spaced" else spaced - } - - /** Not a control, format, separator, private-use, unassigned or lone surrogate character. */ - private fun isPlain(cp: Int): Boolean = when (Character.getType(cp).toByte()) { - Character.CONTROL, Character.FORMAT, Character.LINE_SEPARATOR, Character.PARAGRAPH_SEPARATOR, - Character.PRIVATE_USE, Character.UNASSIGNED, Character.SURROGATE, - -> false - - else -> true - } - - /** The longest prefix of [text] that is at most [bytes] bytes of UTF-8, ending on a character. */ - fun cutBytes(text: String, bytes: Int): String { - var used = 0 - var end = 0 - while (end < text.length) { - val cp = text.codePointAt(end) - val size = String(Character.toChars(cp)).toByteArray().size - if (used + size > bytes) break - used += size - end += Character.charCount(cp) - } - return text.substring(0, end) - } - } -} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/CacheHandoffStagingSweeper.kt b/app/src/main/java/com/pasich/encly/data/handoff/CacheHandoffStagingSweeper.kt index 13cf50a0..33ad1614 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/CacheHandoffStagingSweeper.kt +++ b/app/src/main/java/com/pasich/encly/data/handoff/CacheHandoffStagingSweeper.kt @@ -1,14 +1,16 @@ package com.pasich.encly.data.handoff import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton +import com.pasich.encly.core.di.ApplicationContext +import com.pasich.encly.core.security.HandoffStagingSweeper +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.io.files.Path /** [HandoffStagingSweeper] for the staging directory in the app's cache. */ -@Singleton -class CacheHandoffStagingSweeper @Inject constructor(@param:ApplicationContext private val context: Context) : - HandoffStagingSweeper { - override fun sweep() = HandoffStaging.sweep(File(context.cacheDir, HandoffStaging.DIR_NAME)) +@SingleIn(AppScope::class) +@Inject +class CacheHandoffStagingSweeper(@param:ApplicationContext private val context: Context) : HandoffStagingSweeper { + override fun sweep() = HandoffStaging.sweep(Path(context.cacheDir.path, HandoffStaging.DIR_NAME)) } diff --git a/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt deleted file mode 100644 index 96aef425..00000000 --- a/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.pasich.encly.data.handoff - -import java.io.File -import java.io.IOException -import java.io.InputStream - -/** - * The one place a hand-off touches the disk: My Notes' ZIP is copied into a file of its own in - * [dir] (the app-private cache) because the URI grant ends with the activity and the reader needs - * random access, then parsed and mapped, then deleted in a `finally`. The plaintext lives on disk - * only for the parse. What a killed process left behind is deleted by [sweep], at the next start - * of the app and when a wipe-PIN erase finishes; a file a load in this process is still reading - * is never touched, so two hand-offs at once do not delete each other's. - * - * Deleting is all that can be done: on flash storage overwriting a file does not reliably - * erase the old blocks, so it is not attempted. - */ -class HandoffStaging(private val dir: File, private val limits: HandoffLimits = HandoffLimits()) { - - /** - * Copies what [open] returns, reads and maps it. Throws [HandoffException]; an [IOException] - * or [SecurityException] from [open] or the copy means the URI could not be read. - */ - fun load(open: () -> InputStream?): HandoffImport { - val zip = createStagingFile(dir) - try { - val input = open() ?: throw HandoffException(HandoffError.INVALID_PAYLOAD) - input.use { source -> zip.outputStream().use { MyNotesHandoffReader.copyLimited(source, it, limits) } } - return MyNotesHandoffMapper.map(MyNotesHandoffReader.read(zip, limits)) - } finally { - release(dir, zip) - } - } - - companion object { - const val DIR_NAME = "mynotes-handoff" - private const val ZIP_PREFIX = "handoff-" - private const val ZIP_SUFFIX = ".zip" - - /** Paths of the files loads in this process are using; also the lock of the directory. */ - private val inUse = mutableSetOf() - - /** - * Deletes everything in [dir] that no load in this process is using (what killed - * processes left behind), and [dir] itself once it is empty. - */ - fun sweep(dir: File) { - synchronized(inUse) { - dir.listFiles()?.filterNot { it.path in inUse }?.forEach { it.deleteRecursively() } - if (inUse.isEmpty()) dir.delete() - } - } - - private fun createStagingFile(dir: File): File = synchronized(inUse) { - if (!dir.mkdirs() && !dir.isDirectory) throw IOException("no staging directory") - File.createTempFile(ZIP_PREFIX, ZIP_SUFFIX, dir).also { inUse += it.path } - } - - private fun release(dir: File, zip: File) { - synchronized(inUse) { - zip.delete() - inUse -= zip.path - // Only when no other load is using it (delete fails on a directory that is not empty). - if (inUse.isEmpty()) dir.delete() - } - } - } -} - -/** Deletes what hand-offs of killed processes left in the app's cache (see [HandoffStaging.sweep]). */ -fun interface HandoffStagingSweeper { - fun sweep() -} 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 deleted file mode 100644 index 2b8275fb..00000000 --- a/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.pasich.encly.data.handoff - -/** - * Editor.js stores inline formatting as HTML inside a block's text (``, ``, ``, - * ``, ``, `
`, entities). Encly blocks are plain text, so the formatting is dropped - * and only what the user typed is kept: no raw markup reaches the vault. - */ -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 - - // As char codes, not literals: the characters are invisible in source. - private const val NO_BREAK_SPACE_CODE = 0x00A0 - private const val ZERO_WIDTH_SPACE_CODE = 0x200B - private const val BYTE_ORDER_MARK_CODE = 0xFEFF - private val NO_BREAK_SPACE = Char(NO_BREAK_SPACE_CODE) - private val ZERO_WIDTH_SPACE = Char(ZERO_WIDTH_SPACE_CODE) - private val BYTE_ORDER_MARK = Char(BYTE_ORDER_MARK_CODE) - - private val NAMED = mapOf( - "amp" to "&", - "lt" to "<", - "gt" to ">", - "quot" to "\"", - "apos" to "'", - "nbsp" to " ", - ) - - /** - * [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. 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, 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(), "") - .replace(BYTE_ORDER_MARK.toString(), "") - } - - private fun decode(entity: String): String? { - val codePoint = when { - entity.startsWith("#x", ignoreCase = true) -> entity.substring(2).toIntOrNull(HEX) - entity.startsWith("#") -> entity.substring(1).toIntOrNull() - else -> return NAMED[entity] - } - return codePoint?.takeIf(::isTextCodePoint)?.let { String(Character.toChars(it)) } - } - - /** NUL and lone surrogates are not text; such an entity is kept as typed. */ - private fun isTextCodePoint(codePoint: Int): Boolean = Character.isValidCodePoint(codePoint) && - codePoint != 0 && - codePoint !in Char.MIN_SURROGATE.code..Char.MAX_SURROGATE.code -} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt deleted file mode 100644 index 52c904df..00000000 --- a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.pasich.encly.data.handoff - -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream -import java.io.File -import java.io.FilterInputStream -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.util.zip.ZipFile - -/** - * Size limits of a hand-off. Every one is enforced on the bytes actually read, never on a - * size a header claims, so a ZIP bomb or an endless stream stops at the limit. - */ -data class HandoffLimits( - /** The ZIP as copied from My Notes. */ - val maxZipBytes: Long = MAX_BYTES, - /** `handoff.json` uncompressed. */ - val maxJsonBytes: Long = MAX_BYTES, - /** Records per list (notes, tasks, tags, task categories). */ - val maxRecords: Int = 100_000, - /** A note's title, text or Editor.js JSON, a task's text: characters each. */ - val maxTextChars: Int = MIB, - /** A tag or category name, and a record id. */ - val maxNameChars: Int = 1_000, -) { - private companion object { - const val MIB = 1024 * 1024 - const val MAX_BYTES = 32L * MIB - } -} - -/** - * Reads a My Notes hand-off ZIP strictly: exactly one entry, `handoff.json`, in the v1 shape, - * within [HandoffLimits]. Throws [HandoffException] with [HandoffError.TOO_LARGE], - * [HandoffError.UNSUPPORTED_SCHEMA] or [HandoffError.INVALID_PAYLOAD]; nothing else escapes. - */ -@OptIn(ExperimentalSerializationApi::class) -object MyNotesHandoffReader { - const val ENTRY_NAME = "handoff.json" - - private val json = Json { - ignoreUnknownKeys = true - isLenient = false - coerceInputValues = false - } - - /** Reads `format` and `schema` only, before the full decode. */ - @Serializable - private class Probe(val format: String? = null, val schema: Int? = null) - - /** - * Copies [input] (the content URI's stream) to [target], stopping with [HandoffError.TOO_LARGE] - * past [HandoffLimits.maxZipBytes]. The caller deletes [target] whatever the outcome. - */ - fun copyLimited(input: InputStream, target: OutputStream, limits: HandoffLimits = HandoffLimits()) { - try { - LimitedInputStream(input, limits.maxZipBytes).copyTo(target) - } catch (_: LimitExceededException) { - throw HandoffException(HandoffError.TOO_LARGE) - } - } - - fun read(zip: File, limits: HandoffLimits = HandoffLimits()): MyNotesHandoff { - ensure(zip.length() <= limits.maxZipBytes, HandoffError.TOO_LARGE) - val handoff = guarded { - ZipFile(zip).use { archive -> - ensure(archive.size() == 1, HandoffError.INVALID_PAYLOAD) - val entry = archive.getEntry(ENTRY_NAME)?.takeUnless { it.isDirectory } - ?: throw HandoffException(HandoffError.INVALID_PAYLOAD) - // A declared size is only a hint (it can lie); the stream limit below is the check. - ensure(entry.size <= limits.maxJsonBytes, HandoffError.TOO_LARGE) - val open = { LimitedInputStream(archive.getInputStream(entry), limits.maxJsonBytes) } - val probe = open().use { decode(Probe.serializer(), it) } - val schema = probe.schema?.takeIf { probe.format == MyNotesHandoff.FORMAT } - ensure(schema != null && schema >= MyNotesHandoff.SCHEMA, HandoffError.INVALID_PAYLOAD) - ensure(schema == MyNotesHandoff.SCHEMA, HandoffError.UNSUPPORTED_SCHEMA) - open().use { decode(MyNotesHandoff.serializer(), it) } - } - } - validate(handoff, limits) - return handoff - } - - private fun decode(deserializer: DeserializationStrategy, input: InputStream): T = - json.decodeFromStream(deserializer, input) - - /** Maps every parse failure to a [HandoffError]; a [HandoffException] passes through. */ - private inline fun guarded(block: () -> T): T = try { - block() - } catch (e: IOException) { - rejected(e) - } catch (e: SerializationException) { - rejected(e) - } catch (e: IllegalArgumentException) { - rejected(e) - } - - /** The cause is dropped on purpose: a parser message can quote note content. */ - private fun rejected(e: Exception): Nothing = throw HandoffException( - if (e is LimitExceededException) HandoffError.TOO_LARGE else HandoffError.INVALID_PAYLOAD, - ) - - private fun validate(handoff: MyNotesHandoff, limits: HandoffLimits) { - val lists = listOf(handoff.tags, handoff.notes, handoff.taskCategories, handoff.tasks) - ensure(lists.all { it.size <= limits.maxRecords }, HandoffError.TOO_LARGE) - val texts = handoff.notes.flatMap { listOf(it.title, it.value, it.valueJson.orEmpty()) } + - handoff.tasks.map { it.description } - ensure(texts.all { it.length <= limits.maxTextChars }, HandoffError.TOO_LARGE) - val names = handoff.tags.flatMap { listOf(it.id, it.name) } + - handoff.taskCategories.flatMap { listOf(it.id, it.name) } + - handoff.notes.flatMap { listOf(it.id, it.tag.orEmpty()) } + - handoff.tasks.flatMap { listOf(it.id, it.categoryId.orEmpty()) } - ensure(names.all { it.length <= limits.maxNameChars }, HandoffError.TOO_LARGE) - - val valid = uniqueIds(handoff.tags.map { it.id }) && - uniqueIds(handoff.notes.map { it.id }) && - uniqueIds(handoff.taskCategories.map { it.id }) && - uniqueIds(handoff.tasks.map { it.id }) && - handoff.notes.all { it.attachments >= 0 } - ensure(valid, HandoffError.INVALID_PAYLOAD) - val categoryIds = handoff.taskCategories.mapTo(HashSet()) { it.id } - val knownCategories = handoff.tasks.all { it.categoryId == null || it.categoryId in categoryIds } - ensure(knownCategories, HandoffError.INVALID_PAYLOAD) - } - - private fun uniqueIds(ids: List): Boolean = ids.all { it.isNotBlank() } && ids.toSet().size == ids.size - - private fun ensure(condition: Boolean, error: HandoffError) { - if (!condition) throw HandoffException(error) - } - - private class LimitExceededException : IOException("limit exceeded") - - /** Fails with [LimitExceededException] once more than [limit] bytes were read. */ - private class LimitedInputStream(input: InputStream, private val limit: Long) : FilterInputStream(input) { - private var count = 0L - - 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 skip(n: Long): Long = super.skip(n).also(::count) - - override fun markSupported(): Boolean = false - - private fun count(n: Long) { - count += n - if (count > limit) throw LimitExceededException() - } - } -} 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 deleted file mode 100644 index 04a7eee9..00000000 --- a/app/src/main/java/com/pasich/encly/data/importer/Frontmatter.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.pasich.encly.data.importer - -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive - -/** A Markdown file split into its front matter (a safe subset of YAML) and its body. */ -class MarkdownDocument(private val meta: Map>, val body: String) { - val hasFrontmatter: Boolean get() = meta.isNotEmpty() - - fun has(key: String): Boolean = key in meta - - /** The first value of [key], or null. */ - fun value(key: String): String? = meta[key]?.firstOrNull()?.takeIf { it.isNotBlank() } - - /** The first of [keys] that has a value. */ - fun firstValue(vararg keys: String): String? = keys.firstNotNullOfOrNull(::value) - - /** All values of [key]; a comma separated scalar counts as a list. */ - fun list(key: String): List = meta[key].orEmpty().flatMap { it.split(',') } - - fun flag(key: String): Boolean = value(key)?.lowercase() in TRUE_VALUES - - private companion object { - val TRUE_VALUES = setOf("true", "yes", "1") - } -} - -/** - * Front matter read by hand, with no YAML library and nothing executed: `key: value` lines, a - * `[a, b]` inline list or `- item` lines under a key, quoted values unquoted. Anything else - * (nested maps, block scalars, anchors, tags) is ignored. Without a closing `---` within - * [MAX_LINES] lines the file has no front matter and is all body; nor has a block with no - * `key:` line in it (a horizontal rule, some text, another rule). - */ -object Frontmatter { - private const val MAX_LINES = 200 - private val KEY_VALUE = Regex("""^([A-Za-z0-9_][A-Za-z0-9_ .-]*):(?:[ \t]+(.*))?$""") - private val LIST_ITEM = Regex("""^[ \t]*-[ \t]+(.*)$""") - private val json = Json { isLenient = false } - - fun split(text: String): MarkdownDocument { - val lines = text.lines() - val end = if (lines.firstOrNull()?.trimEnd() == "---") { - (1 until minOf(lines.size, MAX_LINES)).firstOrNull { - lines[it].trimEnd() == "---" || lines[it].trimEnd() == "..." - } - } else { - null - } - return if (end == null || !isFrontmatter(lines.subList(1, end))) { - MarkdownDocument(emptyMap(), text) - } else { - MarkdownDocument(parse(lines.subList(1, end)), lines.drop(end + 1).joinToString("\n")) - } - } - - /** A block is front matter if it is empty or has a `key:` line (list lines only mean something under one). */ - private fun isFrontmatter(block: List): Boolean = - block.all { it.isBlank() } || block.any { KEY_VALUE.matches(it) && !it.first().isWhitespace() } - - private fun parse(lines: List): Map> { - val meta = LinkedHashMap>() - var current: String? = null - for (line in lines) { - val pair = KEY_VALUE.matchEntire(line)?.takeUnless { line.first().isWhitespace() } - if (pair != null) { - current = pair.groupValues[1].trim().lowercase() - meta.getOrPut(current) { ArrayList() } += scalars(pair.groupValues[2].trim()) - } else { - val item = LIST_ITEM.matchEntire(line) - if (item != null && current != null) meta.getValue(current) += unquote(item.groupValues[1].trim()) - } - } - return meta.filterValues { values -> values.any { it.isNotBlank() } } - } - - /** The values on the `key:` line itself: nothing for a block scalar, several for `[a, b]`. */ - private fun scalars(raw: String): List = when { - raw.startsWith("[") && raw.endsWith("]") -> raw.removeSurrounding("[", "]").split(',').map { - unquote(it.trim()) - } - - raw.isNotEmpty() && raw != "|" && raw != ">" -> listOf(unquote(raw)) - - else -> emptyList() - } - - /** A JSON-quoted string is decoded (Notesnook writes titles that way); a single-quoted one is unwrapped. */ - private fun unquote(raw: String): String = when { - raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"") -> try { - (json.parseToJsonElement(raw) as? JsonPrimitive)?.content ?: raw.drop(1).dropLast(1) - } catch (_: SerializationException) { - raw.drop(1).dropLast(1) - } - - raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'") -> raw.drop(1).dropLast(1).replace("''", "'") - - else -> raw - } -} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt deleted file mode 100644 index a2c0f53f..00000000 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt +++ /dev/null @@ -1,31 +0,0 @@ -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/ImportTime.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt deleted file mode 100644 index 85bc34da..00000000 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index ce97b006..00000000 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.pasich.encly.data.importer - -import androidx.annotation.StringRes -import com.pasich.encly.R -import com.pasich.encly.dynamicBlocks.Block - -/** - * The apps Encly can import from. [formats], [howTo] and [losses] are string resources shown on - * the Import notes page; a source is listed only once its parser and tests exist. - */ -enum class ImportSource( - @get:StringRes val title: Int, - @get:StringRes val formats: Int, - @get:StringRes val howTo: Int, - @get:StringRes val losses: Int, -) { - GOOGLE_KEEP( - R.string.import_keep_name, - R.string.import_keep_formats, - R.string.import_keep_howto, - R.string.import_keep_losses, - ), - STANDARD_NOTES( - R.string.import_sn_name, - R.string.import_sn_formats, - R.string.import_sn_howto, - R.string.import_sn_losses, - ), - JOPLIN( - R.string.import_joplin_name, - R.string.import_joplin_formats, - R.string.import_joplin_howto, - R.string.import_joplin_losses, - ), - NOTESNOOK( - R.string.import_nn_name, - R.string.import_nn_formats, - R.string.import_nn_howto, - R.string.import_nn_losses, - ), - MARKDOWN( - R.string.import_md_name, - R.string.import_md_formats, - R.string.import_md_howto, - R.string.import_md_losses, - ), -} - -/** Why an import stops before anything is written. */ -enum class ImportError { - /** A limit of [ImportLimits] was exceeded. */ - TOO_LARGE, - - /** Files were read, but none is a supported export. */ - UNSUPPORTED, - - /** Nothing readable was selected. */ - NOTHING_FOUND, - - /** The selection could not be opened or read. */ - UNREADABLE, -} - -/** Carries an [ImportError]; the message never quotes content. */ -class ImportException(val error: ImportError) : Exception(error.name) - -/** - * Every limit is enforced on the bytes actually read, never on a size an archive header claims, - * so a ZIP bomb stops at the limit. - */ -data class ImportLimits( - /** Bytes read from the selected files in total (compressed bytes count as read); room for a Takeout of photos. */ - val maxInputBytes: Long = MAX_INPUT_BYTES, - /** Bytes after decompression, over every entry that is kept or looked at (oversized ones included). */ - val maxTotalBytes: Long = MAX_TOTAL_BYTES, - /** Bytes inflated and thrown away: media and attachments in a ZIP, which are counted, never kept. */ - val maxSkippedBytes: Long = MAX_SKIPPED_BYTES, - /** One note file. */ - val maxEntryBytes: Int = MAX_ENTRY_BYTES, - /** A JSON export that holds many notes at once (Standard Notes). */ - val maxContainerBytes: Int = MAX_CONTAINER_BYTES, - val maxEntries: Int = MAX_ENTRIES, - val maxNotes: Int = MAX_NOTES, - val maxPathDepth: Int = MAX_PATH_DEPTH, - /** JSON nesting; the tree parser recurses per level. */ - val maxJsonDepth: Int = MAX_JSON_DEPTH, -) { - companion object { - private const val MIB = 1024 * 1024 - const val MAX_TOTAL_BYTES = 64L * MIB - const val MAX_INPUT_BYTES = 2048L * MIB - const val MAX_SKIPPED_BYTES = 2048L * MIB - const val MAX_ENTRY_BYTES = 4 * MIB - const val MAX_CONTAINER_BYTES = 16 * MIB - const val MAX_ENTRIES = 20_000 - const val MAX_NOTES = 20_000 - const val MAX_PATH_DEPTH = 16 - const val MAX_JSON_DEPTH = 64 - } -} - -/** One file of the selection (or one archive member), fully in memory and never on disk. */ -class ImportEntry(val path: String, val bytes: ByteArray) { - val name: String get() = path.substringAfterLast('/') - val extension: String get() = name.substringAfterLast('.', "").lowercase() -} - -/** Files the reading layer left out, by reason. */ -data class ReadSkips( - val unsafePaths: Int = 0, - val nestedArchives: Int = 0, - val tooLarge: Int = 0, - val unreadable: Int = 0, -) { - val total: Int get() = unsafePaths + nestedArchives + tooLarge + unreadable -} - -/** What the parsers could not bring over, counted over the whole selection. */ -data class ImportLosses( - val attachments: Int = 0, - val pinned: Int = 0, - val archived: Int = 0, - val colors: Int = 0, - /** Labels beyond the first: Encly has one tag per note. */ - val extraTags: Int = 0, - /** Joplin to-do notes arrive as ordinary notes. */ - val todos: Int = 0, -) { - operator fun plus(other: ImportLosses) = ImportLosses( - attachments + other.attachments, - pinned + other.pinned, - archived + other.archived, - colors + other.colors, - extraTags + other.extraTags, - todos + other.todos, - ) - - val any: Boolean get() = this != ImportLosses() -} - -/** Entries the parsers did not use. [unsupported] includes encrypted items. */ -data class ImportSkips( - val unsupported: Int = 0, - val unreadable: Int = 0, - val empty: Int = 0, - val duplicatesInSelection: Int = 0, -) { - val files: Int get() = unreadable + empty -} - -/** A note as read from another app, before it is mapped to Encly's records. */ -data class ImportedNote( - /** Stable id inside the source (uuid, path, ...) for a repeatable uid; null when there is none. */ - val sourceKey: String?, - val title: String, - val blocks: List, - /** Tag names in order of preference; Encly keeps the first. */ - val tags: List = emptyList(), - val created: Long? = null, - val edited: Long? = null, - val trashed: Boolean = false, - val losses: ImportLosses = ImportLosses(), -) - -/** Parser output. */ -class ParsedNotes( - val notes: List, - val skips: ImportSkips, - /** Losses that belong to no single note (attachment files, Joplin resources). */ - val extraLosses: ImportLosses = ImportLosses(), -) diff --git a/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt b/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt deleted file mode 100644 index 040edffa..00000000 --- a/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.pasich.encly.data.importer - -import com.pasich.encly.data.handoff.InlineHtml -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType - -/** - * Markdown as Encly blocks. Line based and forgiving; only the markup it reads is removed. - * - * - `#`..`####` -> H1-H4 (5 and 6 -> H4); `- [ ]` / `- [x]` -> LIST_CHECK; `-` `*` `+` -> - * LIST_BULLET; `1.` -> LIST_NUMBER (nested items are flattened, Encly lists are flat); - * `>` -> QUOTE; `---` / `***` -> SEPARATOR; everything else -> TEXT, one block per paragraph. - * - `✅` / `☐` lines (Notesnook text exports) are checklist items. An Obsidian callout keeps its title - * (`> [!note] Decisions` -> `Decisions`), a wiki link keeps its alias or name. - * - Fenced code keeps its lines as TEXT, markers removed. Inline markdown (`**bold**`, links) is - * kept as typed, which reads fine as plain text; HTML tags are removed and entities decoded - * ([InlineHtml]) outside code spans: the inline tags of real HTML (``, `
`, `
`, ...) - * go and their text stays, while other angle-bracket text (`Map`, ``) is kept. - */ -object MarkdownBlockMapper { - private val HEADING = Regex("""^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$""") - private val CHECK = Regex("""^[ \t]*[-*+][ \t]+\[([ xX])\][ \t]+(.*)$""") - private val GLYPH_ITEM = Regex("""^[ \t]*([✅☑☐])[ \t]*(.*)$""") - private val CALLOUT = Regex("""^\[![A-Za-z-]+][+-]?[ \t]*""") - - // No `[` in a name or alias: otherwise every `[[` of a long run of brackets scans to the end of the line. - private val WIKI_LINK = Regex("""!?\[\[([^\[\]|\n]+)(?:\|([^\[\]\n]+))?]]""") - private val BULLET = Regex("""^[ \t]*[-*+][ \t]+(.*)$""") - - // Up to three digits: "2024. Report" is text. Encly numbers its items itself, so a start number is not kept. - private val NUMBER = Regex("""^[ \t]*\d{1,3}[.)][ \t]+(.*)$""") - private val QUOTE = Regex("""^ {0,3}>[ \t]?(.*)$""") - private val SEPARATOR = Regex("""^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$""") - private val FENCE = Regex("""^ {0,3}(```|~~~).*$""") - private val CODE_SPAN = Regex("""`[^`\n]+`""") - private val AUTOLINK = Regex("""<((?:https?://|mailto:)[^\s<>]+|[^\s<>@/]+@[^\s<>@/]+)>""") - - fun toBlocks(markdown: String): List = Builder().apply { markdown.lines().forEach(::line) }.finish() - - /** - * The same line rules for text the user pastes into the editor, where nothing may be - * rewritten: HTML, wiki links and callout markers stay as typed, fence lines stay in their - * code paragraph, and blank list items are kept. [claim] may take a line whole as a block of - * its own (a lone web address); it runs before every other rule. With [plainFirstLine] the - * first line is plain text whatever it starts with, for a paste that continues a line. - * [toBlocks] is unchanged. - */ - internal fun pasteBlocks( - text: String, - plainFirstLine: Boolean = false, - claim: (String) -> Block? = { null }, - ): List = Builder(PasteRules(claim, plainFirstLine)).apply { text.lines().forEach(::line) }.finish() - - private class PasteRules(val claim: (String) -> Block?, var plainFirstLine: Boolean) - - /** [text] with HTML removed outside code spans. */ - internal fun inline(text: String): String { - val linked = AUTOLINK.replace(text, "$1") - val out = StringBuilder() - var last = 0 - for (span in CODE_SPAN.findAll(linked)) { - out.append(plain(linked.substring(last, span.range.first))).append(span.value) - last = span.range.last + 1 - } - return out.append(plain(linked.substring(last))).toString() - } - - /** Wiki links as the text a reader sees (`[[Note|alias]]` -> alias), then no HTML. */ - private fun plain(text: String): String = InlineHtml.toPlainText( - WIKI_LINK.replace(text) { it.groupValues[2].ifEmpty { it.groupValues[1] } }, - knownTagsOnly = true, - ) - - private class Builder(private val paste: PasteRules? = null) { - private val blocks = ArrayList() - private val paragraph = ArrayList() - private val quote = ArrayList() - private val code = ArrayList() - private val items = ArrayList() - private var listType: BlockType? = null - private var inFence = false - - fun line(raw: String) { - if (paste?.plainFirstLine == true) { - paste.plainFirstLine = false - if (raw.isNotBlank()) { - paragraph += raw.trimEnd() - return - } - } - val fence = FENCE.matches(raw) - when { - inFence && fence -> { - if (paste != null) code += raw - closeFence() - } - - inFence -> code += raw - - fence -> { - flushAll() - inFence = true - if (paste != null) code += raw - } - - raw.isBlank() -> { - // A blank line ends a paragraph or quote but not a list: loose lists stay one block. - flushParagraph() - flushQuote() - } - - else -> block(raw) - } - } - - fun finish(): List { - if (inFence) closeFence() - flushAll() - return blocks - } - - private fun block(raw: String) { - val heading = HEADING.matchEntire(raw) - val check = CHECK.matchEntire(raw) - val glyph = GLYPH_ITEM.matchEntire(raw) - val bullet = BULLET.matchEntire(raw) - val number = NUMBER.matchEntire(raw) - val quoted = QUOTE.matchEntire(raw) - val claimed = paste?.claim?.invoke(raw) - when { - claimed != null -> { - flushAll() - blocks += claimed - } - - SEPARATOR.matches(raw) -> { - flushAll() - blocks += Block.SeparatorBlock() - } - - heading != null -> { - flushAll() - val level = heading.groupValues[1].length - blocks += ImportBlocks.heading(readable(heading.groupValues[2]), level) - } - - check != null -> item(BlockType.LIST_CHECK, check.groupValues[2], check.groupValues[1] != " ") - - glyph != null -> item(BlockType.LIST_CHECK, glyph.groupValues[2], glyph.groupValues[1] != "☐") - - bullet != null -> item(BlockType.LIST_BULLET, bullet.groupValues[1], false) - - number != null -> item(BlockType.LIST_NUMBER, number.groupValues[1], false) - - quoted != null -> { - flushParagraph() - flushList() - quote += - readable( - if (paste == - null - ) { - CALLOUT.replaceFirst(quoted.groupValues[1], "") - } else { - quoted.groupValues[1] - }, - ) - } - - // Indented text right after a list item continues that item. - listType != null && raw.first().isWhitespace() -> { - val last = items.removeAt(items.lastIndex) - items += last.copy(value = (last.value + " " + readable(raw.trim())).trim()) - } - - else -> { - flushList() - flushQuote() - paragraph += readable(raw.trimEnd()) - } - } - } - - /** [text] as the importer reads it; a paste keeps it as it is. */ - private fun readable(text: String): String = if (paste == null) inline(text) else text - - private fun item(type: BlockType, text: String, checked: Boolean) { - flushParagraph() - flushQuote() - if (listType != type) flushList() - listType = type - items += ItemListBlock(value = readable(text).trim(), isCheck = checked) - } - - private fun closeFence() { - inFence = false - val text = code.joinToString("\n").trimEnd() - code.clear() - if (text.isNotBlank()) blocks += ImportBlocks.text(text) - } - - private fun flushAll() { - flushParagraph() - flushQuote() - flushList() - } - - private fun flushParagraph() { - if (paragraph.isEmpty()) return - val text = paragraph.joinToString("\n").trim() - paragraph.clear() - if (text.isNotEmpty()) blocks += ImportBlocks.text(text) - } - - private fun flushQuote() { - if (quote.isEmpty()) return - val text = quote.joinToString("\n").trim() - quote.clear() - if (text.isNotEmpty()) blocks += ImportBlocks.quote(text) - } - - private fun flushList() { - val type = listType ?: return - // A paste keeps an empty item (an empty checklist template); an import drops it. - val kept = if (paste != null) items.toList() else items.filter { it.value.isNotBlank() } - items.clear() - listType = null - if (kept.isNotEmpty()) blocks += ImportBlocks.list(type, kept) - } - } -} diff --git a/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt b/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt deleted file mode 100644 index dd040046..00000000 --- a/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt +++ /dev/null @@ -1,72 +0,0 @@ -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/model/Note.kt b/app/src/main/java/com/pasich/encly/data/model/Note.kt deleted file mode 100644 index 8720f9bf..00000000 --- a/app/src/main/java/com/pasich/encly/data/model/Note.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.pasich.encly.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.Index -import androidx.room.PrimaryKey - -/** - * [uid] is the note's stable, device-independent identity used by encrypted backups (merge - * skips a uid that already exists). Code never assigns it: a blank uid is filled with a random - * one by the database on insert and restored on update (see VaultSchema). - */ -@Entity(tableName = "notes", indices = [Index(value = ["uid"], unique = true)]) -data class Note( - @PrimaryKey(autoGenerate = true) - var id: Long = 0, - var title: String = "", - var value: String = "", - var description: String = "", - var date: Long = 0L, - var dateCreate: Long = 0L, - var tagId: Long? = null, - @Ignore var isChecked: Boolean = false, - var isTrash: Boolean = false, - @ColumnInfo(defaultValue = "''") - var uid: String = "", - @ColumnInfo(defaultValue = "0") - var isPinned: Boolean = false, - @ColumnInfo(defaultValue = "0") - var isArchived: Boolean = false, - /** A [com.pasich.encly.domain.model.NoteColor] key, never an ARGB value. */ - @ColumnInfo(defaultValue = "'none'") - var color: String = NO_COLOR, - /** The note's own text size in sp, or null to follow the default from Settings. */ - var textSize: Int? = null, - /** The note's own line spacing (a `NoteLineSpacing` ordinal), or null to follow the default. */ - var lineSpacing: Int? = null, -) { - - /** - * Returns true if the note contains neither a title nor content. - */ - fun isEmpty(): Boolean = title.isBlank() && value.isBlank() - - /** - * Checks whether there are changes in the note's content or title. - */ - internal fun hasContentChanged(newBlocksJson: String, newTitle: String): Boolean { - if (title != newTitle) { - return true - } - // Check for changes in the block content - return value != newBlocksJson - } - - companion object { - /** The stored key of "no colour"; the palette lives in `domain.model.NoteColor`. */ - const val NO_COLOR = "none" - - fun new(title: String, value: String, tagId: Long? = null): Note = Note( - title = title, - value = value, - date = System.currentTimeMillis(), - dateCreate = System.currentTimeMillis(), - tagId = tagId, - ) - } -} diff --git a/app/src/main/java/com/pasich/encly/data/model/NoteWithTag.kt b/app/src/main/java/com/pasich/encly/data/model/NoteWithTag.kt deleted file mode 100644 index 12416477..00000000 --- a/app/src/main/java/com/pasich/encly/data/model/NoteWithTag.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.pasich.encly.data.model - -import androidx.room.Embedded -import androidx.room.Relation - -data class NoteWithTag( - @Embedded val note: Note, - @Relation( - parentColumn = "tagId", - entityColumn = "id", - ) - val tag: Tag?, -) diff --git a/app/src/main/java/com/pasich/encly/data/model/Task.kt b/app/src/main/java/com/pasich/encly/data/model/Task.kt deleted file mode 100644 index 68f2712a..00000000 --- a/app/src/main/java/com/pasich/encly/data/model/Task.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.pasich.encly.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey - -/** [uid]: stable backup identity, see [Note.uid]. */ -@Entity(tableName = "tasks", indices = [Index(value = ["uid"], unique = true)]) -data class Task( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - val title: String, - val description: String? = null, - val isCompleted: Boolean = false, - val createdDate: Long = System.currentTimeMillis(), - val completedDate: Long? = null, - val priority: Int = 0, // 0 - Low, 1 - Medium, 2 - High - val categoryId: Long? = null, - var position: Int = 0, - @ColumnInfo(defaultValue = "''") - val uid: String = "", -) { - companion object { - fun new(title: String, description: String? = null, priority: Int = 0, categoryId: Long? = null): Task = Task( - title = title, - description = description, - priority = priority, - categoryId = categoryId, - ) - } -} 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 deleted file mode 100644 index 979c0079..00000000 --- a/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt +++ /dev/null @@ -1,66 +0,0 @@ -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 - -private const val TAG = "NotesRepository" - -/** Keeps the opening that created this repository; delayed calls cannot cross a re-lock. */ -@Suppress("TooManyFunctions") // One function per read and per single-column write. -class NotesRepositoryImpl @Inject constructor(private val databaseProvider: DatabaseProvider) : NotesRepository { - private val lease: DatabaseLease? = databaseProvider.captureLease() - - private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).notesDao() - - override fun getAllNotesWithTag(): Flow> = daoFlow { dao().getAllNotesWithTags() } - - override fun getNotesByTagId(tagId: Long): Flow> = daoFlow { dao().getNotesByTagId(tagId) } - - override fun getArchivedNotesWithTag(): Flow> = daoFlow { dao().getArchivedNotesWithTags() } - - override fun getArchivedNotesByTagId(tagId: Long): Flow> = - daoFlow { dao().getArchivedNotesByTagId(tagId) } - - override fun observeNote(noteId: Long): Flow = daoFlow { dao().observeNoteWithTag(noteId) } - - override suspend fun setColor(noteId: Long, color: String): Result = storageWrite(TAG, "setColor") { - dao().updateColor(noteId, color).requireRows() - } - - override suspend fun setTag(noteId: Long, tagId: Long?): Result = storageWrite(TAG, "setTag") { - dao().updateTagId(noteId, tagId).requireRows() - } - - override suspend fun setDescription(noteId: Long, description: String): Result = - storageWrite(TAG, "setDescription") { dao().updateDescription(noteId, description).requireRows() } - - override suspend fun setPinned(noteId: Long, pinned: Boolean): Result = storageWrite(TAG, "setPinned") { - dao().updatePinned(noteId, pinned) - // An archived note refuses a pin without touching a row; that is not a failure. - Unit - } - - override suspend fun setArchived(noteId: Long, archived: Boolean): Result = - storageWrite(TAG, "setArchived") { dao().updateArchived(noteId, archived).requireRows() } - - override fun getTrashNotes(): Flow> = daoFlow { dao().getTrashNotes() } - - override suspend fun getNoteById(noteId: Long): Note? = dao().getNoteById(noteId) - - override suspend fun insertNote(note: Note): Result = storageWrite(TAG, "insertNote") { - dao().insertNote(note) - } - - override suspend fun updateNote(note: Note): Result = storageWrite(TAG, "updateNote") { - dao().updateNote(note).requireRows() - } - - override suspend fun deleteNoteById(id: Long): Result = storageWrite(TAG, "deleteNoteById") { - dao().deleteNoteById(id).requireRows() - } -} diff --git a/app/src/main/java/com/pasich/encly/domain/enums/BottomSheetsOpenType.kt b/app/src/main/java/com/pasich/encly/domain/enums/BottomSheetsOpenType.kt deleted file mode 100644 index 182d8f8f..00000000 --- a/app/src/main/java/com/pasich/encly/domain/enums/BottomSheetsOpenType.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.pasich.encly.domain.enums - -enum class BottomSheetsOpenType { - ACTION_LINK, - ACTION_OTHER, - SORT, - NONE, -} diff --git a/app/src/main/java/com/pasich/encly/domain/enums/NoteSortOption.kt b/app/src/main/java/com/pasich/encly/domain/enums/NoteSortOption.kt deleted file mode 100644 index 5489df4c..00000000 --- a/app/src/main/java/com/pasich/encly/domain/enums/NoteSortOption.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.pasich.encly.domain.enums - -import androidx.annotation.StringRes -import com.pasich.encly.R -import com.pasich.encly.data.model.Note - -enum class NoteSortOption( - @param:StringRes val labelRes: Int, - @param:StringRes val shortLabelRes: Int, - val index: Int, -) { - UPDATED_ASC( - labelRes = R.string.sort_updated_asc, - shortLabelRes = R.string.sort_updated_asc_short, - index = 0, - ), - UPDATED_DESC( - labelRes = R.string.sort_updated_desc, - shortLabelRes = R.string.sort_updated_desc_short, - index = 1, - ), // DEFAULT - CREATED_ASC( - labelRes = R.string.sort_created_asc, - shortLabelRes = R.string.sort_created_asc_short, - index = 2, - ), - CREATED_DESC( - labelRes = R.string.sort_created_desc, - shortLabelRes = R.string.sort_created_desc_short, - index = 3, - ), - ; - - /** Orders notes the way this option names. */ - fun comparator(): Comparator = when (this) { - UPDATED_ASC -> compareBy { it.date } - UPDATED_DESC -> compareByDescending { it.date } - CREATED_ASC -> compareBy { it.dateCreate } - CREATED_DESC -> compareByDescending { it.dateCreate } - } - - companion object { - fun fromIndex(index: Int): NoteSortOption = entries.find { it.index == index } ?: UPDATED_DESC - } -} diff --git a/app/src/main/java/com/pasich/encly/domain/model/ItemListBlock.kt b/app/src/main/java/com/pasich/encly/domain/model/ItemListBlock.kt deleted file mode 100644 index 40277476..00000000 --- a/app/src/main/java/com/pasich/encly/domain/model/ItemListBlock.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.pasich.encly.domain.model - -import java.util.UUID - -/** - * One list/checklist row. [id] keys the row's field state in Compose, so deleting or inserting - * a row cannot hand one row's text field to another. It survives `copy()` and is never - * serialized (`@Transient`), so stored notes are unchanged. - */ -data class ItemListBlock( - val value: String = "", - val isCheck: Boolean = false, - @Transient val id: String = UUID.randomUUID().toString(), -) diff --git a/app/src/main/java/com/pasich/encly/domain/model/LinkDataBlock.kt b/app/src/main/java/com/pasich/encly/domain/model/LinkDataBlock.kt deleted file mode 100644 index 454ff652..00000000 --- a/app/src/main/java/com/pasich/encly/domain/model/LinkDataBlock.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.pasich.encly.domain.model - -data class LinkDataBlock( - var title: String = "", - var imageUrl: String = "", - var url: String = "", - var isError: Boolean = false, - -) diff --git a/app/src/main/java/com/pasich/encly/domain/model/NoteColor.kt b/app/src/main/java/com/pasich/encly/domain/model/NoteColor.kt deleted file mode 100644 index b3f9da04..00000000 --- a/app/src/main/java/com/pasich/encly/domain/model/NoteColor.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.pasich.encly.domain.model - -import androidx.annotation.StringRes -import com.pasich.encly.R -import com.pasich.encly.data.model.Note - -/** - * The colour a note can be marked with. Only the stable [key] is stored (database and backups); - * the shade is resolved from the active theme at draw time, so it never goes stale when the - * theme or light/dark mode changes. An unknown key (a newer palette) reads as [NONE]. - */ -enum class NoteColor(val key: String, @get:StringRes val labelRes: Int) { - NONE(Note.NO_COLOR, R.string.note_color_none), - RED("red", R.string.note_color_red), - ORANGE("orange", R.string.note_color_orange), - YELLOW("yellow", R.string.note_color_yellow), - GREEN("green", R.string.note_color_green), - TEAL("teal", R.string.note_color_teal), - BLUE("blue", R.string.note_color_blue), - PURPLE("purple", R.string.note_color_purple), - PINK("pink", R.string.note_color_pink), - ; - - /** The next colour of the palette; the last one wraps to [NONE]. */ - fun next(): NoteColor = entries[(ordinal + 1) % entries.size] - - companion object { - fun fromKey(key: String?): NoteColor = entries.firstOrNull { it.key == key } ?: NONE - } -} diff --git a/app/src/main/java/com/pasich/encly/domain/model/NoteListItem.kt b/app/src/main/java/com/pasich/encly/domain/model/NoteListItem.kt deleted file mode 100644 index 68bffbcb..00000000 --- a/app/src/main/java/com/pasich/encly/domain/model/NoteListItem.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.pasich.encly.domain.model - -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.data.model.Tag -import com.pasich.encly.utils.NotesTextFormatter - -/** - * A note as the note list and search show it. [preview] and [searchText] are derived from the - * stored blocks once, when the note is loaded, never during composition. - * - * @property preview the note's description, or else the plain text of its blocks; null when - * the blocks cannot be read (damaged, or written by a newer version) - * @property searchText what search matches besides the title: the description and the text the - * user typed, without list markers or separator lines - */ -data class NoteListItem(val noteWithTag: NoteWithTag, val preview: String?, val searchText: String) { - val note: Note get() = noteWithTag.note - val tag: Tag? get() = noteWithTag.tag - - /** Case-insensitive match of [query] against the title, description and content. */ - fun matches(query: String): Boolean = - note.title.contains(query, ignoreCase = true) || searchText.contains(query, ignoreCase = true) - - companion object { - /** Parses [noteWithTag]'s blocks once; an unreadable note becomes an item, not a crash. */ - fun of(noteWithTag: NoteWithTag): NoteListItem { - val note = noteWithTag.note - val blocks = BlockConverter.jsonToBlocksOrNull(note.value) - val preview = note.description.ifEmpty { blocks?.let(NotesTextFormatter::blocksToPlainText) } - val content = blocks?.let(NotesTextFormatter::blocksToSearchText).orEmpty() - val searchText = listOf(note.description, content).filter { it.isNotEmpty() }.joinToString("\n") - return NoteListItem(noteWithTag, preview, searchText) - } - } -} diff --git a/app/src/main/java/com/pasich/encly/domain/repository/NotesRepository.kt b/app/src/main/java/com/pasich/encly/domain/repository/NotesRepository.kt deleted file mode 100644 index d12e79d5..00000000 --- a/app/src/main/java/com/pasich/encly/domain/repository/NotesRepository.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.pasich.encly.domain.repository - -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag -import kotlinx.coroutines.flow.Flow - -/** - * The notes in the encrypted vault. Flows fail through the flow; a read throws; a write - * returns [Result.failure] (including when no row was written) and never a sentinel value. - */ -@Suppress("TooManyFunctions") // One function per read and per single-column write. -interface NotesRepository { - fun getAllNotesWithTag(): Flow> - fun getNotesByTagId(tagId: Long): Flow> - fun getArchivedNotesWithTag(): Flow> - fun getArchivedNotesByTagId(tagId: Long): Flow> - fun getTrashNotes(): Flow> - - /** The note as stored now (null once it is gone), following every change to it. */ - fun observeNote(noteId: Long): Flow - - // Each writes one column of the stored row and nothing else, so a caller holding an older - // copy of the note cannot undo another change. - suspend fun setColor(noteId: Long, color: String): Result - suspend fun setTag(noteId: Long, tagId: Long?): Result - suspend fun setDescription(noteId: Long, description: String): Result - - /** Pins or unpins; an archived note stays unpinned. */ - suspend fun setPinned(noteId: Long, pinned: Boolean): Result - - /** Archives (and unpins) or unarchives. */ - suspend fun setArchived(noteId: Long, archived: Boolean): Result - - suspend fun getNoteById(noteId: Long): Note? - - /** The new note's row id. */ - suspend fun insertNote(note: Note): Result - suspend fun updateNote(note: Note): Result - suspend fun deleteNoteById(id: Long): Result -} diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/ObserveNotesUseCase.kt b/app/src/main/java/com/pasich/encly/domain/usecase/note/ObserveNotesUseCase.kt deleted file mode 100644 index f9550bbd..00000000 --- a/app/src/main/java/com/pasich/encly/domain/usecase/note/ObserveNotesUseCase.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.pasich.encly.domain.usecase.note - -import com.pasich.encly.core.di.IoDispatcher -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag -import com.pasich.encly.domain.enums.NoteSortOption -import com.pasich.encly.domain.model.NoteListItem -import com.pasich.encly.domain.repository.NotesRepository -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -/** - * The notes of the main list (not trashed, not archived) or of the archive, sorted with the - * pinned ones first, with their previews. Parsing every note's blocks happens here, - * on [dispatcher], once per database change: never on the main thread and never per keystroke - * or recomposition. A read failure fails the flow; the screen turns it into an error state. - */ -class ObserveNotesUseCase @Inject constructor( - private val repository: NotesRepository, - @IoDispatcher private val dispatcher: CoroutineDispatcher, -) { - /** - * @param tagId the tag to show, or null for every note whose tag is not hidden - * @param archived the archive instead of the main list ([tagId] does not apply to it) - */ - operator fun invoke( - tagId: Long?, - sortOption: NoteSortOption, - archived: Boolean = false, - ): Flow> { - val notes = when { - archived -> repository.getArchivedNotesWithTag().map { all -> all.filter { it.isShown() } } - - tagId == null -> repository.getAllNotesWithTag().map { all -> all.filter { it.isShown() } } - - else -> combine(repository.getNotesByTagId(tagId), repository.getArchivedNotesByTagId(tagId)) { live, old -> - // A hidden tag's archived notes are in no other list (the archive leaves hidden tags - // out too), so its own view is the way to unarchive them. A visible tag's are in the archive. - live + old.filter { it.tag?.isVisible == false } - } - } - return notes.map { list -> list.toSortedItems(sortOption) }.flowOn(dispatcher) - } - - /** The note as stored now, following every change to it; null once it is gone. */ - fun observeNote(noteId: Long): Flow = repository.observeNote(noteId) - - /** A note whose tag is hidden stays out of the lists that show every note. */ - private fun NoteWithTag.isShown(): Boolean = tag == null || tag.isVisible - - private fun List.toSortedItems(sortOption: NoteSortOption): List { - // Pinned first whatever the sort, archived notes (a hidden tag's view) last; the sort orders within. - val comparator = compareBy { it.isArchived }.thenByDescending { it.isPinned } - .then(sortOption.comparator()) - return sortedWith { a, b -> comparator.compare(a.note, b.note) }.map(NoteListItem::of) - } -} diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteArchiveStatusUseCase.kt b/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteArchiveStatusUseCase.kt deleted file mode 100644 index 865851cc..00000000 --- a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteArchiveStatusUseCase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.pasich.encly.domain.usecase.note - -import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.repository.NotesRepository -import javax.inject.Inject - -/** Moves the note to the archive, or back to the list. Archiving unpins it. */ -class UpdateNoteArchiveStatusUseCase @Inject constructor(private val repository: NotesRepository) { - suspend operator fun invoke(targetNote: Note, isArchived: Boolean): Result = - repository.setArchived(targetNote.id, isArchived) -} diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteTagUseCase.kt b/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteTagUseCase.kt deleted file mode 100644 index b50d290b..00000000 --- a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteTagUseCase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.pasich.encly.domain.usecase.note - -import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.repository.NotesRepository -import javax.inject.Inject - -/** Moves the note to the tag [tagId] (0: no tag). Writes the tag only. */ -class UpdateNoteTagUseCase @Inject constructor(private val repository: NotesRepository) { - suspend operator fun invoke(targetNote: Note, tagId: Long): Result = - repository.setTag(targetNote.id, tagId.takeIf { it > 0L }) -} diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteTrashStatusUseCase.kt b/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteTrashStatusUseCase.kt deleted file mode 100644 index e9d9bdf2..00000000 --- a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteTrashStatusUseCase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.pasich.encly.domain.usecase.note - -import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.repository.NotesRepository -import javax.inject.Inject - -/** Moves the note to the trash, or back out of it. */ -class UpdateNoteTrashStatusUseCase @Inject constructor(private val repository: NotesRepository) { - suspend operator fun invoke(targetNote: Note, isTrash: Boolean): Result = - repository.updateNote(targetNote.copy(isTrash = isTrash)) -} diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCase.kt b/app/src/main/java/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCase.kt deleted file mode 100644 index 401d9688..00000000 --- a/app/src/main/java/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.pasich.encly.domain.usecase.task - -import com.pasich.encly.domain.repository.TasksRepository -import javax.inject.Inject - -/** Completes or reopens a task; a completed task records when it was completed. */ -class UpdateTaskStatusUseCase @Inject constructor(private val tasksRepository: TasksRepository) { - suspend operator fun invoke( - id: Long, - isCompleted: Boolean, - completedDate: Long? = if (isCompleted) System.currentTimeMillis() else null, - ): Result = tasksRepository.updateTaskStatus(id, isCompleted, completedDate) -} diff --git a/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt b/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt deleted file mode 100644 index 2d1a677b..00000000 --- a/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.pasich.encly.dynamicBlocks - -import androidx.compose.ui.graphics.Color -import com.pasich.encly.R -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.domain.model.LinkDataBlock -import kotlinx.coroutines.flow.MutableStateFlow -import java.util.UUID - -enum class BlockType { - TEXT, - LIST_NUMBER, - LIST_CHECK, - LIST_BULLET, - QUOTE, - SEPARATOR, - LINK, - H1, - H2, - H3, - H4, -} - -/** A fresh editor-session identity for a block or list item. Never persisted. */ -fun newBlockId(): String = UUID.randomUUID().toString() - -/** - * Every block carries [id], a stable identity for the editor session: Compose keys its - * remembered state by it, undo and focus find their block by it, and two blocks with equal - * content (two separators) are still different blocks. It is not serialized; loading a note - * gives every block a new one. - */ -sealed class Block { - abstract val id: String - - data class TextBlock( - override val text: MutableStateFlow = MutableStateFlow(""), - val placeholder: Int = R.string.text, - override val id: String = newBlockId(), - ) : Block(), - TextualBlock - - data class HBlock( - override val text: MutableStateFlow = MutableStateFlow(""), - val blockType: BlockType, - override val id: String = newBlockId(), - ) : Block(), - TextualBlock - - data class QuoteBlock( - override val text: MutableStateFlow = MutableStateFlow(""), - override val id: String = newBlockId(), - ) : Block(), - TextualBlock - - data class LinkBlock( - val block: MutableStateFlow = MutableStateFlow( - LinkDataBlock(), - ), - override val id: String = newBlockId(), - ) : Block() - - data class SeparatorBlock(@Transient val color: Color = Color.Gray, override val id: String = newBlockId()) : - Block() - - data class ListBlock( - val items: MutableStateFlow> = MutableStateFlow( - listOf( - ItemListBlock(""), - ), - ), - val blockType: BlockType, - override val id: String = newBlockId(), - /** - * A locked checklist keeps its text out of reach (no focus, no keyboard) while its boxes - * still toggle. Stored as an optional `isLocked` key, only when true. - */ - val locked: MutableStateFlow = MutableStateFlow(false), - ) : Block() -} - -/** - * Whether [this] block is stored. The single rule for "blank": blocks that carry no content - * are left out when a note is serialized, and a draft of only such blocks is not saved. - */ -fun Block.hasContent(): Boolean = when (this) { - is Block.TextBlock -> text.value.isNotBlank() - is Block.HBlock -> text.value.isNotBlank() - is Block.QuoteBlock -> text.value.isNotBlank() - is Block.ListBlock -> items.value.any { item -> item.value.isNotBlank() } - is Block.LinkBlock -> block.value.url.isNotBlank() - is Block.SeparatorBlock -> true -} diff --git a/app/src/main/java/com/pasich/encly/dynamicBlocks/TextualBlock.kt b/app/src/main/java/com/pasich/encly/dynamicBlocks/TextualBlock.kt deleted file mode 100644 index f36387a4..00000000 --- a/app/src/main/java/com/pasich/encly/dynamicBlocks/TextualBlock.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.pasich.encly.dynamicBlocks - -import kotlinx.coroutines.flow.MutableStateFlow - -/** - * Interface for blocks that contain text. - * Used for uniform handling of text blocks of different types. - */ -interface TextualBlock { - val text: MutableStateFlow - - /** - * Checks whether the block's text is empty. - */ - fun isEmpty(): Boolean = text.value.isEmpty() - - /** - * Appends text to the end. - */ - fun appendText(addition: String) { - text.value = text.value + addition - } -} diff --git a/app/src/main/java/com/pasich/encly/platform/AndroidDeviceCapabilities.kt b/app/src/main/java/com/pasich/encly/platform/AndroidDeviceCapabilities.kt new file mode 100644 index 00000000..1194200c --- /dev/null +++ b/app/src/main/java/com/pasich/encly/platform/AndroidDeviceCapabilities.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.platform + +import android.os.Build +import com.pasich.encly.utils.DeviceCapabilities +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +/** [DeviceCapabilities] of an Android device. */ +@SingleIn(AppScope::class) +@Inject +@ContributesBinding(AppScope::class) +class AndroidDeviceCapabilities : DeviceCapabilities { + override fun supportsDynamicColors(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S +} diff --git a/app/src/main/java/com/pasich/encly/platform/AndroidDocumentPickers.kt b/app/src/main/java/com/pasich/encly/platform/AndroidDocumentPickers.kt new file mode 100644 index 00000000..0971f1cf --- /dev/null +++ b/app/src/main/java/com/pasich/encly/platform/AndroidDocumentPickers.kt @@ -0,0 +1,60 @@ +package com.pasich.encly.platform + +import android.content.ActivityNotFoundException +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * The Storage Access Framework dialogs as [DocumentPickers]: the system's own "save as", "open" and "choose + * a folder" screens, so Encly needs no storage permission and learns of no more than the one document the + * user picked. A device with no documents app reports it as [PickerUnavailableException]. + */ +object AndroidDocumentPickers : DocumentPickers { + + @Composable + override fun rememberCreateDocument(mimeType: String, onPick: (DocumentRef?) -> Unit): PickerLauncher { + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(mimeType)) { uri -> + onPick(uri?.let { DocumentRef(it.toString()) }) + } + return launcher.asPicker() + } + + @Composable + override fun rememberOpenDocument(onPick: (DocumentRef?) -> Unit): PickerLauncher> { + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + onPick(uri?.let { DocumentRef(it.toString()) }) + } + return launcher.asPicker() + } + + @Composable + override fun rememberOpenDocuments(onPick: (List) -> Unit): PickerLauncher> { + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + onPick(uris.map { DocumentRef(it.toString()) }) + } + return launcher.asPicker() + } + + @Composable + override fun rememberOpenFolder(onPick: (DocumentRef?) -> Unit): PickerLauncher { + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + onPick(uri?.let { DocumentRef(it.toString()) }) + } + return remember(launcher) { PickerLauncher { launcher.launchOrUnavailable(null) } } + } + + @Composable + private fun ActivityResultLauncher.asPicker(): PickerLauncher = + remember(this) { PickerLauncher { input -> launchOrUnavailable(input) } } + + private fun ActivityResultLauncher.launchOrUnavailable(input: I) { + try { + launch(input) + } catch (_: ActivityNotFoundException) { + throw PickerUnavailableException() + } + } +} diff --git a/app/src/main/java/com/pasich/encly/platform/AndroidDocuments.kt b/app/src/main/java/com/pasich/encly/platform/AndroidDocuments.kt new file mode 100644 index 00000000..d0e875d4 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/platform/AndroidDocuments.kt @@ -0,0 +1,98 @@ +package com.pasich.encly.platform + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import androidx.core.net.toUri +import com.pasich.encly.core.di.ApplicationContext +import com.pasich.encly.data.importer.DocumentTree +import com.pasich.encly.data.importer.TreeNode +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.io.IOException +import kotlinx.io.RawSink +import kotlinx.io.RawSource +import kotlinx.io.asSink +import kotlinx.io.asSource + +/** + * [PlatformDocuments] over the Storage Access Framework: the documents the user picked through the system + * dialogs, reached by their content URI. Only [android.content.ContentResolver] streams are used (no file + * paths, seeking or reliance on a provider's size), and a folder is walked with [DocumentsContract]. A + * refused grant (a [SecurityException]) is reported as an [IOException] without its message. + */ +@SingleIn(AppScope::class) +@Inject +@ContributesBinding(AppScope::class) +class AndroidDocuments(@param:ApplicationContext private val context: Context) : PlatformDocuments { + + override fun openRead(doc: DocumentRef): RawSource? = + refusedAsIo { context.contentResolver.openInputStream(doc.uri())?.asSource() } + + // "wt": the document is empty, but some providers need an explicit truncate mode. + override fun openWrite(doc: DocumentRef): RawSink? = + refusedAsIo { context.contentResolver.openOutputStream(doc.uri(), "wt")?.asSink() } + + override fun displayName(doc: DocumentRef): String? = try { + context.contentResolver.query(doc.uri(), arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } + } catch (_: SecurityException) { + null + } catch (_: IllegalArgumentException) { + null + } + + override fun delete(doc: DocumentRef) { + DocumentsContract.deleteDocument(context.contentResolver, doc.uri()) + } + + override fun openTree(root: DocumentRef, maxEntries: Int): DocumentTree = SafTree(root.uri(), maxEntries) + + private fun DocumentRef.uri(): Uri = value.toUri() + + private inline fun refusedAsIo(block: () -> T): T = try { + block() + } catch (_: SecurityException) { + throw IOException("access to the document was refused") + } + + /** A picked folder through [DocumentsContract]; document ids are the provider's. */ + private inner class SafTree(private val tree: Uri, private val maxEntries: Int) : 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 < 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 + } + + /** A file the provider refuses to open is an unreadable one, like a missing one. */ + override fun open(id: String): RawSource? = try { + context.contentResolver.openInputStream(DocumentsContract.buildDocumentUriUsingTree(tree, id))?.asSource() + } catch (_: SecurityException) { + null + } + } +} diff --git a/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt b/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt new file mode 100644 index 00000000..00173710 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt @@ -0,0 +1,280 @@ +package com.pasich.encly.platform + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.WindowManager +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.pasich.encly.BuildConfig +import com.pasich.encly.R +import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.di.ApplicationContext +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.locale.AppLocales +import com.pasich.encly.core.security.ActivityBiometricHost +import com.pasich.encly.core.security.ScreenshotAllowance +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.io.files.Path +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString + +/** Hands the shared UI what it needs from Android, once per Activity, around its whole content. */ +@Inject +class AndroidHostServices( + private val toaster: Toaster, + private val clipboard: SensitiveClipboard, + private val locales: AppLocaleController, + private val info: AppInfo, + private val viewModelFactory: MetroViewModelFactory, + private val screenshots: ScreenshotAllowance, +) { + /** + * Provides the `Local...` services of `:shared` for [activity] to [content], inside the keyboard and + * clipboard policy ([strictKeyboard] is "Strict keyboard privacy" in Settings). + */ + @Composable + fun Provide(activity: AppCompatActivity, strictKeyboard: Boolean, content: @Composable () -> Unit) { + val view = LocalView.current + val window = remember(activity, view) { AndroidAppWindow(activity, view) } + val links = remember(activity) { AndroidExternalLinks(activity) } + val biometricHost = remember(activity) { ScreenBiometricHost(ActivityBiometricHost(activity), activity) } + FollowScreenshotAllowance(screenshots, activity, window) + CompositionLocalProvider( + LocalMetroViewModelFactory provides viewModelFactory, + LocalAppWindow provides window, + LocalExternalLinks provides links, + LocalToaster provides toaster, + LocalSensitiveClipboard provides clipboard, + LocalBiometricHost provides biometricHost, + LocalScreenshotAllowance provides screenshots, + LocalAppLocales provides locales, + LocalAppInfo provides info, + LocalDocumentPickers provides AndroidDocumentPickers, + ) { + SecureTextInputBoundary(strict = strictKeyboard) { + SensitiveClipboardBoundary(content) + } + } + } +} + +/** + * Keeps [window] capturable exactly while [ScreenshotAllowance] says so, and protected again whenever the + * Activity leaves the foreground (the recents snapshot). + */ +@Composable +private fun FollowScreenshotAllowance( + screenshots: ScreenshotAllowance, + activity: AppCompatActivity, + window: AndroidAppWindow, +) { + DisposableEffect(screenshots, activity, window) { + val stop = screenshots.observe(window::setCaptureAllowed) + val lifecycle = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> window.setInForeground(false) + Lifecycle.Event.ON_RESUME -> window.setInForeground(true) + else -> Unit + } + } + activity.lifecycle.addObserver(lifecycle) + onDispose { + activity.lifecycle.removeObserver(lifecycle) + stop() + } + } +} + +/** + * The Activity's window as [AppWindow]. [view] is the Compose view, which the keyboard is shown and hidden + * through on API 29 and older. + */ +class AndroidAppWindow(private val activity: Activity, private val view: View) : AppWindow { + private val window get() = activity.window + + override val isChangingConfigurations: Boolean get() = activity.isChangingConfigurations + + /** + * Whether the window may be captured (the user's "Allow screenshots until the app locks", with no secret + * screen showing). Never persisted: a window always starts protected. + */ + private var captureAllowed = false + + /** + * Whether the Activity is resumed; false until it is, and again from onPause on, so the recents snapshot + * of a window that was allowed is protected again. + */ + private var inForeground = false + + /** What the recents snapshot setting was last made (Android 13+); the platform default is on. */ + private var recentsScreenshots = true + + /** Protects the window, unless screenshots are allowed right now; run again by the theme on each frame. */ + override fun enforceSecure() = applySecurity() + + fun setCaptureAllowed(allowed: Boolean) { + captureAllowed = allowed + applySecurity() + } + + fun setInForeground(foreground: Boolean) { + inForeground = foreground + applySecurity() + } + + private fun applySecurity() { + val secure = WindowManager.LayoutParams.FLAG_SECURE + if (captureAllowed && inForeground) window.clearFlags(secure) else window.addFlags(secure) + // Android 13+: a window that may be captured still never leaves a snapshot in recents. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && recentsScreenshots == captureAllowed) { + recentsScreenshots = !captureAllowed + activity.setRecentsScreenshotEnabled(recentsScreenshots) + } + } + + override fun setLightStatusBar(light: Boolean) { + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = light + } + + override fun setBackgroundColor(argb: Int) = window.setBackgroundDrawable(argb.toDrawable()) + + override fun hideKeyboard() { + WindowCompat.getInsetsController(window, view).hide(WindowInsetsCompat.Type.ime()) + } + + override fun showKeyboard() { + WindowCompat.getInsetsController(window, view).show(WindowInsetsCompat.Type.ime()) + } +} + +/** [ExternalLinks] that start the activity of another app from [context] (an Activity). */ +class AndroidExternalLinks(private val context: Context) : ExternalLinks { + + @Suppress("TooGenericExceptionCaught") // Another app's failure to take the link must never crash Encly. + override fun openUrl(url: String, chooser: Boolean): Boolean { + val view = Intent(Intent.ACTION_VIEW, url.toUri()) + return try { + context.startActivity(if (chooser) Intent.createChooser(view, null) else view) + true + } catch (_: ActivityNotFoundException) { + false + } catch (e: RuntimeException) { + // FileUriExposedException, SecurityException: the address cannot leave the app safely. + AppLogger.w(TAG, "Link not opened: ${e.javaClass.simpleName}") + false + } + } + + override fun writeMail(address: String): Boolean { + val intent = Intent(Intent.ACTION_SENDTO).apply { data = "mailto:$address".toUri() } + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } + } + + private companion object { + const val TAG = "ExternalLinks" + } +} + +/** Toasts with the string resource read in a coroutine and shown on the main thread. */ +@SingleIn(AppScope::class) +@Inject +@ContributesBinding(AppScope::class) +class AndroidToaster(@param:ApplicationContext private val context: Context) : Toaster { + override fun show(text: StringResource, duration: ToastDuration, vararg args: Any) { + CoroutineScope(Dispatchers.Main.immediate).launch { + val length = if (duration == ToastDuration.LONG) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + Toast.makeText(context, getString(text, *args), length).show() + } + } +} + +/** [SensitiveClipboard] over the system clipboard, by [SensitiveClip]'s policy. */ +@SingleIn(AppScope::class) +@Inject +@ContributesBinding(AppScope::class) +class AndroidSensitiveClipboard(@param:ApplicationContext private val context: Context) : SensitiveClipboard { + private val manager get() = context.getSystemService(ClipboardManager::class.java) + + override val systemConfirmsCopy: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + override fun copy(label: String, text: String): Boolean { + val clipboard = manager ?: return false + SensitiveClip.copy(clipboard, label, text) + return true + } + + override fun clear() { + manager?.let(SensitiveClip::clear) + } +} + +/** The in-app language through [AppLocales] (AppCompat's per-app locales). */ +@SingleIn(AppScope::class) +@Inject +@ContributesBinding(AppScope::class) +class AndroidAppLocaleController : AppLocaleController { + override fun current(): AppLanguage = AppLocales.current() + + /** Applied on the main thread: a restore from a backup calls this from its I/O thread. */ + override fun apply(language: AppLanguage) { + if (Looper.myLooper() == Looper.getMainLooper()) { + AppLocales.apply(language) + } else { + Handler(Looper.getMainLooper()).post { AppLocales.apply(language) } + } + } +} + +/** This build's facts, from [BuildConfig] and the launcher label. */ +@SingleIn(AppScope::class) +@Inject +@ContributesBinding(AppScope::class) +class AndroidAppInfo(@param:ApplicationContext private val context: Context) : AppInfo { + override val appName: String get() = context.getString(R.string.app_name) + override val versionName: String = BuildConfig.VERSION_NAME + override val donationsEnabled: Boolean = BuildConfig.DONATIONS_ENABLED + override val storeRatingEnabled: Boolean = BuildConfig.STORE_RATING_ENABLED + override val storePageLinks: List = listOf( + "market://details?id=${context.packageName}", + "https://play.google.com/store/apps/details?id=${context.packageName}", + ) +} + +/** The app's cache directory. */ +@SingleIn(AppScope::class) +@Inject +@ContributesBinding(AppScope::class) +class AndroidCacheDirectory(@param:ApplicationContext private val context: Context) : CacheDirectory { + override val path: Path get() = Path(context.cacheDir.path) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/SecureTextInputBoundary.kt b/app/src/main/java/com/pasich/encly/platform/SecureTextInputBoundary.kt similarity index 98% rename from app/src/main/java/com/pasich/encly/presentation/components/SecureTextInputBoundary.kt rename to app/src/main/java/com/pasich/encly/platform/SecureTextInputBoundary.kt index 2a4b979a..152ffad0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/SecureTextInputBoundary.kt +++ b/app/src/main/java/com/pasich/encly/platform/SecureTextInputBoundary.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.components +package com.pasich.encly.platform import android.text.InputType import android.view.inputmethod.EditorInfo diff --git a/app/src/main/java/com/pasich/encly/presentation/components/SensitiveClip.kt b/app/src/main/java/com/pasich/encly/platform/SensitiveClip.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/presentation/components/SensitiveClip.kt rename to app/src/main/java/com/pasich/encly/platform/SensitiveClip.kt index 99c765a3..9e5d6208 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/SensitiveClip.kt +++ b/app/src/main/java/com/pasich/encly/platform/SensitiveClip.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.components +package com.pasich.encly.platform import android.content.ClipData import android.content.ClipDescription @@ -99,7 +99,7 @@ object SensitiveClip { * `Clipboard.nativeClipboardManager`, which throws for any other [Clipboard], so a plain * wrapper crashed the app on every text selection. */ -private class SensitiveClipboard(private val delegate: Clipboard) : AndroidClipboard { +private class SensitiveComposeClipboard(private val delegate: Clipboard) : AndroidClipboard { override val clipboardManager: ClipboardManager get() = delegate.nativeClipboard override suspend fun getClipEntry(): ClipEntry? = delegate.getClipEntry() @@ -115,6 +115,6 @@ private class SensitiveClipboard(private val delegate: Clipboard) : AndroidClipb @Composable fun SensitiveClipboardBoundary(content: @Composable () -> Unit) { val platform = LocalClipboard.current - val clipboard = remember(platform) { SensitiveClipboard(platform) } + val clipboard = remember(platform) { SensitiveComposeClipboard(platform) } CompositionLocalProvider(LocalClipboard provides clipboard, content = content) } diff --git a/app/src/main/java/com/pasich/encly/presentation/components/NoteLineSpacingSlider.kt b/app/src/main/java/com/pasich/encly/presentation/components/NoteLineSpacingSlider.kt deleted file mode 100644 index f14816b7..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/NoteLineSpacingSlider.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.pasich.encly.presentation.components - -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.domain.model.NoteLineSpacing -import kotlin.math.roundToInt - -/** The reading densities in display order; the enum's own order is fixed by what is stored. */ -internal 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, -) - -/** The reading density as a three-step slider, in the same row style as the text size. */ -@Composable -fun NoteLineSpacingSlider( - selected: NoteLineSpacing, - onSelect: (NoteLineSpacing) -> Unit, - modifier: Modifier = Modifier, -) { - val index = lineSpacingOrder.indexOfFirst { it.first == selected }.coerceAtLeast(0) - LabeledSlider( - modifier = modifier, - label = stringResource(R.string.note_line_spacing_title), - valueText = stringResource(lineSpacingOrder[index].second), - value = index.toFloat(), - onValueChange = { onSelect(lineSpacingOrder[it.roundToInt().coerceIn(lineSpacingOrder.indices)].first) }, - scale = SliderScale(0f..lineSpacingOrder.lastIndex.toFloat(), steps = lineSpacingOrder.size - 2), - ) -} 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 deleted file mode 100644 index 231346a5..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt +++ /dev/null @@ -1,313 +0,0 @@ -package com.pasich.encly.presentation.components.editNote - -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.view.Window -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.isImeVisible -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import com.pasich.encly.R -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.designsystem.EnclyEditorToolbar -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyToolButton -import com.pasich.encly.presentation.designsystem.EnclyToolbarRule -import com.pasich.encly.presentation.designsystem.ToolStyle -import com.pasich.encly.presentation.editor.BlockToolButton -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 - -/** - * The editor's formatting toolbar, pinned to the bottom above the keyboard (design spec §3.3): - * the filled "Add block", then the block tools, the one of the block the user works on marked - * active, and undo and redo. The chevron at the start swaps in a paragraph, every heading level, a link - * and a separator, and moving, duplicating or deleting the block. In simple editing only undo and redo - * are offered. - */ -@Composable -fun NoteBottomBar( - modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), - simpleEdit: Boolean = false, -) { - var showMore by rememberSaveable { mutableStateOf(false) } - // Re-evaluated when the block the user works on or the blocks (their order) change. - val interactedBlockId by viewModel.interactedBlockId.collectAsState() - val activeTool by remember(viewModel) { - derivedStateOf { viewModel.blocks.firstOrNull { it.id == interactedBlockId }?.toolType() } - } - val checklistLock = rememberChecklistLock(viewModel, interactedBlockId) - val blockFocused = viewModel.focusedBlockId.collectAsState().value != null - val anchor = rememberKeyboardAnchor() - // A change that replaces the focused field runs inside the anchor, so the keyboard stays up. - val keepingKeyboard: (() -> Unit) -> Unit = { change -> - anchor.around(viewModel.focusedBlockId.value, { id -> viewModel.blocks.any { it.id == id } }, change) - } - val history = rememberHistoryTools(viewModel, keepingKeyboard) - val blockTools = rememberBlockMoveTools(viewModel, interactedBlockId, checklistLock, keepingKeyboard) - EnclyEditorToolbar(modifier = modifier.imePadding()) { - KeyboardAnchorField(anchor) - when { - simpleEdit -> ToolRow(spread = false) { - HistoryButtons(history) - Spacer(Modifier.weight(1f)) - KeyboardToggle(blockFocused = blockFocused, onShow = viewModel::focusWorkingBlock) - } - - else -> AnimatedContent( - targetState = showMore, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - modifier = Modifier.weight(1f), - label = "toolbar", - ) { more -> - if (more) { - MoreTools( - blockTools = blockTools, - onApply = toolApplier(checklistLock) { type -> - keepingKeyboard { viewModel.applyTool(type, exact = true) } - }, - onClose = { showMore = false }, - ) - } else { - MainTools( - activeTool = activeTool, - history = history, - onApply = toolApplier(checklistLock) { type -> keepingKeyboard { viewModel.applyTool(type) } }, - onMore = { showMore = true }, - keyboard = KeyboardToggleState(blockFocused, viewModel::focusWorkingBlock), - ) - } - } - } - } -} - -/** The block-tools toggle, the block tools (the active one marked), then undo and redo. */ -@Composable -private fun MainTools( - activeTool: BlockType?, - history: HistoryTools, - onApply: ((BlockType) -> Unit)?, - onMore: () -> Unit, - keyboard: KeyboardToggleState, -) { - ToolRow(spread = true) { - EnclyToolButton( - icon = EnclyIcons.ChevronUp, - contentDescription = stringResource(R.string.block_more_tools), - onClick = onMore, - style = ToolStyle.FILLED, - ) - // The filled toggle never touches a highlighted tool next to it, even when the row is tight. - Spacer(Modifier.width(EnclyTheme.spacing.xs)) - mainBlockTools.forEach { tool -> - BlockToolButton( - tool = tool, - active = tool.type == activeTool, - onClick = onApply?.let { apply -> { apply(tool.type) } }, - ) - } - EnclyToolbarRule() - HistoryButtons(history) - EnclyToolbarRule() - KeyboardToggle(blockFocused = keyboard.blockFocused, onShow = keyboard.onShow) - } -} - -/** What the keyboard toggle needs: whether a block's field has focus, and how to focus one. */ -private class KeyboardToggleState(val blockFocused: Boolean, val onShow: () -> Unit) - -/** - * Hides the keyboard while it is up, and brings it back into the block the user was writing in - * ([onShow] focuses it, the cursor where it was) while it is down. - * - * Built to work on every keyboard and skin: - * - Whether the keyboard is up comes from the window insets, but a floating or split keyboard - * (Gboard floating, Samsung's) reports none. So a focused block ([blockFocused]) counts as the - * keyboard being up too, unless the insets just showed it being put away with the block still - * focused (Back on a docked keyboard). - * - Hiding clears focus as well as asking the keyboard to hide: an editor without a focused field - * has no input connection, which closes any keyboard, however it handles hide requests. - * - Showing focuses the working block first (a keyboard cannot open for nothing), then asks - * both Compose's controller and the window's insets controller, since some OEM keyboards and - * skins ignore one of the two. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun KeyboardToggle(blockFocused: Boolean, onShow: () -> Unit) { - val imeVisible = WindowInsets.isImeVisible - val keyboard = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - val view = LocalView.current - val insets = remember(view) { view.context.findWindow()?.let { WindowCompat.getInsetsController(it, view) } } - // True once the insets saw the keyboard go away while a block kept focus: it is down now, - // even though the block is focused. A new focus or a show starts over. - var dismissedWhileFocused by remember { mutableStateOf(false) } - var wasImeVisible by remember { mutableStateOf(imeVisible) } - LaunchedEffect(imeVisible, blockFocused) { - when { - !blockFocused || imeVisible -> dismissedWhileFocused = false - wasImeVisible -> dismissedWhileFocused = true - } - wasImeVisible = imeVisible - } - val shown = imeVisible || (blockFocused && !dismissedWhileFocused) - EnclyToolButton( - icon = if (shown) EnclyIcons.KeyboardHide else EnclyIcons.Keyboard, - contentDescription = stringResource(if (shown) R.string.keyboard_hide else R.string.keyboard_show), - onClick = { - if (shown) { - keyboard?.hide() - insets?.hide(WindowInsetsCompat.Type.ime()) - focusManager.clearFocus() - } else { - dismissedWhileFocused = false - // A field that still has focus reopens its input connection when focused again. - if (blockFocused) focusManager.clearFocus() - onShow() - scope.launch { - // Once the field has taken focus (the next frames), in case the keyboard did - // not come up on its own. - repeat(SHOW_AFTER_FRAMES) { withFrameNanos { } } - keyboard?.show() - insets?.show(WindowInsetsCompat.Type.ime()) - } - } - }, - ) -} - -/** Frames to wait for the focused field before asking the keyboard to show. */ -private const val SHOW_AFTER_FRAMES = 2 - -private tailrec fun Context.findWindow(): Window? = when (this) { - is Activity -> window - is ContextWrapper -> baseContext.findWindow() - else -> null -} - -/** - * One row of tools across the toolbar's width: [spread] evenly (`SpaceBetween`) when they fit, - * scrolling sideways on a screen too narrow for them. - */ -@Composable -private fun ToolRow(spread: Boolean, content: @Composable RowScope.() -> Unit) { - BoxWithConstraints { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = if (spread) { - Arrangement.SpaceBetween - } else { - Arrangement.spacedBy(EnclyTheme.spacing.xxs) - }, - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .widthIn(min = maxWidth), - content = content, - ) - } -} - -@Composable -private fun HistoryButtons(history: HistoryTools) { - EnclyToolButton( - icon = EnclyIcons.Undo, - contentDescription = stringResource(R.string.undo), - onClick = history.onUndo, - ) - EnclyToolButton( - icon = EnclyIcons.Redo, - contentDescription = stringResource(R.string.redo), - onClick = history.onRedo, - ) -} - -/** Behind the toggle: close, paragraph, heading levels, link and separator, move, duplicate and delete. */ -@Composable -private fun MoreTools(blockTools: BlockMoveTools, onApply: ((BlockType) -> Unit)?, onClose: () -> Unit) { - ToolRow(spread = false) { - EnclyToolButton(icon = EnclyIcons.Close, contentDescription = stringResource(R.string.close), onClick = onClose) - moreBlockTools.forEach { tool -> - BlockToolButton( - tool = tool, - active = false, - onClick = onApply?.let { apply -> - { - apply(tool.type) - onClose() - } - }, - ) - } - EnclyToolbarRule() - EnclyToolButton( - icon = EnclyIcons.ArrowUp, - contentDescription = stringResource(R.string.block_move_up), - onClick = blockTools.onMoveUp, - ) - EnclyToolButton( - icon = EnclyIcons.ArrowDown, - contentDescription = stringResource(R.string.block_move_down), - onClick = blockTools.onMoveDown, - ) - EnclyToolButton( - icon = EnclyIcons.Duplicate, - contentDescription = stringResource(R.string.block_duplicate), - onClick = blockTools.onDuplicate, - ) - blockTools.lock?.let { lock -> - EnclyToolButton( - icon = if (lock.locked) EnclyIcons.LockOpen else EnclyIcons.Lock, - contentDescription = stringResource( - if (lock.locked) R.string.checklist_unlock else R.string.checklist_lock, - ), - onClick = lock.onToggle, - ) - } - EnclyToolButton( - icon = EnclyIcons.Trash, - contentDescription = stringResource(R.string.delete_block), - onClick = blockTools.onDelete, - ) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteHeader.kt b/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteHeader.kt deleted file mode 100644 index 3bc77be8..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteHeader.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.pasich.encly.presentation.components.editNote - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -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.stringResource -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow -import com.pasich.encly.R -import com.pasich.encly.data.model.Tag -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.EnclySheetRow -import com.pasich.encly.utils.formatNoteDate -import java.util.Date - -/** - * The note's overline (design spec §4.4): "PERSONAL · TODAY 9:40 AM", the tag and when the note - * was last edited; only the date without a tag. [notSaved] adds a persistent "Not saved" in - * `error`, announced politely, after a save failed. - */ -@Composable -fun NoteOverline(tagName: String?, date: Long, notSaved: Boolean, modifier: Modifier = Modifier) { - val edited = formatNoteDate(if (date == 0L) Date() else Date(date)) - val style = MaterialTheme.typography.labelSmall - Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text( - text = listOfNotNull(tagName, edited).joinToString(OVERLINE_SEPARATOR).uppercase(), - style = style, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - if (notSaved) { - Text( - text = (OVERLINE_SEPARATOR + stringResource(R.string.note_not_saved)).uppercase(), - style = style, - color = MaterialTheme.colorScheme.error, - maxLines = 1, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - ) - } - } -} - -private const val OVERLINE_SEPARATOR = " · " - -/** - * Chooses the note's tag: "No tag" and every tag, the current one checked, then a way to - * create or manage tags ([onManageTags]). Dismissible without choosing. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NoteTagSheet( - tags: List, - selectedTagId: Long?, - onSelect: (Long) -> Unit, - onManageTags: () -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - EnclyBottomSheet(onDismissRequest = onDismiss, modifier = modifier, title = stringResource(R.string.edit_tags)) { - // Many tags scroll; the manage row below stays in view. - Column(modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { - EnclySheetRow( - title = stringResource(R.string.no_tag), - onClick = { onSelect(0L) }, - selected = selectedTagId == null || selectedTagId == 0L, - muted = true, - ) - tags.forEach { tag -> - EnclySheetRow( - title = tag.nameTag, - icon = EnclyIcons.Tag, - onClick = { onSelect(tag.id) }, - selected = selectedTagId == tag.id, - ) - } - } - EnclyGroupDivider() - EnclySheetRow( - title = stringResource(if (tags.isEmpty()) R.string.create_new_tag else R.string.tags_manage), - icon = if (tags.isEmpty()) EnclyIcons.Plus else EnclyIcons.Edit, - onClick = onManageTags, - ) - } -} 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 deleted file mode 100644 index 45eff928..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.pasich.encly.presentation.components.settings - -import androidx.annotation.StringRes -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.domain.model.SwipeAction -import com.pasich.encly.domain.model.SwipeSettings -import com.pasich.encly.domain.model.SwipeSide -import com.pasich.encly.presentation.components.swipe.choiceLabel -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.viewmodel.SettingsEvent -import com.pasich.encly.presentation.viewmodel.SettingsViewModel - -/** Destinations the settings list links to. */ -data class SettingsNavigation( - val onAppearance: () -> Unit, - val onSecurity: () -> Unit, - val onBackup: () -> Unit, - val onImportNotes: () -> Unit, - val onAbout: () -> Unit, - val onFaq: () -> Unit, -) - -/** - * The settings root (design spec §4.6): General (Appearance, Language), Notes and tasks (the - * switches), Privacy, Backup, and About with the FAQ last. - */ -class SettingsBuilder { - - @Composable - fun buildSettingsCategories( - viewModel: SettingsViewModel, - showTasks: Boolean, - simpleEdit: Boolean, - swipe: SwipeSettings, - navigation: SettingsNavigation, - ): List = listOf( - general(viewModel, navigation), - notesAndTasks(viewModel, showTasks, simpleEdit), - swipeActions(viewModel, swipe), - single(R.string.settings_privacy, R.string.security_title, R.string.security_subtitle, EnclyIcons.Shield) { - navigation.onSecurity() - }, - single( - R.string.settings_backup, - R.string.backup_title, - R.string.backup_settings_subtitle, - EnclyIcons.Download, - ) { - navigation.onBackup() - }, - single( - R.string.settings_import, - R.string.import_title, - R.string.import_settings_subtitle, - EnclyIcons.File, - ) { - navigation.onImportNotes() - }, - SettingsCategory( - titleRes = R.string.about, - items = listOf( - SettingsItem.Navigation( - titleRes = R.string.about, - subtitleRes = R.string.settings_about_desc, - action = navigation.onAbout, - icon = EnclyIcons.Info, - ), - SettingsItem.Navigation( - titleRes = R.string.main_drawer_faq, - subtitleRes = R.string.settings_faq_desc, - action = navigation.onFaq, - icon = EnclyIcons.Help, - ), - ), - ), - ) - - @Composable - private fun general(viewModel: SettingsViewModel, navigation: SettingsNavigation) = SettingsCategory( - titleRes = R.string.settings_general, - items = listOf( - SettingsItem.Navigation( - titleRes = R.string.settings_appearance, - subtitleRes = R.string.appearance_settings_subtitle, - action = navigation.onAppearance, - icon = EnclyIcons.Palette, - ), - SettingsItem.Selection( - titleRes = R.string.settings_language, - currentValue = stringResource(viewModel.currentLanguage().nativeName), - action = { viewModel.setLanguageDialogVisibility(true) }, - icon = EnclyIcons.Globe, - ), - ), - ) - - private fun notesAndTasks(viewModel: SettingsViewModel, showTasks: Boolean, simpleEdit: Boolean) = SettingsCategory( - titleRes = R.string.settings_notes_and_tasks, - items = listOf( - SettingsItem.Switch( - titleRes = R.string.settings_show_tasks, - subtitleRes = R.string.settings_show_tasks_desc, - checked = showTasks, - icon = EnclyIcons.Checklist, - onCheckedChange = { viewModel.onEvent(SettingsEvent.UpdateShowTasks(it)) }, - ), - SettingsItem.Switch( - titleRes = R.string.settings_simple_edit, - subtitleRes = R.string.settings_simple_edit_desc, - checked = simpleEdit, - icon = EnclyIcons.Type, - onCheckedChange = { viewModel.onEvent(SettingsEvent.UpdateSimpleEdit(it)) }, - ), - ), - ) - - /** The master switch for swipes on note cards and, while it is on, the action for each direction. */ - @Composable - private fun swipeActions(viewModel: SettingsViewModel, swipe: SwipeSettings) = SettingsCategory( - titleRes = R.string.settings_swipe_section, - items = buildList { - add( - SettingsItem.Switch( - titleRes = R.string.settings_swipe_enabled, - subtitleRes = R.string.settings_swipe_enabled_desc, - checked = swipe.enabled, - icon = EnclyIcons.Sliders, - onCheckedChange = { viewModel.onEvent(SettingsEvent.UpdateSwipeEnabled(it)) }, - ), - ) - if (swipe.enabled) { - add(swipeRow(viewModel, SwipeSide.RIGHT, swipe.right)) - add(swipeRow(viewModel, SwipeSide.LEFT, swipe.left)) - } - }, - ) - - @Composable - private fun swipeRow(viewModel: SettingsViewModel, side: SwipeSide, action: SwipeAction) = SettingsItem.Selection( - titleRes = if (side == SwipeSide.RIGHT) R.string.settings_swipe_right else R.string.settings_swipe_left, - currentValue = stringResource(action.choiceLabel()), - action = { viewModel.chooseSwipeAction(side) }, - icon = if (side == SwipeSide.RIGHT) EnclyIcons.Chevron else EnclyIcons.Back, - ) - - /** A section holding one navigation row. */ - private fun single( - @StringRes section: Int, - @StringRes title: Int, - @StringRes subtitle: Int, - icon: ImageVector, - action: () -> Unit, - ) = SettingsCategory( - titleRes = section, - items = listOf(SettingsItem.Navigation(titleRes = title, subtitleRes = subtitle, action = action, icon = icon)), - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsCategory.kt b/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsCategory.kt deleted file mode 100644 index 6a86dee7..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsCategory.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.pasich.encly.presentation.components.settings - -import androidx.annotation.StringRes -import androidx.compose.ui.graphics.vector.ImageVector - -data class SettingsCategory(@param:StringRes val titleRes: Int, val items: List) - -sealed class SettingsItem( - @param:StringRes open val titleRes: Int, - @param:StringRes open val subtitleRes: Int? = null, - open val isEnabled: Boolean = true, - open val requiresValidation: Boolean = false, - /** Leading icon of the row, in `primary`. */ - open val icon: ImageVector? = null, -) { - data class Switch( - @param:StringRes override val titleRes: Int, - @param:StringRes override val subtitleRes: Int? = null, - val checked: Boolean, - val onCheckedChange: (Boolean) -> Unit, - override val isEnabled: Boolean = true, - override val requiresValidation: Boolean = false, - val validationMessage: String? = null, - override val icon: ImageVector? = null, - ) : SettingsItem(titleRes, subtitleRes, isEnabled, requiresValidation, icon) - - data class Navigation( - @param:StringRes override val titleRes: Int, - @param:StringRes override val subtitleRes: Int? = null, - val action: () -> Unit, - override val isEnabled: Boolean = true, - override val icon: ImageVector? = null, - ) : SettingsItem(titleRes, subtitleRes, isEnabled, icon = icon) - - data class Selection( - @param:StringRes override val titleRes: Int, - @param:StringRes override val subtitleRes: Int? = null, - val currentValue: String, - val action: () -> Unit, - override val isEnabled: Boolean = true, - override val icon: ImageVector? = null, - ) : SettingsItem(titleRes, subtitleRes, isEnabled, icon = icon) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/swipe/SwipeLabels.kt b/app/src/main/java/com/pasich/encly/presentation/components/swipe/SwipeLabels.kt deleted file mode 100644 index d1f4f08b..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/swipe/SwipeLabels.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.pasich.encly.presentation.components.swipe - -import androidx.annotation.StringRes -import com.pasich.encly.R -import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.model.SwipeAction - -/** The name of [this] action in the settings choice. */ -@StringRes -fun SwipeAction.choiceLabel(): Int = when (this) { - SwipeAction.PIN -> R.string.swipe_choice_pin - SwipeAction.ARCHIVE -> R.string.note_archive - SwipeAction.NEXT_COLOR -> R.string.swipe_choice_color - SwipeAction.DELETE -> R.string.swipe_choice_delete - SwipeAction.NONE -> R.string.swipe_choice_none -} - -/** What [this] action does to [note] right now, as a label ("Unpin" for a pinned note). */ -@StringRes -fun SwipeAction.noteLabel(note: Note): Int = when (this) { - SwipeAction.PIN -> if (note.isPinned) R.string.note_unpin else R.string.note_pin - SwipeAction.ARCHIVE -> if (note.isArchived) R.string.note_unarchive else R.string.note_archive - else -> choiceLabel() -} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tasks/PriorityValues.kt b/app/src/main/java/com/pasich/encly/presentation/components/tasks/PriorityValues.kt deleted file mode 100644 index f73b5d53..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/tasks/PriorityValues.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.pasich.encly.presentation.components.tasks - -import androidx.annotation.StringRes -import com.pasich.encly.R - -/** A task priority: its stored [id] and label. [emphasis] marks the one shown in `error` (High). */ -data class PriorityData(val id: Int, @param:StringRes val label: Int, val emphasis: Boolean = false) - -object PriorityValues { - - /** Highest first, the order the widget and the editor sheet list them in. */ - val priorities = listOf( - PriorityData(id = 2, label = R.string.priority_high, emphasis = true), - PriorityData(id = 1, label = R.string.priority_medium), - PriorityData(id = 0, label = R.string.priority_low), - ) - - fun getById(id: Int): PriorityData = priorities.firstOrNull { it.id == id } ?: priorities.last() -} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tiles/NoteItem.kt b/app/src/main/java/com/pasich/encly/presentation/components/tiles/NoteItem.kt deleted file mode 100644 index 3cbd0ab8..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/tiles/NoteItem.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.pasich.encly.presentation.components.tiles - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.model.NoteColor -import com.pasich.encly.domain.model.NoteListItem -import com.pasich.encly.presentation.designsystem.EnclyNoteCard -import com.pasich.encly.presentation.designsystem.NoteCardMeta -import com.pasich.encly.presentation.effects.ContainerEnd -import com.pasich.encly.presentation.effects.rememberNoteContainerModifier -import com.pasich.encly.utils.NotesTextFormatter -import com.pasich.encly.utils.formatCardDate -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.Date - -/** - * A note in a list: the design-system note card with its tag and date. Long-press opens the - * note's actions. [dimmed] is the trash look; a checked note ([Note.isChecked]) is shown selected. - */ -@Composable -fun NoteItem( - onItemClick: (Note) -> Unit, - onItemLongClick: (Note) -> Unit, - modifier: Modifier = Modifier, - item: NoteListItem? = null, - itemNote: Note? = null, - excerptLines: Int = 2, - highlight: String = "", - dimmed: Boolean = false, -) { - val note: Note = item?.note ?: itemNote ?: Note() - val preview = rememberNotePreview(item, note) ?: stringResource(R.string.note_preview_unreadable) - val hapticFeedback = LocalHapticFeedback.current - - EnclyNoteCard( - title = note.title, - excerpt = preview, - meta = NoteCardMeta( - tag = item?.tag?.nameTag, - date = note.date.takeIf { it > 0L }?.let { formatCardDate(Date(it)) }, - ), - excerptLines = excerptLines, - highlight = highlight, - selected = note.isChecked, - dimmed = dimmed, - pinned = note.isPinned && !dimmed, - noteColor = if (dimmed) NoteColor.NONE else NoteColor.fromKey(note.color), - onClick = { onItemClick(note) }, - onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - onItemLongClick(note) - }, - // The trash opens its notes read-only, with the plain screen motion: no card to grow. - modifier = modifier.then(rememberNoteContainerModifier(if (dimmed) 0L else note.id, ContainerEnd.CARD)), - ) -} - -/** - * The note list passes a preview built off the main thread; a bare note (the trash) is parsed - * here, also off the main thread. Null means unreadable content. - */ -@Composable -private fun rememberNotePreview(item: NoteListItem?, note: Note): String? { - if (item != null) return item.preview - val parsed by produceState(initialValue = note.description, note.description, note.value) { - value = withContext(Dispatchers.Default) { - note.description.ifEmpty { NotesTextFormatter.jsonToPlainText(note.value) } - } - } - return parsed -} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt b/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt deleted file mode 100644 index b268ae4f..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.pasich.encly.presentation.components.tiles - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.CustomAccessibilityAction -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import com.pasich.encly.R -import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.Task -import com.pasich.encly.presentation.components.tasks.NextStepLeaf -import com.pasich.encly.presentation.components.tasks.PriorityValues -import com.pasich.encly.presentation.components.tasks.SubtaskTree -import com.pasich.encly.presentation.components.tasks.SubtaskTreeActions -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyRowIconButton -import com.pasich.encly.presentation.designsystem.EnclySubtaskTree -import com.pasich.encly.presentation.designsystem.EnclyTaskRow -import com.pasich.encly.presentation.viewmodel.InlineSubtaskEdit -import com.pasich.encly.utils.rememberDateTimeFormat -import kotlinx.coroutines.delay -import java.util.Date - -private const val TASK_REMOVAL_ANIMATION_MS = 400 -private const val TASK_TRANSLATION_X = 100f - -/** - * What a task's block on the Tasks list can do; the screen builds it once from its ViewModel. - * A tap on the row opens or folds its sub-task tree ([SubtaskTreeActions.onToggleTree]). - */ -@Immutable -class TaskItemActions( - val onToggleTask: (taskId: Long, completed: Boolean) -> Unit, - /** The pencil: opens the task sheet, the only way to it from the list. */ - val onEdit: (Task) -> Unit, - val tree: SubtaskTreeActions, -) - -private data class TaskCardState(val enabled: Boolean, val isRemoving: Boolean, val animationProgress: Float) - -/** - * A task and its sub-tasks. Folded: an open task shows its next step as one leaf - * ([NextStepLeaf]) and every task with sub-tasks a segment bar of their progress; a task - * without sub-tasks looks like a plain task. [expanded]: the whole tree ([SubtaskTree]). A tap on - * the row opens or folds it; the pencil opens the task sheet. The block is one list item, so it - * fades out as a whole when the task is completed. [inlineEdit] is the list's inline sub-task - * field when it belongs to this task. - */ -@Composable -fun TaskItem( - task: Task, - subtasks: List, - expanded: Boolean, - actions: TaskItemActions, - modifier: Modifier = Modifier, - inlineEdit: InlineSubtaskEdit? = null, - enabled: Boolean = true, -) { - var isRemoving by remember(task.id) { mutableStateOf(false) } - var shouldComplete by remember(task.id) { mutableStateOf(false) } - val animationProgress by animateFloatAsState( - targetValue = if (isRemoving) 0f else 1f, - animationSpec = tween(durationMillis = TASK_REMOVAL_ANIMATION_MS), - label = "task_removal_animation", - ) - - CompleteTaskAfterAnimation( - task = task, - shouldComplete = shouldComplete, - onRemovingChange = { isRemoving = it }, - onCompleteChange = { shouldComplete = it }, - onTaskToggle = actions.onToggleTask, - ) - - val state = TaskCardState( - enabled = enabled, - isRemoving = isRemoving, - animationProgress = animationProgress, - ) - val tree = updateTransition(targetState = expanded, label = "subtasks") - val enabledNow = state.enabled && !state.isRemoving - // Done tasks show only the bar while folded. - val showsNextStep = !task.isCompleted && subtasks.isNotEmpty() - Column( - modifier = modifier.graphicsLayer { - alpha = state.animationProgress - scaleX = state.animationProgress - scaleY = state.animationProgress - translationX = (1f - state.animationProgress) * TASK_TRANSLATION_X - }, - ) { - TaskRow( - task = task, - subtasks = subtasks, - expanded = expanded, - // The trunk stays drawn until the tree has fully folded. - connectorBelow = showsNextStep || tree.currentState || tree.targetState, - enabled = enabledNow, - actions = actions, - onComplete = { shouldComplete = true }, - ) - EnclySubtaskTree( - expanded = tree, - collapsed = { - if (showsNextStep) NextStepLeaf(task.id, subtasks, actions.tree, enabled = enabledNow) - }, - ) { - SubtaskTree( - taskId = task.id, - subtasks = subtasks, - edit = inlineEdit, - actions = actions.tree, - dimmed = task.isCompleted, - enabled = enabledNow, - ) - } - } -} - -@Composable -private fun CompleteTaskAfterAnimation( - task: Task, - shouldComplete: Boolean, - onRemovingChange: (Boolean) -> Unit, - onCompleteChange: (Boolean) -> Unit, - onTaskToggle: (Long, Boolean) -> Unit, -) { - // The effect outlives recompositions (it restarts only on shouldComplete); always call the - // latest callbacks. - val currentOnRemovingChange by rememberUpdatedState(onRemovingChange) - val currentOnCompleteChange by rememberUpdatedState(onCompleteChange) - val currentOnTaskToggle by rememberUpdatedState(onTaskToggle) - LaunchedEffect(shouldComplete) { - if (!shouldComplete) return@LaunchedEffect - currentOnRemovingChange(true) - delay(TASK_REMOVAL_ANIMATION_MS.toLong()) - currentOnTaskToggle(task.id, true) - currentOnCompleteChange(false) - currentOnRemovingChange(false) - } -} - -/** - * The task's own row: checkbox (completes it after the removal animation, or reopens it), the - * text with the segment bar of its sub-tasks, and the pencil. A tap elsewhere opens or folds the - * tree. For TalkBack the row is one node that reads the title, priority and how many sub-tasks - * are done, with Expand/Collapse, Edit and Add sub-task as actions. - */ -@Suppress("LongParameterList", "LongMethod") // The row's state, callbacks and semantics, split out of TaskItem. -@Composable -private fun TaskRow( - task: Task, - subtasks: List, - expanded: Boolean, - connectorBelow: Boolean, - enabled: Boolean, - actions: TaskItemActions, - onComplete: () -> Unit, -) { - val dateFormat = rememberDateTimeFormat() - val focusManager = LocalFocusManager.current - val priority = PriorityValues.getById(task.priority) - val completedAt = task.completedDate?.takeIf { task.isCompleted }?.let { - stringResource(R.string.task_completed_at, dateFormat.format(Date(it))) - } - val done = subtasks.count { it.isCompleted } - val summary = pluralStringResource(R.plurals.subtasks_done_summary, subtasks.size, done, subtasks.size) - .takeIf { subtasks.isNotEmpty() } - val toggleLabel = stringResource(if (expanded) R.string.subtasks_hide else R.string.subtasks_show) - val stateLabel = stringResource(if (expanded) R.string.subtasks_state_shown else R.string.subtasks_state_hidden) - val editLabel = stringResource(R.string.task_edit_placeholder) - val addLabel = stringResource(R.string.subtask_add) - val toggle = { actions.tree.onToggleTree(task.id) } - EnclyTaskRow( - title = task.title, - checked = task.isCompleted, - onCheckedChange = { checked -> - // Saves and closes an inline sub-task field first, as any other tap on the list does. - focusManager.clearFocus() - when { - checked && !task.isCompleted -> onComplete() - !checked && task.isCompleted -> actions.onToggleTask(task.id, false) - } - }, - description = task.description, - meta = completedAt, - priority = stringResource(priority.label).takeIf { !task.isCompleted }, - priorityEmphasis = priority.emphasis, - large = true, - enabled = enabled, - // Done tasks open too: their sub-tasks can still be ticked, renamed and added. - onClick = toggle, - onClickLabel = toggleLabel, - connectorBelow = connectorBelow, - progress = subtasks.map { it.isCompleted }.takeIf { it.isNotEmpty() }, - modifier = Modifier.semantics { - stateDescription = listOfNotNull(summary, stateLabel).joinToString(", ") - customActions = listOf( - CustomAccessibilityAction(toggleLabel) { - toggle() - true - }, - CustomAccessibilityAction(editLabel) { - actions.onEdit(task) - true - }, - CustomAccessibilityAction(addLabel) { - actions.tree.onStartAdding(task.id) - true - }, - ) - }, - ) { - EnclyRowIconButton( - icon = EnclyIcons.Edit, - contentDescription = editLabel, - onClick = { actions.onEdit(task) }, - enabled = enabled, - ) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Layout.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/Layout.kt deleted file mode 100644 index cb557833..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Layout.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.pasich.encly.presentation.designsystem - -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.Dp - -/** - * Widens the element by [horizontal] on both sides, past its parent's padding: a full-width row - * (the search field and tag chips) inside a padded grid still runs to the screen edges. - */ -fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints -> - val extra = horizontal.roundToPx() * 2 - val width = constraints.maxWidth + extra - val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width)) - layout(constraints.maxWidth, placeable.height) { - placeable.place(-extra / 2, 0) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteCard.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteCard.kt deleted file mode 100644 index 96a2d175..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteCard.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.pasich.encly.presentation.designsystem - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.pasich.encly.R -import com.pasich.encly.domain.model.NoteColor -import com.pasich.encly.ui.theme.EnclyTheme -import com.pasich.encly.ui.theme.noteColorStyle - -/** - * Title opacity of a card in the trash (design spec §4.6). Only the title fades: the excerpt and - * meta line are already muted (`onSurfaceVariant`) and faded too would drop below 4.5:1. - */ -const val DIMMED_CARD_ALPHA = 0.7f - -private val MetaIcon = 14.dp - -/** The meta line of a note card: tag, relative date and checklist progress; each is optional. */ -@Immutable -data class NoteCardMeta(val tag: String? = null, val date: String? = null, val progress: String? = null) - -/** - * A note card (design spec §3.3): `surfaceContainer`, radius 16, padding 16, no border or shadow. - * Title in titleMedium, excerpt in bodyMedium `onSurfaceVariant` cut at [excerptLines], then the - * meta line. [highlight] marks search matches. A [noteColor] tints the card and adds an accent edge. - * [selected] draws a 2 dp `primary` ring and a check; [dimmed] fades the title (trash), keeping every - * text at 4.5:1 or more. A [pinned] note shows a small pin after its title. - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun EnclyNoteCard( - title: String?, - excerpt: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - meta: NoteCardMeta = NoteCardMeta(), - excerptLines: Int = 2, - highlight: String = "", - selected: Boolean = false, - dimmed: Boolean = false, - pinned: Boolean = false, - noteColor: NoteColor = NoteColor.NONE, -) { - val colors = MaterialTheme.colorScheme - val tint = remember(noteColor, colors) { noteColorStyle(noteColor, colors) } - val muted = tint?.onCardVariant ?: colors.onSurfaceVariant - Surface( - shape = MaterialTheme.shapes.medium, - color = tint?.card ?: colors.surfaceContainer, - contentColor = colors.onSurface, - border = if (selected) BorderStroke(EnclyTheme.spacing.stroke, colors.primary) else null, - modifier = modifier - .fillMaxWidth() - .semantics { this.selected = selected } - .combinedClickable(onClick = onClick, onLongClick = onLongClick), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.labelGap), - modifier = Modifier - .then(if (tint != null) Modifier.accentEdge(tint.accent, EnclyTheme.spacing.xxs) else Modifier) - .padding(EnclyTheme.spacing.m), - ) { - if (!title.isNullOrEmpty() || selected || pinned) { - NoteTitleRow( - title = title.orEmpty(), - highlight = highlight, - state = TitleState(dimmed = dimmed, pinned = pinned, selected = selected, muted = muted), - ) - } - if (excerpt.isNotEmpty()) { - HighlightedText( - text = excerpt, - searchQuery = highlight, - style = MaterialTheme.typography.bodyMedium, - color = muted, - maxLines = excerptLines, - ) - } - if (meta != NoteCardMeta()) NoteMetaLine(meta, muted) - } - } -} - -/** What a card's title row marks besides the title. */ -private class TitleState(val dimmed: Boolean, val pinned: Boolean, val selected: Boolean, val muted: Color) - -/** The title, then the pin of a pinned note and the check of a selected one. */ -@Composable -private fun NoteTitleRow(title: String, highlight: String, state: TitleState) { - val colors = MaterialTheme.colorScheme - Row(verticalAlignment = Alignment.CenterVertically) { - HighlightedText( - text = title, - searchQuery = highlight, - style = MaterialTheme.typography.titleMedium, - color = if (state.dimmed) colors.onSurface.copy(alpha = DIMMED_CARD_ALPHA) else colors.onSurface, - maxLines = 2, - modifier = Modifier.weight(1f), - ) - if (state.pinned) { - Icon( - EnclyIcons.Pin, - contentDescription = stringResource(R.string.notes_section_pinned), - tint = state.muted, - modifier = Modifier.size(EnclyTheme.spacing.iconSmall), - ) - } - if (state.selected) { - Icon( - EnclyIcons.CheckCircle, - contentDescription = null, - tint = colors.primary, - modifier = Modifier.size(EnclyTheme.spacing.iconSmall), - ) - } - } -} - -@Composable -private fun NoteMetaLine(meta: NoteCardMeta, muted: Color) { - val style = EnclyTheme.typography.meta - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), - modifier = Modifier - .fillMaxWidth() - .padding(top = EnclyTheme.spacing.xxs), - ) { - meta.tag?.let { MetaItem(icon = EnclyIcons.Tag, text = it, color = muted) } - meta.date?.let { Text(text = it, style = style, color = muted, maxLines = 1) } - Spacer(modifier = Modifier.weight(1f)) - meta.progress?.let { - MetaItem( - icon = EnclyIcons.Checklist, - text = it, - color = MaterialTheme.colorScheme.primary, - weight = FontWeight.SemiBold, - ) - } - } -} - -@Composable -private fun MetaItem(icon: ImageVector, text: String, color: Color, weight: FontWeight? = null) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), - ) { - Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(MetaIcon)) - Text( - text = text, - style = EnclyTheme.typography.meta.copy(fontWeight = weight ?: EnclyTheme.typography.meta.fontWeight), - color = color, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -/** - * The left edge of a coloured note card: a [width]-wide strip of [color] the full height of the - * card. The card's own shape clips it, so its ends follow the rounded corners. - */ -private fun Modifier.accentEdge(color: Color, width: Dp): Modifier = drawBehind { - drawRect(color = color, size = Size(width.toPx(), size.height)) -} - -/** A small tag label (e.g. on the Appearance preview): `primaryContainer`, radius 12. */ -@Composable -fun EnclyTagPill(text: String, modifier: Modifier = Modifier) { - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = modifier, - ) { - Text( - text = text, - style = EnclyTheme.typography.meta.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 10.dp, vertical = EnclyTheme.spacing.textGap), - ) - } -} 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 deleted file mode 100644 index 8f065081..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteSpacing.kt +++ /dev/null @@ -1,74 +0,0 @@ -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 androidx.compose.ui.unit.sp -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 const val DEFAULT_LINE_FACTOR = 1.5f -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. A text larger than the theme's body grows its line - * with it (1.5× the size, the theme's own ratio at 16sp), so big text never overruns its line. - */ -internal fun NoteLineSpacing.lineHeight(fontSize: TextUnit, normal: TextUnit): TextUnit { - val default = maxOf(normal.value, fontSize.value * DEFAULT_LINE_FACTOR).sp - return when (this) { - NoteLineSpacing.COMPACT -> fontSize - NoteLineSpacing.DEFAULT -> default - NoteLineSpacing.COMFORTABLE -> default * 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 -} - -// A list's marker follows the text: the checkbox and the gap after it grow with the font size, and -// bullets and numbers use the same column, so every kind of list lines its text up the same way. -private const val MARKER_BOX_PER_FONT = 1.1f -private const val MARKER_GAP_PER_FONT = 0.5f -private val MARKER_BOX_MIN = 16.dp -private val MARKER_BOX_MAX = 28.dp -private val MARKER_GAP_MIN = 6.dp -private val MARKER_GAP_MAX = 14.dp - -/** The checkbox side for list text of [font] size: 1.1 times it, from 16 to 28 dp. */ -internal fun markerBoxSize(font: Dp): Dp = (font * MARKER_BOX_PER_FONT).coerceIn(MARKER_BOX_MIN, MARKER_BOX_MAX) - -/** The gap between the marker and the text for list text of [font] size: half of it, from 6 to 14 dp. */ -internal fun markerGap(font: Dp): Dp = (font * MARKER_GAP_PER_FONT).coerceIn(MARKER_GAP_MIN, MARKER_GAP_MAX) - -/** The marker column of a list: the box and the gap after it. */ -internal fun markerColumn(font: Dp): Dp = markerBoxSize(font) + markerGap(font) diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Pin.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/Pin.kt deleted file mode 100644 index 657aa9f1..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Pin.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.pasich.encly.presentation.designsystem - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.border -import androidx.compose.foundation.combinedClickable -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -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.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import com.pasich.encly.R -import com.pasich.encly.ui.theme.EnclyTheme - -/** PIN length of the vault. */ -const val PIN_LENGTH = 6 - -/** Keys above the bottom row, row by row. */ -private const val KEYPAD_DIGITS = "123456789" -private const val KEYPAD_COLUMNS = 3 - -/** A wrong PIN shakes the dots three times, 8 dp each way, in 300 ms. */ -private const val SHAKE_CYCLES = 3 -private const val SHAKE_DP = 8f -private const val SHAKE_QUARTER_MS = 25 - -/** - * The PIN progress dots: filled `primary` for entered digits, a 2 dp `outline` ring otherwise. - * [error] turns every dot `error`; each new [shakeKey] above zero shakes them once. - */ -@Composable -fun PinDots( - entered: Int, - modifier: Modifier = Modifier, - length: Int = PIN_LENGTH, - error: Boolean = false, - shakeKey: Int = 0, -) { - val colors = MaterialTheme.colorScheme - val description = stringResource(R.string.pin_digits_entered, entered, length) - val shake = remember { Animatable(0f) } - LaunchedEffect(shakeKey) { - if (shakeKey == 0) return@LaunchedEffect - repeat(SHAKE_CYCLES) { - shake.animateTo(SHAKE_DP, tween(SHAKE_QUARTER_MS)) - shake.animateTo(-SHAKE_DP, tween(SHAKE_QUARTER_MS * 2)) - shake.animateTo(0f, tween(SHAKE_QUARTER_MS)) - } - } - Row( - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.m, Alignment.CenterHorizontally), - modifier = modifier - .fillMaxWidth() - .offset { IntOffset(shake.value.dp.roundToPx(), 0) } - .semantics { contentDescription = description }, - ) { - repeat(length) { index -> - val filled = index < entered - val tint = if (error) colors.error else colors.primary - Box( - modifier = Modifier - .size(14.dp) - .border( - 2.dp, - if (filled) { - tint - } else if (error) { - colors.error - } else { - colors.outline - }, - CircleShape, - ), - ) { - if (filled) Surface(shape = CircleShape, color = tint, modifier = Modifier.size(14.dp)) {} - } - } - } -} - -/** Keypad sizes: onboarding setup (56 dp keys, 10 dp gap) and the lock screen (64 dp, 12 dp). */ -enum class KeypadSize(val keyHeight: Dp, val gap: Dp) { - SETUP(56.dp, 10.dp), - LOCK(64.dp, 12.dp), -} - -/** - * The PIN keypad: a 3-column grid of round `surfaceContainer` keys, bottom row - * [biometric] [0] [backspace]. [onBiometric] null leaves the bottom-left slot empty. A long press on - * backspace clears every digit ([onClear]; by default delete repeated for a whole PIN) with a haptic tick. - */ -@Composable -fun PinKeypad( - onDigit: (Int) -> Unit, - onBackspace: () -> Unit, - modifier: Modifier = Modifier, - size: KeypadSize = KeypadSize.SETUP, - enabled: Boolean = true, - onBiometric: (() -> Unit)? = null, - onClear: (() -> Unit)? = null, -) { - val haptic = LocalHapticFeedback.current - Column( - verticalArrangement = Arrangement.spacedBy(size.gap), - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 40.dp), - ) { - KEYPAD_DIGITS.chunked(KEYPAD_COLUMNS).forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(size.gap)) { - row.map { it.digitToInt() }.forEach { digit -> - DigitKey(digit, size, enabled, { onDigit(digit) }, Modifier.weight(1f)) - } - } - } - Row(horizontalArrangement = Arrangement.spacedBy(size.gap)) { - if (onBiometric != null) { - Key( - onClick = onBiometric, - size = size, - enabled = enabled, - container = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = EnclyIcons.Fingerprint, - contentDescription = stringResource(R.string.pin_unlock_with_fingerprint), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(28.dp), - ) - } - } else { - Box(modifier = Modifier.weight(1f)) - } - DigitKey(0, size, enabled, { onDigit(0) }, Modifier.weight(1f)) - Key( - onClick = onBackspace, - size = size, - enabled = enabled, - container = Color.Transparent, - modifier = Modifier.weight(1f), - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - // No clear handler: delete once per digit a PIN can hold. - onClear?.invoke() ?: repeat(PIN_LENGTH) { onBackspace() } - }, - ) { - Icon( - imageVector = EnclyIcons.Backspace, - contentDescription = stringResource(R.string.pin_delete_digit), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(26.dp), - ) - } - } - } -} - -@Composable -private fun DigitKey( - digit: Int, - size: KeypadSize, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Key( - onClick = onClick, - size = size, - enabled = enabled, - container = MaterialTheme.colorScheme.surfaceContainer, - modifier = modifier, - ) { - Text(text = digit.toString(), style = EnclyTheme.typography.keypadDigit) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun Key( - onClick: () -> Unit, - size: KeypadSize, - enabled: Boolean, - container: Color, - modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - content: @Composable () -> Unit, -) { - val color = if (enabled) container else container.copy(alpha = container.alpha * DISABLED_CONTENT_ALPHA) - val contentColor = MaterialTheme.colorScheme.onSurface.let { if (enabled) it else it.disabled() } - Surface( - shape = CircleShape, - color = color, - contentColor = contentColor, - modifier = modifier.height(size.keyHeight), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.combinedClickable( - enabled = enabled, - role = Role.Button, - onClick = onClick, - onLongClick = onLongClick, - ), - ) { content() } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/di/EditorModule.kt b/app/src/main/java/com/pasich/encly/presentation/di/EditorModule.kt deleted file mode 100644 index 65bd8881..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/di/EditorModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.pasich.encly.presentation.di - -import com.pasich.encly.R -import com.pasich.encly.core.common.AppStrings -import com.pasich.encly.presentation.editor.persistence.NoteCopyTitle -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -object EditorModule { - - @Provides - fun provideNoteCopyTitle(strings: AppStrings): NoteCopyTitle = NoteCopyTitle { title -> - strings.get(R.string.note_copy_title, title.ifBlank { strings.get(R.string.untitled) }) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/ConfirmDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/ConfirmDialog.kt deleted file mode 100644 index a75fc1ab..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/ConfirmDialog.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog - -/** A yes/no confirmation; [destructive] shows the confirm action in `error`. */ -@Composable -fun ConfirmDialog( - isVisible: Boolean, - titleText: String, - messageText: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit, - destructive: Boolean = false, -) { - if (isVisible) { - EnclyDialog( - title = titleText, - text = messageText, - onDismissRequest = onDismiss, - confirm = DialogAction(stringResource(R.string.dialog_confirm), onConfirm, destructive = destructive), - dismiss = DialogAction(stringResource(R.string.dialog_dismiss), onDismiss), - ) - } -} 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 deleted file mode 100644 index 573f82f2..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.pasich.encly.R -import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.model.NoteColor -import com.pasich.encly.presentation.components.FontSizeSlider -import com.pasich.encly.presentation.components.NoteLineSpacingSlider -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.EnclyNoteColorPicker -import com.pasich.encly.presentation.designsystem.EnclySheetRow -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.designsystem.bleed -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel -import com.pasich.encly.ui.theme.EnclyTheme - -enum class EditNoteBottomSheetAction { - CLOSE_NO_SAVE, - DUPLICATE, - FIND, - TRASH, - EXPORT_MARKDOWN, - COPY_MARKDOWN, - ARCHIVE, - UNARCHIVE, -} - -/** - * The editor's "More actions" sheet: text size, then archive, find, duplicate, discard changes and delete. The - * font itself is chosen app-wide in Settings, Appearance. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditNoteBottomSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), - onAction: (EditNoteBottomSheetAction) -> Unit = {}, -) { - val fontSize by viewModel.fontSize.collectAsStateWithLifecycle() - val lineSpacing by viewModel.noteLineSpacing.collectAsStateWithLifecycle() - val note by viewModel.state.collectAsStateWithLifecycle() - val override by viewModel.textOverride.collectAsStateWithLifecycle() - val loadFailed by viewModel.contentLoadFailed.collectAsStateWithLifecycle() - - if (isVisible) { - EnclyBottomSheet(onDismissRequest = onDismiss, modifier = modifier, scrollable = true) { - FontSizeSlider( - currentSize = fontSize, - onSizeChange = { newSize -> viewModel.updateFontSize(newSize) }, - ) - NoteLineSpacingSlider(selected = lineSpacing, onSelect = viewModel::updateNoteLineSpacing) - UseDefaultRow(visible = override.isSet, onClick = viewModel::useDefaultText) - EnclyGroupDivider() - SectionOverline(stringResource(R.string.note_color_title)) - EnclyNoteColorPicker( - selected = NoteColor.fromKey(note.note.color), - onSelect = viewModel::updateColor, - modifier = Modifier.bleed(EnclyTheme.spacing.gutter), - contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter), - ) - ArchiveRow(note = note.note, onAction = onAction, onDismiss = onDismiss) - EditorActionRows(canCopy = !loadFailed, onAction = onAction, onDismiss = onDismiss) - } - } -} - -/** - * Find, copy, export, duplicate, discard and delete: each runs its action and closes the sheet. - * Copy as Markdown is hidden for a note whose content could not be read, like the export's no-op. - */ -@Composable -private fun EditorActionRows(canCopy: Boolean, onAction: (EditNoteBottomSheetAction) -> Unit, onDismiss: () -> Unit) { - fun run(action: EditNoteBottomSheetAction) { - onAction(action) - onDismiss() - } - EnclySheetRow( - title = stringResource(R.string.find_in_note), - icon = EnclyIcons.Search, - onClick = { run(EditNoteBottomSheetAction.FIND) }, - ) - EnclySheetRow( - title = stringResource(R.string.markdown_export), - icon = EnclyIcons.Upload, - onClick = { run(EditNoteBottomSheetAction.EXPORT_MARKDOWN) }, - ) - if (canCopy) { - EnclySheetRow( - title = stringResource(R.string.markdown_copy), - icon = EnclyIcons.Copy, - onClick = { run(EditNoteBottomSheetAction.COPY_MARKDOWN) }, - ) - } - EnclySheetRow( - title = stringResource(id = R.string.duplicate), - icon = EnclyIcons.Duplicate, - onClick = { run(EditNoteBottomSheetAction.DUPLICATE) }, - ) - EnclySheetRow( - title = stringResource(R.string.note_discard_all_changes), - icon = EnclyIcons.Undo, - onClick = { run(EditNoteBottomSheetAction.CLOSE_NO_SAVE) }, - ) - EnclySheetRow( - title = stringResource(id = R.string.delete), - icon = EnclyIcons.Trash, - destructive = true, - confirmFirst = true, - onClick = { run(EditNoteBottomSheetAction.TRASH) }, - ) -} - -/** Archive, or unarchive. Pinning is a list matter: it lives in the notes list's sheet only. */ -@Composable -private fun ArchiveRow(note: Note, onAction: (EditNoteBottomSheetAction) -> Unit, onDismiss: () -> Unit) { - EnclySheetRow( - title = stringResource(if (note.isArchived) R.string.note_unarchive else R.string.note_archive), - icon = if (note.isArchived) EnclyIcons.Unarchive else EnclyIcons.Archive, - onClick = { - onAction( - if (note.isArchived) EditNoteBottomSheetAction.UNARCHIVE else EditNoteBottomSheetAction.ARCHIVE, - ) - onDismiss() - }, - ) -} - -/** "Use default": shown while the note sets its own text size or line spacing. */ -@Composable -private fun UseDefaultRow(visible: Boolean, onClick: () -> Unit) { - if (!visible) return - EnclySheetRow(title = stringResource(R.string.note_text_use_default), icon = EnclyIcons.Undo, onClick = onClick) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/LanguageDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/LanguageDialog.kt deleted file mode 100644 index 6d25e7be..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/LanguageDialog.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.core.locale.AppLanguage -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog -import com.pasich.encly.presentation.designsystem.EnclyRadioRow - -/** - * Picks the in-app language. Every language is listed under its own name; only - * "System default" is shown in the current UI language. - */ -@Composable -fun LanguageDialog( - current: AppLanguage, - onDismiss: () -> Unit, - onConfirm: (AppLanguage) -> Unit, - modifier: Modifier = Modifier, -) { - var selected by rememberSaveable { mutableStateOf(current) } - - EnclyDialog( - title = stringResource(R.string.language_dialog_title), - onDismissRequest = onDismiss, - modifier = modifier, - confirm = DialogAction(stringResource(R.string.done), { onConfirm(selected) }), - dismiss = DialogAction(stringResource(R.string.cancel), onDismiss), - ) { - LazyColumn(modifier = Modifier.selectableGroup()) { - items(AppLanguage.entries) { language -> - EnclyRadioRow( - title = stringResource(language.nativeName), - selected = language == selected, - onClick = { selected = language }, - ) - } - } - } -} 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 deleted file mode 100644 index aefd4feb..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.animation.AnimatedVisibility -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.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetState -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -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.layout.layout -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 com.pasich.encly.R -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag -import com.pasich.encly.domain.model.NoteColor -import com.pasich.encly.presentation.designsystem.EnclyBottomSheet -import com.pasich.encly.presentation.designsystem.EnclyButton -import com.pasich.encly.presentation.designsystem.EnclyChip -import com.pasich.encly.presentation.designsystem.EnclyGroupDivider -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyNoteColorPicker -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.designsystem.SectionOverline -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 - -sealed class NoteAction { - object Edit : NoteAction() - object Duplicate : NoteAction() - object Delete : NoteAction() - data class Pin(val pinned: Boolean) : NoteAction() - data class Archive(val archived: Boolean) : NoteAction() - data class ChangeColor(val color: NoteColor) : NoteAction() - data class ChangeTag(val tagId: Long) : NoteAction() - data class ChangeDescription(val description: String) : NoteAction() -} - -/** - * Long-press actions for a note. Every block sits on the sheet's gutter, so the title, the tag chips, - * the description and the rows share one left edge. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NoteCardBottomSheet( - isVisible: Boolean, - item: NoteWithTag, - onAction: (NoteAction) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), -) { - // Per note and per opening: a dismissed sheet never reopens mid-edit. - var isEditingDescription by remember(item.note.id, isVisible) { mutableStateOf(false) } - - if (isVisible) { - EnclyBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - modifier = modifier, - scrollable = true, - ) { - NoteActionHeader(item) - TagChips(selectedTagId = item.note.tagId ?: 0L, onSelect = { onAction(NoteAction.ChangeTag(it)) }) - ColorChoices( - selected = NoteColor.fromKey(item.note.color), - onSelect = { onAction(NoteAction.ChangeColor(it)) }, - ) - EnclyGroupDivider() - DescriptionEditor( - initial = item.note.description, - editing = isEditingDescription, - onEditingChange = { isEditingDescription = it }, - onSave = { onAction(NoteAction.ChangeDescription(it)) }, - ) - AnimatedVisibility(!isEditingDescription) { NoteActionRows(item.note, onAction) } - } - } -} - -/** Edit, pin, archive, duplicate and delete; a pinned note offers Unpin, an archived one Unarchive and no pin. */ -@Composable -private fun NoteActionRows(note: Note, onAction: (NoteAction) -> Unit) { - Column { - EnclySheetRow( - title = stringResource(id = R.string.edit), - icon = EnclyIcons.Edit, - onClick = { onAction(NoteAction.Edit) }, - ) - if (!note.isArchived) { - EnclySheetRow( - title = stringResource(if (note.isPinned) R.string.note_unpin else R.string.note_pin), - icon = if (note.isPinned) EnclyIcons.PinOff else EnclyIcons.Pin, - onClick = { onAction(NoteAction.Pin(!note.isPinned)) }, - ) - } - EnclySheetRow( - title = stringResource( - if (note.isArchived) R.string.note_unarchive else R.string.note_archive, - ), - icon = if (note.isArchived) EnclyIcons.Unarchive else EnclyIcons.Archive, - onClick = { onAction(NoteAction.Archive(!note.isArchived)) }, - ) - EnclySheetRow( - title = stringResource(id = R.string.duplicate), - icon = EnclyIcons.Duplicate, - onClick = { onAction(NoteAction.Duplicate) }, - ) - EnclySheetRow( - title = stringResource(id = R.string.delete), - icon = EnclyIcons.Trash, - destructive = true, - confirmFirst = true, - onClick = { onAction(NoteAction.Delete) }, - ) - } -} - -/** The note's colour: a heading and a row of swatches, bleeding into the sheet's gutters like the tag chips. */ -@Composable -private fun ColorChoices(selected: NoteColor, onSelect: (NoteColor) -> Unit) { - SectionOverline(stringResource(R.string.note_color_title)) - EnclyNoteColorPicker( - selected = selected, - onSelect = onSelect, - modifier = Modifier.bleedHorizontally(EnclyTheme.spacing.gutter), - contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter), - ) -} - -/** Title and last edit: the sheet's heading. */ -@Composable -private fun NoteActionHeader(item: NoteWithTag) { - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = item.note.title.ifEmpty { stringResource(R.string.untitled) }, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = formatNoteDate(if (item.note.date == 0L) Date() else Date(item.note.date)), - style = EnclyTheme.typography.meta, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -/** "No tag" and every tag as chips; the selected one is the note's tag. */ -@Composable -private fun TagChips( - selectedTagId: Long, - onSelect: (Long) -> Unit, - tagListViewModel: TagListViewModel = enclyViewModel(), -) { - val state by tagListViewModel.state.collectAsState() - val gutter = EnclyTheme.spacing.gutter - // The row bleeds into the sheet's gutters so chips scroll off the edge instead of being cut at - // the gutter; the content padding puts the first chip back on the shared left edge. - LazyRow( - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - contentPadding = PaddingValues(horizontal = gutter), - modifier = Modifier.bleedHorizontally(gutter), - ) { - item { - EnclyChip( - label = stringResource(R.string.no_tag), - selected = selectedTagId == 0L, - onClick = { onSelect(0L) }, - ) - } - items(state.listTags, key = { it.id }) { tag -> - EnclyChip(label = tag.nameTag, selected = selectedTagId == tag.id, onClick = { onSelect(tag.id) }) - } - } -} - -/** The description as a row; tapping it turns it into a field with Cancel and Save. */ -@Composable -private fun DescriptionEditor( - initial: String, - editing: Boolean, - onEditingChange: (Boolean) -> Unit, - onSave: (String) -> Unit, -) { - if (!editing) { - EnclySheetRow( - title = initial.ifBlank { stringResource(R.string.note_description_placeholder) }, - muted = initial.isBlank(), - icon = EnclyIcons.Comment, - onClick = { onEditingChange(true) }, - ) - return - } - var text by remember(initial) { mutableStateOf(initial) } - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { focus.requestFocus() } - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - EnclyTextField( - value = text, - onValueChange = { text = it }, - label = stringResource(R.string.note_description_placeholder), - fieldModifier = Modifier.focusRequester(focus), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - singleLine = false, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s, Alignment.End), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - EnclyTextButton(text = stringResource(R.string.cancel), onClick = { onEditingChange(false) }, muted = true) - EnclyButton( - text = stringResource(R.string.save), - onClick = { - onSave(text.trim()) - onEditingChange(false) - }, - ) - } - } -} - -/** Widens the element by [bleed] on both sides, past its parent's padding. */ -private fun Modifier.bleedHorizontally(bleed: Dp) = layout { measurable, constraints -> - val extra = bleed.roundToPx() * 2 - val placeable = measurable.measure( - constraints.copy( - maxWidth = constraints.maxWidth + extra, - minWidth = - constraints.maxWidth + extra, - ), - ) - layout(constraints.maxWidth, placeable.height) { placeable.place(-extra / 2, 0) } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/RequestCleanCompleteTaskDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/RequestCleanCompleteTaskDialog.kt deleted file mode 100644 index ea2c3e3f..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/RequestCleanCompleteTaskDialog.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog - -@Composable -fun RequestCleanCompleteTaskDialog(showDialog: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit) { - if (showDialog) { - EnclyDialog( - title = stringResource(R.string.task_clear_completed_title), - text = stringResource(R.string.task_clear_completed_message), - onDismissRequest = onDismiss, - confirm = DialogAction( - text = stringResource(R.string.clear), - onClick = { - onConfirm() - onDismiss() - }, - destructive = true, - ), - dismiss = DialogAction(stringResource(R.string.cancel), onDismiss), - ) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/RequestDeleteTagsDIalog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/RequestDeleteTagsDIalog.kt deleted file mode 100644 index b8fd6522..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/RequestDeleteTagsDIalog.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog - -@Composable -fun RequestDeleteTagsDialog(showDialog: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit) { - if (showDialog) { - EnclyDialog( - title = stringResource(R.string.delete_tag), - text = stringResource(R.string.delete_tag_desrpt), - onDismissRequest = onDismiss, - confirm = DialogAction( - text = stringResource(R.string.delete), - onClick = { - onConfirm() - onDismiss() - }, - destructive = true, - ), - dismiss = DialogAction(stringResource(R.string.cancel), onDismiss), - ) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/SwipeActionDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/SwipeActionDialog.kt deleted file mode 100644 index 00e317d7..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/SwipeActionDialog.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.domain.model.SwipeAction -import com.pasich.encly.domain.model.SwipeSide -import com.pasich.encly.presentation.components.swipe.choiceLabel -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog -import com.pasich.encly.presentation.designsystem.EnclyRadioRow - -/** Picks what swiping a note card to [side] does. */ -@Composable -fun SwipeActionDialog( - side: SwipeSide, - current: SwipeAction, - onDismiss: () -> Unit, - onConfirm: (SwipeAction) -> Unit, - modifier: Modifier = Modifier, -) { - var selected by rememberSaveable { mutableStateOf(current) } - - EnclyDialog( - title = stringResource( - if (side == - SwipeSide.RIGHT - ) { - R.string.settings_swipe_right - } else { - R.string.settings_swipe_left - }, - ), - onDismissRequest = onDismiss, - modifier = modifier, - confirm = DialogAction(stringResource(R.string.done), { onConfirm(selected) }), - dismiss = DialogAction(stringResource(R.string.cancel), onDismiss), - ) { - Column(modifier = Modifier.selectableGroup()) { - SwipeAction.entries.forEach { action -> - EnclyRadioRow( - title = stringResource(action.choiceLabel()), - selected = action == selected, - onClick = { selected = action }, - ) - } - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionLinkBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionLinkBottomSheet.kt deleted file mode 100644 index fa3a76f6..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionLinkBottomSheet.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.pasich.encly.presentation.dialogs.blocks - -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.dynamicBlocks.Block -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.EnclySheetRow -import com.pasich.encly.presentation.editor.blocks.LinkBlock -import com.pasich.encly.presentation.editor.blocks.isOpenableLink - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ActionLinkBottomSheet( - settings: SettingsBlockDialog, - sheetState: SheetState, - onAction: (ActionBlockDialog) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - if (settings.isBottomSheetVisible) { - EnclyBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { - val block = settings.block - var openable = false - if (block is Block.LinkBlock) { - val urlModel by block.block.collectAsState() - openable = isOpenableLink(urlModel.url) - if (!urlModel.isError) { - LinkBlock(block, blockActions = null, onClick = {}) - } - // The whole address, so the user sees exactly what "Open link" hands to another app. - SelectionContainer { - Text( - text = urlModel.url, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - // An address Encly will not open (see NoteLink.Blocked) can still be copied. - if (openable) { - EnclySheetRow( - title = stringResource(R.string.link_open), - icon = EnclyIcons.External, - onClick = { onAction(ActionBlockDialog.OpenLink) }, - ) - } - EnclySheetRow( - title = stringResource(R.string.link_copy), - icon = EnclyIcons.Copy, - onClick = { onAction(ActionBlockDialog.CopyLink) }, - ) - if (settings.canEdit) { - EnclySheetRow( - title = stringResource(R.string.link_edit), - icon = EnclyIcons.Edit, - onClick = { onAction(ActionBlockDialog.EditLink) }, - ) - EnclyGroupDivider() - BlockEditRows(settings, onAction) - } - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionOtherBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionOtherBottomSheet.kt deleted file mode 100644 index 95e33e87..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionOtherBottomSheet.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.pasich.encly.presentation.dialogs.blocks - -import androidx.annotation.StringRes -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.presentation.designsystem.EnclyBottomSheet - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ActionOtherBottomSheet( - settings: SettingsBlockDialog, - sheetState: SheetState, - onAction: (ActionBlockDialog) -> Unit, - onDismiss: () -> Unit, -) { - if (settings.isBottomSheetVisible) { - EnclyBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - title = stringResource(settings.block.describe()), - ) { - // Every action here changes the note: a read-only editor offers none. - if (settings.canEdit) BlockEditRows(settings, onAction) - } - } -} - -/** Human-readable block type, as a string resource. */ -@StringRes -fun Block.describe(): Int = when (this) { - is Block.TextBlock -> R.string.text - is Block.QuoteBlock -> R.string.quote - is Block.ListBlock -> R.string.block_list - is Block.HBlock -> R.string.block_heading - is Block.LinkBlock -> R.string.block_link - is Block.SeparatorBlock -> R.string.block_separator -} 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 deleted file mode 100644 index 1693cdbd..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/BlockEditRows.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.pasich.encly.presentation.dialogs.blocks - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclySheetRow - -/** 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( - title = stringResource(R.string.block_move_up), - icon = EnclyIcons.ArrowUp, - enabled = settings.blockMove != 1, - onClick = { onAction(ActionBlockDialog.Move(1)) }, - ) - EnclySheetRow( - title = stringResource(R.string.block_move_down), - icon = EnclyIcons.ArrowDown, - 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, - destructive = true, - confirmFirst = true, - onClick = { onAction(ActionBlockDialog.Delete) }, - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/SettingsBlockDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/SettingsBlockDialog.kt deleted file mode 100644 index 1b3ffa24..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/SettingsBlockDialog.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.pasich.encly.presentation.dialogs.blocks - -import com.pasich.encly.dynamicBlocks.Block - -data class SettingsBlockDialog( - val block: Block, - val blockMove: Int = -1, // 1 - up, 2 - down - val isBottomSheetVisible: Boolean = false, - // False in a read-only editor: only actions that do not change the note are offered. - val canEdit: Boolean = true, -) diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt deleted file mode 100644 index d244f6e4..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt +++ /dev/null @@ -1,233 +0,0 @@ -package com.pasich.encly.presentation.dialogs.tasks - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetState -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.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -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.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.pasich.encly.R -import com.pasich.encly.data.model.Task -import com.pasich.encly.presentation.components.tasks.PriorityValues -import com.pasich.encly.presentation.designsystem.EnclyBottomSheet -import com.pasich.encly.presentation.designsystem.EnclyButton -import com.pasich.encly.presentation.designsystem.EnclyChip -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.EnclyTextField -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.viewmodel.SubtaskDraft -import com.pasich.encly.presentation.viewmodel.TaskDraft -import com.pasich.encly.ui.theme.EnclyTheme -import kotlinx.coroutines.delay - -private const val TASK_TITLE_MAX_LENGTH = 100 -private const val TASK_DESCRIPTION_MAX_LENGTH = 150 -private const val INITIAL_FOCUS_DELAY_MS = 300L - -/** [subtasks] is the checklist to save, null while an edited task's one has not loaded. */ -private data class TaskEditorState( - val title: String, - val description: String, - val priority: Int, - val editTaskId: Long?, - val subtasks: List?, -) { - val isEditMode: Boolean get() = editTaskId != null -} - -private data class TaskEditorActions( - val onTitleChange: (String) -> Unit, - val onDescriptionChange: (String) -> Unit, - val onPrioritySelect: (Int) -> Unit, - val onSubmit: () -> Unit, - /** Null when there is nothing to delete (a new task). */ - val onDelete: (() -> Unit)? = null, -) - -/** Saves the sheet: title, description, priority and the checklist (null: leave it as stored). */ -private fun interface TaskSubmit { - fun submit(title: String, description: String?, priority: Int, subtasks: List?) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Suppress("LongParameterList") // Compose sheet API: independent state + callbacks from TasksScreen. -@Composable -fun AddTaskDialog( - onDismiss: () -> Unit, - sheetState: SheetState, - onAddTask: (title: String, description: String?, priority: Int, subtasks: List) -> Unit, - editTask: Task? = null, - onEditTask: ( - ( - taskId: Long, - title: String, - description: String?, - priority: Int, - subtasks: List?, - ) -> Unit - )? = null, - onBackgroundSave: (TaskDraft) -> Unit = {}, - onDeleteTask: ((Task) -> Unit)? = null, - editSubtasks: List? = emptyList(), - subtasks: SubtaskListState? = null, -) { - var title by remember { mutableStateOf(editTask?.title.orEmpty()) } - var description by remember { mutableStateOf(editTask?.description.orEmpty()) } - var selectedPriority by remember { mutableIntStateOf(editTask?.priority ?: 0) } - // The caller's checklist (TasksViewModel's, which outlives a rotation), or one of its own. - val ownChecklist = rememberSubtaskListState(editSubtasks) - val checklist = subtasks ?: ownChecklist - val titleFocusRequester = remember { FocusRequester() } - - val state = TaskEditorState( - title = title, - description = description, - priority = selectedPriority, - editTaskId = editTask?.id, - subtasks = checklist.toSave(), - ) - val submit = TaskSubmit { taskTitle, taskDescription, priority, checklist -> - val taskId = editTask?.id - if (taskId != null && onEditTask != null) { - onEditTask(taskId, taskTitle, taskDescription, priority, checklist) - } else { - onAddTask(taskTitle, taskDescription, priority, checklist.orEmpty()) - } - } - val actions = TaskEditorActions( - onTitleChange = { title = it.take(TASK_TITLE_MAX_LENGTH) }, - onDescriptionChange = { description = it.take(TASK_DESCRIPTION_MAX_LENGTH) }, - onPrioritySelect = { selectedPriority = it }, - onSubmit = { submitTask(state, submit) }, - onDelete = editTask?.let { task -> onDeleteTask?.let { delete -> { delete(task) } } }, - ) - - // Backgrounding re-locks the vault and drops this sheet; flush the draft first, like - // EditNote does. ON_PAUSE fires well before ProcessLifecycleOwner's delayed ON_STOP. - val currentDraft by rememberUpdatedState( - TaskDraft(title, description, selectedPriority, state.subtasks), - ) - val currentOnBackgroundSave by rememberUpdatedState(onBackgroundSave) - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE) currentOnBackgroundSave(currentDraft) - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - EnclyBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - TaskEditorContent(state, titleFocusRequester, actions, checklist) - } - - LaunchedEffect(Unit) { - delay(INITIAL_FOCUS_DELAY_MS) - titleFocusRequester.requestFocus() - } -} - -private fun submitTask(state: TaskEditorState, submit: TaskSubmit) { - if (state.title.isBlank()) return - submit.submit(state.title, state.description.ifBlank { null }, state.priority, state.subtasks) -} - -@Composable -private fun TaskEditorContent( - state: TaskEditorState, - titleFocusRequester: FocusRequester, - actions: TaskEditorActions, - checklist: SubtaskListState, -) { - val descriptionFocus = remember { FocusRequester() } - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.fieldGap), - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - ) { - EnclyTextField( - value = state.title, - onValueChange = actions.onTitleChange, - label = stringResource( - if (state.isEditMode) R.string.task_edit_placeholder else R.string.task_new_placeholder, - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { descriptionFocus.requestFocus() }), - fieldModifier = Modifier.focusRequester(titleFocusRequester), - ) - EnclyTextField( - value = state.description, - onValueChange = actions.onDescriptionChange, - label = stringResource(R.string.task_description_placeholder), - textStyle = MaterialTheme.typography.bodyMedium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { actions.onSubmit() }), - fieldModifier = Modifier.focusRequester(descriptionFocus), - ) - // Right under the description, so it stays in view above the keyboard. - SubtaskEditor(checklist) - PriorityChips(selected = state.priority, onSelect = actions.onPrioritySelect) - TaskEditorFooter(state, actions) - } -} - -/** The priority as three chips, highest first. */ -@Composable -private fun PriorityChips(selected: Int, onSelect: (Int) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - SectionOverline(stringResource(R.string.priority_select_title)) - Row(horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - PriorityValues.priorities.forEach { priority -> - EnclyChip( - label = stringResource(priority.label), - selected = priority.id == selected, - onClick = { onSelect(priority.id) }, - ) - } - } - } -} - -@Composable -private fun TaskEditorFooter(state: TaskEditorState, actions: TaskEditorActions) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - actions.onDelete?.let { onDelete -> - EnclyTextButton(text = stringResource(R.string.task_delete), onClick = onDelete, destructive = true) - } - EnclyButton( - text = stringResource(if (state.isEditMode) R.string.save else R.string.add), - onClick = actions.onSubmit, - enabled = state.title.isNotBlank(), - modifier = Modifier.weight(1f), - ) - } -} 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 deleted file mode 100644 index 956a0fa3..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActions.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.pasich.encly.presentation.editor - -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.domain.model.LinkDataBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.presentation.editor.state.BlockRemoveAction -import com.pasich.encly.presentation.editor.state.CaretMove -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], - * [onListItemsChanged] and [onLinkChanged], never by writing the block's state directly, - * so every edit is saved and can be undone. - */ -@Suppress("TooManyFunctions") // Everything a block's fields can ask of the editor, in one place. -interface BlockActions { - fun onAddParagraph() - fun onRemoveBlock(blockRemoveAction: BlockRemoveAction) - fun onReplaceBlock(type: Block) - fun onTextChanged(newText: String) - - /** [mergeable] is true for typing, false for discrete edits (check, add/remove item). */ - fun onListItemsChanged(newItems: List, mergeable: Boolean) - fun onLinkChanged(newLink: LinkDataBlock) - - /** - * A line break typed (Enter) or pasted into this block's field. - * @return the text the field keeps; null to keep the break as text - */ - fun onLineBreak(lineBreak: LineBreak): String? - - /** Backspace with the cursor at the start of this block's field; false when it does nothing. */ - fun onBackspaceAtStart(): Boolean - - /** A mark typed at the start of this paragraph, which now reads [typed]; true when applied. */ - fun onShortcut(typed: String, shortcut: Shortcut): Boolean - - /** A lone web address pasted into this empty paragraph; true when it became [link]. */ - fun onLinkPasted(pasted: String, link: LinkDataBlock): Boolean - - /** [onLineBreak] in item [itemId] of this list. */ - fun onListLineBreak(itemId: String, lineBreak: LineBreak): String? - - /** [onBackspaceAtStart] in item [itemId] of this list. */ - fun onListBackspaceAtStart(itemId: String): Boolean - - /** - * A cursor key leaves this block's field ([move]): focuses the next or previous block that has a - * field, the cursor placed by the key. False when there is none (or it is not on screen), and the - * key is then left to the field. Nothing is ever added by it. - */ - fun navigate(move: CaretMove): Boolean - - /** - * Registers how to put the cursor of this block's field at a horizontal position (on its last - * line when it arrives from below, else its first). - * @return a function that removes the registration - */ - fun registerCaretAtX(callback: (Float, Boolean) -> Unit): () -> Unit - - /** The cursor in item [itemId] of this list moved to [offset]. */ - fun onListItemCaret(itemId: String, offset: Int) - - /** The cursor in this block's field moved to [offset]. */ - fun onCaretMoved(offset: Int) - - /** - * Registers how to put the cursor of this block's field at an offset ([Int.MAX_VALUE]: the end). - * @return a function that removes the registration - */ - fun registerCaret(callback: (Int) -> Unit): () -> Unit - - /** - * Registers how to focus a block with several fields (a list): the item a request names, - * else its last field with the cursor at the end when asked for the end, else its first. - * @return a function that removes the registration - */ - 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) -} - -/** 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 deleted file mode 100644 index 03e8c228..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActionsImpl.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.pasich.encly.presentation.editor - -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 com.pasich.encly.presentation.editor.focus.BlockFocusRegistry -import com.pasich.encly.presentation.editor.state.BlockRemoveAction -import com.pasich.encly.presentation.editor.state.CaretMove -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.editor.state.goesUp -import com.pasich.encly.presentation.editor.state.requestFor -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 - * action never targets whatever block happens to sit at a stale index. Focus moves go through - * [focusRegistry], which owns the fields' FocusRequesters. - */ -@Suppress("TooManyFunctions") // Implements BlockActions, one editor call each. -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) - - override fun onAddParagraph() { - val current = index - if (current >= 0) viewModel.addBlockAfter(current, BlockType.TEXT) - } - - override fun onRemoveBlock(blockRemoveAction: BlockRemoveAction) { - viewModel.removeBlock(block, blockRemoveAction) - } - - override fun onReplaceBlock(type: Block) { - val current = index - if (current >= 0) viewModel.replaceBlock(current, type) - } - - override fun onTextChanged(newText: String) { - viewModel.onBlockTextChanged(block, newText) - } - - override fun onListItemsChanged(newItems: List, mergeable: Boolean) { - if (block is Block.ListBlock) viewModel.onListItemsChanged(block, newItems, mergeable) - } - - override fun onLinkChanged(newLink: LinkDataBlock) { - if (block is Block.LinkBlock) viewModel.onLinkChanged(block, newLink) - } - - override fun onLineBreak(lineBreak: LineBreak): String? = viewModel.onLineBreak(block, lineBreak) - - override fun onBackspaceAtStart(): Boolean = viewModel.onBackspaceAtStart(block) - - override fun onShortcut(typed: String, shortcut: Shortcut): Boolean = viewModel.onShortcut(block, typed, shortcut) - - override fun onLinkPasted(pasted: String, link: LinkDataBlock): Boolean = - viewModel.onLinkPasted(block, pasted, link) - - override fun onListLineBreak(itemId: String, lineBreak: LineBreak): String? = - (block as? Block.ListBlock)?.let { viewModel.onListLineBreak(it, itemId, lineBreak) } - - override fun onListBackspaceAtStart(itemId: String): Boolean = - (block as? Block.ListBlock)?.let { viewModel.onListBackspaceAtStart(it, itemId) } == true - - override fun onCaretMoved(offset: Int) = viewModel.onCaretMoved(block, offset) - - override fun onListItemCaret(itemId: String, offset: Int) = viewModel.onListItemCaret(block, itemId, offset) - - override fun registerCaret(callback: (Int) -> Unit): () -> Unit = focusRegistry.registerCaret(block.id, callback) - - 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 navigate(move: CaretMove): Boolean { - val target = if (move.goesUp) { - focusRegistry.previousFocusableBlock(block.id) - } else { - focusRegistry.nextFocusableBlock(block.id) - } - // Only a field that is on screen can be focused; otherwise the key stays with the field. - val moved = target != null && focusRegistry.focus(move.requestFor(target.id)) - if (moved) viewModel.onBlockFocused(target!!) - return moved - } - - override fun registerCaretAtX(callback: (Float, Boolean) -> Unit): () -> Unit = - focusRegistry.registerCaretAtX(block.id, callback) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/BlockTool.kt b/app/src/main/java/com/pasich/encly/presentation/editor/BlockTool.kt deleted file mode 100644 index 18039102..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/BlockTool.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.pasich.encly.presentation.editor - -import androidx.annotation.StringRes -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyToolButton -import com.pasich.encly.presentation.designsystem.ToolStyle -import com.pasich.encly.presentation.editor.state.HEADING_TOOL - -/** One block tool of the editor toolbar. [headingLevel] fills the "Heading %d" label. */ -data class BlockTool( - val icon: ImageVector, - val type: BlockType, - @param:StringRes val label: Int, - val headingLevel: Int? = null, -) - -/** - * The toolbar's block tools (design spec §3.3), in its order. Each turns the block the user works - * on into its type, or adds one after it (see applyTool). - */ -val mainBlockTools = listOf( - BlockTool(EnclyIcons.Heading1, HEADING_TOOL, R.string.block_heading), - BlockTool(EnclyIcons.Checklist, BlockType.LIST_CHECK, R.string.block_checklist), - BlockTool(EnclyIcons.ListBullet, BlockType.LIST_BULLET, R.string.block_bulleted_list), - BlockTool(EnclyIcons.ListNumbered, BlockType.LIST_NUMBER, R.string.block_numbered_list), - BlockTool(EnclyIcons.Quote, BlockType.QUOTE, R.string.quote), -) - -/** Behind "More block tools": a plain paragraph, every heading level, a link and a separator. */ -val moreBlockTools = listOf( - BlockTool(EnclyIcons.Type, BlockType.TEXT, R.string.text), - BlockTool(EnclyIcons.Heading1, BlockType.H1, R.string.block_heading_level, headingLevel = 1), - BlockTool(EnclyIcons.Heading2, BlockType.H2, R.string.block_heading_level, headingLevel = 2), - BlockTool(EnclyIcons.Heading3, BlockType.H3, R.string.block_heading_level, headingLevel = 3), - BlockTool(EnclyIcons.Heading4, BlockType.H4, R.string.block_heading_level, headingLevel = 4), - BlockTool(EnclyIcons.Link, BlockType.LINK, R.string.block_link), - BlockTool(EnclyIcons.Separator, BlockType.SEPARATOR, R.string.block_separator), -) - -/** A tool button; [active] marks the tool of the block the user works on. */ -@Composable -fun BlockToolButton(tool: BlockTool, active: Boolean, onClick: (() -> Unit)?, modifier: Modifier = Modifier) { - EnclyToolButton( - icon = tool.icon, - contentDescription = tool.headingLevel?.let { stringResource(tool.label, it) } ?: stringResource(tool.label), - onClick = onClick, - style = if (active) ToolStyle.ACTIVE else ToolStyle.PLAIN, - modifier = modifier, - ) -} 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 deleted file mode 100644 index 1b8f432d..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt +++ /dev/null @@ -1,297 +0,0 @@ -package com.pasich.encly.presentation.editor - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.CustomAccessibilityAction -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.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -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 -import com.pasich.encly.presentation.editor.blocks.QuoteBlock -import com.pasich.encly.presentation.editor.blocks.SeparatorBlock -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 -import kotlinx.coroutines.flow.MutableStateFlow - -/** What the editor's blocks need from the screen that shows them. */ -class EditorBlocksHost( - val viewModel: EditNoteViewModel, - val focusRegistry: BlockFocusRegistry, - val highlights: FindHighlights, - val onOpenSheet: (BottomSheetsOpenType, Block) -> Unit, -) - -/** - * The note's blocks as lazy items, one per block keyed by its id, so a long note composes and - * 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; [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, host.highlights) } - val callbacks = remember(block, host) { - EditorBlockCallbacks( - onFocus = { host.viewModel.onBlockFocused(block) }, - onInteract = { host.viewModel.onBlockInteraction(block) }, - onToggleLock = { host.viewModel.toggleChecklistLock(block) }, - onFocusLost = { host.viewModel.onBlockFocusLost(block) }, - onOpenSheet = { type -> - host.viewModel.onBlockInteraction(block) - host.onOpenSheet(type, block) - }, - onMove = { up -> host.viewModel.moveBlockOf(block, up) }, - onDuplicate = { host.viewModel.duplicateBlock(block) }, - onDelete = { host.viewModel.removeBlock(block, BlockRemoveAction.REMOVE) }, - ) - } - EditorBlock( - block = block, - blockActions = blockActions, - callbacks = callbacks, - focusRegistry = host.focusRegistry, - isLocked = isLocked, - isEditingLink = block.id in editingLinkIds, - isOnlyBlock = blocks.size == 1, - position = BlockPosition(first = index == 0, last = index == blocks.lastIndex), - ) - } - - // 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) { - val keyboardController = LocalSoftwareKeyboardController.current - Spacer( - Modifier - .height(EnclyTheme.spacing.editorTapArea) - .fillMaxWidth() - .clickable( - enabled = !isLocked, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - host.viewModel.addBlockToEnd() - // The last field may still hold focus with the keyboard hidden: focusing it again shows nothing. - keyboardController?.show() - }, - ) - } -} - -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. */ -@Suppress("LongParameterList") // One callback per thing a block reports or is asked to do. -private class EditorBlockCallbacks( - val onFocus: () -> Unit, - val onInteract: () -> Unit, - val onToggleLock: () -> Unit, - val onFocusLost: () -> Unit, - val onOpenSheet: (BottomSheetsOpenType) -> Unit, - val onMove: (up: Boolean) -> Unit, - val onDuplicate: () -> Unit, - val onDelete: () -> Unit, -) - -/** Where a block is in the note: TalkBack offers to move it only where it can go. */ -private data class BlockPosition(val first: Boolean, val last: Boolean) - -@Composable -private fun EditorBlock( - block: Block, - blockActions: BlockActions, - callbacks: EditorBlockCallbacks, - focusRegistry: BlockFocusRegistry, - isLocked: Boolean = false, - isEditingLink: Boolean = false, - isOnlyBlock: Boolean = false, - position: BlockPosition = BlockPosition(first = true, last = true), -) { - val focusRequester = remember { FocusRequester() } - // A list registers its own target: it focuses its first or last item. - if (block !is Block.ListBlock) RegisterFocusRequester(block.id, focusRegistry, focusRequester) - // Whether the block is being edited: its placeholder shows then. - var hasFocus by remember { mutableStateOf(false) } - - val checklist = (block as? Block.ListBlock)?.takeIf { it.blockType == BlockType.LIST_CHECK } - val lockedFlow = remember(checklist) { checklist?.locked ?: MutableStateFlow(false) } - val checklistLocked by lockedFlow.collectAsState() - Box( - modifier = Modifier - // A touch anywhere in a list makes it the block the toolbar acts on, even where its text - // cannot take focus (a locked checklist), so its lock can be undone from the toolbar. - .then(if (block is Block.ListBlock) Modifier.markInteractionOn(callbacks.onInteract) else Modifier) - // Blocks carry an 8 dp frame of their own: they sit that much inside the gutter. - .padding(horizontal = EnclyTheme.spacing.m) - .onFocusChanged { focusState -> - hasFocus = focusState.hasFocus - // A list has one field per item: any of them focused counts as the list focused, - // so a new block goes after the list and not after the block focused before it. - val focused = if (block is Block.ListBlock) focusState.hasFocus else focusState.isFocused - when { - focused -> callbacks.onFocus() - !focusState.hasFocus -> callbacks.onFocusLost() - } - } - .editorBlockFrame( - LocalNoteLineSpacing.current.frameInset.takeIf { - block is Block.TextBlock || block is Block.ListBlock || block is Block.QuoteBlock - }, - ), - ) { - BlockContent( - block = block, - blockActions = blockActions, - focusRequester = focusRequester, - state = BlockContentState( - isLocked = isLocked, - isEditingLink = isEditingLink, - showPlaceholder = !isLocked && (hasFocus || isOnlyBlock), - ), - onOpenSheet = callbacks.onOpenSheet, - modifier = if (isLocked) { - Modifier - } else { - blockAccessibilityActions(callbacks, position, isOnlyBlock, checklist?.let { checklistLocked }) - }, - ) - } -} - -/** - * 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 -private fun blockAccessibilityActions( - callbacks: EditorBlockCallbacks, - position: BlockPosition, - isOnlyBlock: Boolean, - checklistLocked: Boolean?, -): 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 lock = stringResource(if (checklistLocked == true) R.string.checklist_unlock else R.string.checklist_lock) - 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() } }) - if (checklistLocked != null) add(CustomAccessibilityAction(lock) { true.also { callbacks.onToggleLock() } }) - } - return Modifier.semantics { customActions = actions } -} - -/** How a block shows: read-only, its link being edited, its placeholder. */ -private class BlockContentState(val isLocked: Boolean, val isEditingLink: Boolean, val showPlaceholder: Boolean) - -@Composable -private fun BlockContent( - block: Block, - blockActions: BlockActions, - focusRequester: FocusRequester, - state: BlockContentState, - onOpenSheet: (BottomSheetsOpenType) -> Unit, - modifier: Modifier = Modifier, -) { - // [modifier] reaches the block's field (its TalkBack actions); the focus target joins it. - val fieldModifier = modifier.focusRequester(focusRequester) - val isLocked = state.isLocked - when (block) { - is Block.TextBlock -> TextBlock( - block, - blockActions, - modifier = fieldModifier, - isLocked = isLocked, - showPlaceholder = state.showPlaceholder, - ) - - is Block.QuoteBlock -> QuoteBlock( - block, - blockActions, - focusRequester, - fieldModifier = modifier, - isLocked = isLocked, - ) - - is Block.HBlock -> HBlock(block, blockActions, modifier = fieldModifier, isLocked = isLocked) - - is Block.ListBlock -> ListBlock(block, blockActions, fieldModifier = modifier, isLocked = isLocked) - - is Block.LinkBlock -> LinkBlock( - block, - blockActions = blockActions, - onClick = { onOpenSheet(BottomSheetsOpenType.ACTION_LINK) }, - fieldModifier = fieldModifier, - isLocked = isLocked, - isEditing = state.isEditingLink, - ) - - is Block.SeparatorBlock -> SeparatorBlock( - onClick = { onOpenSheet(BottomSheetsOpenType.ACTION_OTHER) }, - modifier = fieldModifier, - isLocked = isLocked, - ) - } -} - -/** Calls [onTouch] on every touch that lands in the block, whether or not a child handles it. */ -private fun Modifier.markInteractionOn(onTouch: () -> Unit): Modifier = pointerInput(onTouch) { - awaitPointerEventScope { - while (true) { - awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - onTouch() - } - } -} 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 deleted file mode 100644 index 47138620..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/FindHighlights.kt +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 5253bb65..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/BlockInput.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.pasich.encly.presentation.editor.blocks - -import androidx.compose.foundation.text.input.InputTransformation -import androidx.compose.foundation.text.input.TextFieldBuffer -import androidx.compose.foundation.text.input.placeCursorAtEnd -import com.pasich.encly.presentation.editor.BlockActions -import com.pasich.encly.presentation.editor.state.LineBreak -import com.pasich.encly.presentation.editor.state.isLoneWebAddress -import com.pasich.encly.presentation.editor.state.normalizeLineBreaks -import com.pasich.encly.presentation.editor.state.textDiff -import com.pasich.encly.presentation.editor.state.typedShortcut - -/** Which field of a block a [BlockInput] watches: a paragraph, another text block, or a list item. */ -internal sealed interface BlockField { - /** A paragraph: its marks ("# ", "- ", ...) and a pasted lone web address convert it. */ - data object Paragraph : BlockField - - /** A heading or quote. */ - data object Styled : BlockField - - /** Item [itemId] of a list. */ - data class ListItem(val itemId: String) : BlockField -} - -/** - * The writing rules of a block's field, applied to every edit the user makes: typing, the - * keyboard's Enter, a paste, autocorrect. Enter arrives as a line break from every keyboard - * (some send a key press, some commit "\n"), so this is the one place it is handled: a line - * break typed or pasted splits the block ([BlockActions.onLineBreak]), and the field keeps only - * 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. - */ -internal class BlockInput(private val field: BlockField, private val actions: () -> BlockActions) : - InputTransformation { - override fun TextFieldBuffer.transformInput() { - val old = originalText.toString() - val new = asCharSequence().toString() - if (old == new) return - val cursor = if (selection.collapsed) selection.start else -1 - val diff = textDiff(old, new, cursor) - val lineBreak = LineBreak.of(old, diff) - when { - lineBreak != null -> { - val kept = when (field) { - is BlockField.ListItem -> actions().onListLineBreak(field.itemId, lineBreak) - else -> actions().onLineBreak(lineBreak) - } - if (kept != null) { - replace(0, length, kept) - placeCursorAtEnd() - } else if (diff.inserted.any { it == '\r' }) { - // Kept as text: one kind of line break only. - replace(diff.start, diff.end, normalizeLineBreaks(diff.inserted)) - } - } - - field != BlockField.Paragraph -> Unit - - 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) - } - } - } - - private fun onParagraphInsert(old: String, new: String, cursor: Int, inserted: String) { - val shortcut = typedShortcut(new, cursor) - when { - shortcut != null -> actions().onShortcut(new, shortcut) - - // A paste (not typing, which inserts one character at a time) of an address alone. - old.isEmpty() && inserted.length > 1 && isLoneWebAddress(new) -> - buildLinkData(new)?.let { actions().onLinkPasted(new, it) } - } - } -} - -/** 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 deleted file mode 100644 index 23b083e0..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/FieldHighlight.kt +++ /dev/null @@ -1,74 +0,0 @@ -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 -import com.pasich.encly.presentation.editor.focus.CaretGeometry -import com.pasich.encly.presentation.editor.focus.geometry - -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) - - /** The field's lines as the cursor moves between fields need them; null until it is measured. */ - fun geometry(): CaretGeometry? = layout?.invoke()?.geometry() - - /** The field's measured text; null until it has been measured. */ - fun layoutResult(): TextLayoutResult? = layout?.invoke() - - 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 deleted file mode 100644 index b87364e2..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/HBlock.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.pasich.encly.presentation.editor.blocks - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicTextField -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.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -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.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.designsystem.EDITOR_PLACEHOLDER_ALPHA -import com.pasich.encly.presentation.editor.BlockActions -import com.pasich.encly.presentation.screen.editnote.rememberFontStyles -import com.pasich.encly.ui.theme.EnclyTheme - -@Composable -fun HBlock(block: Block.HBlock, blockActions: BlockActions, modifier: Modifier = Modifier, isLocked: Boolean = false) { - val textStyle = headingTextStyle(block.blockType) - val state = rememberBlockTextFieldState(block.text, blockActions) - 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) - RegisterCaretAtX(blockActions, state, highlight) - var focused by remember { mutableStateOf(false) } - - BasicTextField( - state = state, - readOnly = isLocked, - inputTransformation = input, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - textStyle = textStyle, - keyboardOptions = WritingKeyboard, - onTextLayout = highlight.onTextLayout, - modifier = modifier - .onFocusChanged { focused = it.isFocused } - .fillMaxWidth() - .padding(top = EnclyTheme.spacing.labelGap) - .then(highlight.modifier) - .textBlockKeys(state, blockActions, highlight), - decorator = { innerTextField -> - Box(modifier = Modifier.fillMaxWidth()) { - if (state.text.isEmpty() && focused) { - Text( - text = stringResource(R.string.block_heading_level, block.blockType.headingLevel()), - style = textStyle.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), - ), - ) - } - innerTextField() - } - }, - ) -} - -private val HeadingTypes = listOf(BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4) - -/** 1 for H1 up to 4 for H4. */ -private fun BlockType.headingLevel(): Int = HeadingTypes.indexOf(this) + 1 - -/** The text style of a heading of [blockType] (H1-H4). */ -@Composable -private fun headingTextStyle(blockType: BlockType): TextStyle { - val fontStyles = rememberFontStyles() - val (base, size) = when (blockType) { - BlockType.H1 -> MaterialTheme.typography.headlineSmall to fontStyles.sizes.h1 - BlockType.H2 -> MaterialTheme.typography.titleLarge to fontStyles.sizes.h2 - BlockType.H3 -> MaterialTheme.typography.titleMedium to fontStyles.sizes.h3 - BlockType.H4 -> MaterialTheme.typography.titleSmall to fontStyles.sizes.h4 - else -> return MaterialTheme.typography.titleMedium - } - return base.copy( - color = MaterialTheme.colorScheme.onSurface, - fontSize = size, - fontFamily = fontStyles.families.heading, - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/LinkBlock.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/LinkBlock.kt deleted file mode 100644 index afdd6574..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/LinkBlock.kt +++ /dev/null @@ -1,230 +0,0 @@ -package com.pasich.encly.presentation.editor.blocks - -import androidx.compose.foundation.clickable -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -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.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -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.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextOverflow -import com.pasich.encly.R -import com.pasich.encly.domain.model.LinkDataBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyTextField -import com.pasich.encly.presentation.designsystem.EnclyToolButton -import com.pasich.encly.presentation.designsystem.FieldState -import com.pasich.encly.presentation.designsystem.ToolStyle -import com.pasich.encly.presentation.editor.BlockActions -import com.pasich.encly.presentation.editor.state.BlockRemoveAction -import com.pasich.encly.ui.theme.EnclyTheme - -/** - * A link: its address entry until one is saved (or while [isEditing] it), then the link as - * underlined `primary` text; [onClick] opens the link's sheet. The editor focuses a new link's - * entry through [modifier]; the entry focuses itself only when "Edit link" reopened it. - */ -@Composable -fun LinkBlock( - block: Block.LinkBlock, - blockActions: BlockActions?, - onClick: () -> Unit, - modifier: Modifier = Modifier, - fieldModifier: Modifier = Modifier, - isLocked: Boolean = false, - isEditing: Boolean = false, -) { - val urlModel by block.block.collectAsState() - // Outlives the saved link, so "Edit link" reopens the entry with the current address. - var inputText by remember { mutableStateOf(urlModel.url) } - // Set when the entered address may not be saved (not a web or mail link). - var rejected by remember { mutableStateOf(false) } - LaunchedEffect(urlModel.url, isEditing) { - if (urlModel.url.isNotBlank()) inputText = urlModel.url - } - - // [isEditing]: the saved link stays in the block while its address is edited here, so it - // is still stored if the user leaves without committing a new one. - if ((urlModel.url.isBlank() || isEditing) && !isLocked) { - LinkUrlField( - inputText = inputText, - rejected = rejected, - onInputChange = { - inputText = it - rejected = false - }, - onCommit = { - if (inputText.isNotBlank()) { - val link = buildLinkData(inputText) - rejected = link == null - if (link != null) { - if (blockActions != null) blockActions.onLinkChanged(link) else block.block.value = link - } - } - }, - onRemoveBlock = { blockActions?.onRemoveBlock(BlockRemoveAction.REMOVE_BACKSPACE) }, - modifier = modifier, - fieldModifier = fieldModifier, - autoFocus = isEditing, - ) - } else if (urlModel.url.isNotBlank()) { - LinkText(urlModel = urlModel, onClick = onClick, modifier = modifier) - } -} - -@Composable -private fun LinkUrlField( - inputText: String, - rejected: Boolean, - onInputChange: (String) -> Unit, - onCommit: () -> Unit, - onRemoveBlock: () -> Unit, - modifier: Modifier = Modifier, - fieldModifier: Modifier = Modifier, - autoFocus: Boolean = false, -) { - val focusRequester = remember { FocusRequester() } - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - modifier = modifier.fillMaxWidth(), - ) { - EnclyTextField( - value = inputText, - onValueChange = onInputChange, - label = stringResource(R.string.enter_url), - state = if (rejected) FieldState.Error(stringResource(R.string.link_blocked)) else FieldState.Default, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { onCommit() }), - modifier = Modifier.weight(1f), - fieldModifier = fieldModifier - .focusRequester(focusRequester) - .onKeyEvent { - // Backspace in an empty field removes the block; otherwise it edits the URL. - if (it.key == Key.Backspace && it.type == KeyEventType.KeyDown && inputText.isEmpty()) { - onRemoveBlock() - true - } else { - false - } - }, - ) - EnclyToolButton( - icon = EnclyIcons.Plus, - contentDescription = stringResource(R.string.link_add), - onClick = if (inputText.isNotBlank()) onCommit else null, - style = ToolStyle.FILLED, - ) - } - if (autoFocus) { - LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } - } -} - -/** - * A saved link as a card: a globe tile, the site's host as its title and the rest of the address - * under it (the app is offline, so there is no fetched preview). A tap opens the link's sheet. - * A link Encly will not open ([NoteLink.Blocked]) shows its whole address and says so in `error`. - */ -@Composable -private fun LinkText(urlModel: LinkDataBlock, onClick: () -> Unit, modifier: Modifier = Modifier) { - val link = remember(urlModel.url) { parseNoteLink(urlModel.url) } - val (title, detail) = remember(link) { link.cardLines() } - val error = when { - link is NoteLink.Blocked -> stringResource(R.string.link_blocked) - urlModel.isError -> stringResource(R.string.falied_content) - else -> null - } - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = modifier - .padding(vertical = EnclyTheme.spacing.xxs) - .fillMaxWidth(), - ) { - // Clickable inside the Surface, so the ripple keeps to the card's corners. - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), - modifier = Modifier - .clickable(role = Role.Button, onClickLabel = stringResource(R.string.more_options), onClick = onClick) - .padding(EnclyTheme.spacing.s), - ) { - LinkTile() - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap), - modifier = Modifier.weight(1f), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = if (link is NoteLink.Blocked) TextOverflow.MiddleEllipsis else TextOverflow.Ellipsis, - ) - if (error != null) { - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } else if (detail.isNotEmpty()) { - Text( - text = detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) - } - } - } - } -} - -/** The globe tile at the start of a link card. */ -@Composable -private fun LinkTile(modifier: Modifier = Modifier) { - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = modifier, - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(EnclyTheme.spacing.tileSmall)) { - Icon( - EnclyIcons.Globe, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(EnclyTheme.spacing.iconSmall), - ) - } - } -} 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 deleted file mode 100644 index a378a875..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/ListBlock.kt +++ /dev/null @@ -1,508 +0,0 @@ -package com.pasich.encly.presentation.editor.blocks - -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.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -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.focus.onFocusChanged -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.LineHeightStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import com.pasich.encly.R -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.designsystem.CheckboxSize -import com.pasich.encly.presentation.designsystem.EDITOR_PLACEHOLDER_ALPHA -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.designsystem.markerBoxSize -import com.pasich.encly.presentation.designsystem.markerColumn -import com.pasich.encly.presentation.editor.BlockActions -import com.pasich.encly.presentation.editor.focus.KeyboardUtils -import com.pasich.encly.presentation.editor.focus.caretLine -import com.pasich.encly.presentation.editor.focus.caretOffsetFor -import com.pasich.encly.presentation.editor.state.CaretMove -import com.pasich.encly.presentation.editor.state.goesUp -import com.pasich.encly.presentation.screen.editnote.LocalNoteLineSpacing -import com.pasich.encly.presentation.screen.editnote.rememberFontStyles -import com.pasich.encly.ui.theme.EnclyTheme -import kotlin.math.roundToInt - -/** From this many items numbers take two digits, and a wider hanging indent. */ -private const val WIDE_MARKER_FROM = 10 - -private const val FOCUS_FRAMES = 5 - -/** A two-digit number's extra width, in text sizes. */ -private const val WIDE_MARKER_EXTRA = 0.6f - -/** Which item takes focus next, and where its cursor goes (null: where it is; [Int.MAX_VALUE]: the end). */ -private data class ItemFocus( - val itemId: String, - val caret: Int?, - /** A horizontal position to put the cursor at, on the item's last line when [fromBelow], else its first. */ - val caretX: Float? = null, - val fromBelow: Boolean = false, -) - -/** The list's item fields by item id, and the item to focus once it is composed. */ -private class ListFocus { - val requesters = mutableMapOf() - val fieldStates = mutableMapOf() - val highlights = mutableMapOf() - var pending by mutableStateOf(null) - - /** The item whose field has focus; its cursor is reported so undo can put it back. */ - var focusedItem: String? = null -} - -/** - * The list's focus: the target the editor focuses the list through (the item a request names, - * else its first item, or its last with the cursor at the end), and focus moves between items - * once the item is composed. - */ -@Composable -private fun rememberListFocus(block: Block.ListBlock, blockActions: BlockActions): ListFocus { - val focus = remember { ListFocus() } - DisposableEffect(block, blockActions) { - val unregister = blockActions.registerFieldsFocusTarget { request -> - val items = block.items.value - val item = request.itemId?.let { id -> items.firstOrNull { it.id == id } } - // An item that is not there (undone away) is never "the first one": the nearest end is. - ?: if (request.itemId != null || request.cursorToEnd || request.fromBelow) { - items.lastOrNull() - } else { - items.firstOrNull() - } - item?.let { focus.pending = ItemFocus(it.id, request.caretOffset, request.caretX, request.fromBelow) } - } - onDispose { unregister() } - } - // A new item is composed a frame or two after the edit that added it. - LaunchedEffect(focus.pending) { - val request = focus.pending ?: return@LaunchedEffect - repeat(FOCUS_FRAMES) { - val requester = focus.requesters[request.itemId] - if (requester != null) { - runCatching { requester.requestFocus() } - val stored = block.items.value.firstOrNull { it.id == request.itemId }?.value - val state = focus.fieldStates[request.itemId] - if (stored != null && state != null) { - if (request.caretX != null) { - val geometry = focus.highlights[request.itemId]?.geometry() - state.placeCaret( - stored, - caretOffsetFor(geometry, request.caretX, request.fromBelow, stored.length), - ) - } else if (request.caret != null) { - state.placeCaret(stored, request.caret) - } - } - focus.pending = null - return@LaunchedEffect - } - withFrameNanos { } - } - focus.pending = null - } - return focus -} - -/** - * A checklist, bulleted or numbered list: one field per item. Every change goes through - * [blockActions], so it is autosaved and can be undone. The editor focuses the list through the - * target it registers ([BlockActions.registerFieldsFocusTarget]). - */ -@Composable -fun ListBlock( - block: Block.ListBlock, - blockActions: BlockActions, - modifier: Modifier = Modifier, - fieldModifier: Modifier = Modifier, - isLocked: Boolean = false, -) { - val items by block.items.collectAsState() - val textLocked by block.locked.collectAsState() - val actions by rememberUpdatedState(blockActions) - val focus = rememberListFocus(block, blockActions) - val editor = remember(block) { ListEditor(block, { actions }) { focus.pending = it } } - val checklist = block.blockType == BlockType.LIST_CHECK - val marker = rememberListMarker(block.blockType, items.size) - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy( - LocalNoteLineSpacing.current.listItemGap(checklist, EnclyTheme.spacing.xxs), - ), - ) { - items.forEachIndexed { itemIndex, item -> - key(item.id) { - val state = rememberSyncedTextFieldState( - model = { block.items.value.firstOrNull { it.id == item.id }?.value }, - modelChanges = block.items, - onEdit = { text -> editor.setText(item.id, text) }, - ) - val requester = remember { FocusRequester() } - val highlight = rememberFieldHighlight(blockActions.highlights, block.id, item.id) - LaunchedEffect(item.id, state) { - snapshotFlow { state.selection.start }.collect { offset -> - if (focus.focusedItem == item.id) actions.onListItemCaret(item.id, offset) - } - } - DisposableEffect(item.id) { - focus.requesters[item.id] = requester - focus.fieldStates[item.id] = state - focus.highlights[item.id] = highlight - onDispose { - focus.highlights.remove(item.id) - focus.requesters.remove(item.id) - focus.fieldStates.remove(item.id) - } - } - ListItemRow( - item = item, - marker = marker.forItem(itemIndex + 1), - field = ItemField(state, highlight, marker.metrics), - editor = editor, - locks = ItemLocks(editorLocked = isLocked, textLocked = textLocked), - fieldModifier = fieldModifier.focusRequester(requester).onFocusChanged { focusState -> - if (focusState.isFocused) { - focus.focusedItem = item.id - actions.onListItemCaret(item.id, state.selection.start) - } - }, - ) - } - } - } -} - -/** The marker of a list of [count] items: the checkbox's column, grown with the text (more for two digits). */ -@Composable -private fun rememberListMarker(type: BlockType, count: Int): ListMarker { - val font = listFontDp() - val wide = font * (if (count >= WIDE_MARKER_FROM) WIDE_MARKER_EXTRA else 0f) - val metrics = rememberLineMetrics() - return ListMarker(type = type, width = markerColumn(font) + wide, box = markerBoxSize(font), metrics = metrics) -} - -/** What keeps a list item from being edited: the whole editor ([editorLocked]) or this checklist's text. */ -private class ItemLocks(val editorLocked: Boolean, val textLocked: Boolean) - -/** A list item's text field state, what Find tints in it, and its list's [metrics]. */ -private class ItemField(val state: TextFieldState, val highlight: FieldHighlight, val metrics: LineMetrics) - -/** - * How a list marks its items: checkboxes, bullets or numbers in a hanging-indent column of - * [width]; [number] is the item's own. [metrics] are measured once per list, not per item. - */ -private data class ListMarker( - val type: BlockType, - val width: Dp, - val box: Dp, - val metrics: LineMetrics, - val number: Int = 0, -) { - fun forItem(number: Int) = copy(number = number) -} - -@Composable -private fun ListItemRow( - item: ItemListBlock, - marker: ListMarker, - field: ItemField, - editor: ListEditor, - locks: ItemLocks, - 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( - modifier = modifier.fillMaxWidth(), - verticalAlignment = if (checklist) Alignment.Top else Alignment.CenterVertically, - ) { - when (marker.type) { - BlockType.LIST_CHECK -> RowCheckMark( - state = checkState(item, state.text.toString().ifBlank { placeholder }, locks, editor), - marker = marker, - ) - - BlockType.LIST_NUMBER -> Marker("${marker.number}.", marker.width, EnclyTheme.typography.dataSmall) - - // A bullet says nothing TalkBack should read out before every item. - else -> Marker("•", marker.width, listTextStyle(), Modifier.clearAndSetSemantics { }) - } - ListItemField( - field = field, - input = editor.inputFor(item.id), - checked = checklist && item.isCheck, - placeholder = placeholder, - locks = locks, - modifier = Modifier - .weight(1f) - .then( - if (checklist) { - Modifier.padding(vertical = EnclyTheme.spacing.textGap) - } else { - Modifier.alignByBaseline() - }, - ) - .then(fieldModifier) - .onPreviewKeyEvent { event -> - val selection = state.selection - val line = if (event.isArrow()) { - caretLine(field.highlight.geometry(), state.text.length, selection.start) - } else { - null - } - KeyboardUtils.handleKeyEvent( - event = event, - text = state.text, - cursorPosition = if (selection.collapsed) selection.start else -1, - onBackspaceAtStart = { editor.backspaceAtStart(item.id) }, - onNavigateUp = { editor.moveFocus(item.id, CaretMove.Up(line?.x)) }, - onNavigateDown = { editor.moveFocus(item.id, CaretMove.Down(line?.x)) }, - onNavigateLeft = { editor.moveFocus(item.id, CaretMove.Left) }, - onNavigateRight = { editor.moveFocus(item.id, CaretMove.Right) }, - line = line, - ) - }, - ) - } -} - -/** The list's edits, all by item id, so a pending edit can never land on the wrong item. */ -private class ListEditor( - private val block: Block.ListBlock, - private val actions: () -> BlockActions, - private val focusItem: (ItemFocus) -> Unit, -) { - private val items: List get() = block.items.value - - private fun update(mergeable: Boolean, change: MutableList.() -> Unit) { - actions().onListItemsChanged(items.toMutableList().apply(change), mergeable) - } - - fun setText(itemId: String, text: String) = update(mergeable = true) { - val at = indexOfFirst { it.id == itemId } - if (at >= 0) this[at] = this[at].copy(value = text) - } - - fun setChecked(itemId: String, checked: Boolean) = update(mergeable = false) { - val at = indexOfFirst { it.id == itemId } - if (at >= 0) this[at] = this[at].copy(isCheck = checked) - } - - private val inputs = mutableMapOf() - - /** The writing rules of item [itemId]'s field: Enter and pasted lines go to the editor. */ - fun inputFor(itemId: String): BlockInput = - inputs.getOrPut(itemId) { BlockInput(BlockField.ListItem(itemId), actions) } - - /** Backspace at the start of an item (see listBackspaceAtStart). */ - fun backspaceAtStart(itemId: String): Boolean = actions().onListBackspaceAtStart(itemId) - - /** - * A cursor key that leaves item [itemId]'s field: the item above or below takes the cursor (at the - * same column for Up and Down), and from the first or last item the block above or below does. - * False when there is nowhere to go; nothing is added. - */ - fun moveFocus(itemId: String, move: CaretMove): Boolean { - val at = items.indexOfFirst { it.id == itemId } - val neighbour = items.getOrNull(if (move.goesUp) at - 1 else at + 1) - if (neighbour == null) return actions().navigate(move) - val focus = when (move) { - is CaretMove.Up -> ItemFocus(neighbour.id, null, move.x, fromBelow = true) - is CaretMove.Down -> ItemFocus(neighbour.id, null, move.x) - CaretMove.Left -> ItemFocus(neighbour.id, Int.MAX_VALUE) - CaretMove.Right -> ItemFocus(neighbour.id, 0) - } - focusItem( - when { - // Without a known column, Up lands at the end of the item above and Down at the start of the one below. - focus.caretX == null && move is CaretMove.Up -> ItemFocus(neighbour.id, Int.MAX_VALUE) - - focus.caretX == null && move is CaretMove.Down -> ItemFocus(neighbour.id, 0) - - else -> focus - }, - ) - return true - } -} - -@Composable -internal fun listTextStyle(): TextStyle { - val fontStyles = rememberFontStyles() - val spacing = LocalNoteLineSpacing.current - return MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface, - fontFamily = fontStyles.families.body, - fontSize = fontStyles.sizes.list, - lineHeight = spacing.lineHeight(fontStyles.sizes.list, MaterialTheme.typography.bodyLarge.lineHeight), - // The extra (or missing) line height is shared above and below the glyphs, so a line's text - // is centred in its line box at every spacing and size: the marker centres on that box. - lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None), - ) -} - -/** A bullet or number in its hanging-indent column, on the item's first baseline. */ -@Composable -private fun RowScope.Marker(text: String, width: Dp, style: TextStyle, modifier: Modifier = Modifier) { - val fontStyles = rememberFontStyles() - Text( - text = text, - style = style.copy(fontSize = fontStyles.sizes.list), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = modifier - .width(width) - .alignByBaseline(), - ) -} - -/** - * The checklist box, flush with the paragraphs above, in the marker column. Its middle sits on the - * middle of the item's first line at every line spacing and size ([LineMetrics], taken from the - * text style, not from any item's layout, so it cannot lag or drift between rows); its side and the - * gap after it grow with the text ([ListMarker]). The tap target is the column, widened to 48dp by - * Compose's minimum touch size. Announced with the item's text, so TalkBack says what is checked. - */ -@Composable -private fun RowScope.RowCheckMark(state: CheckState, marker: ListMarker) { - val metrics = marker.metrics - val pad = with(LocalDensity.current) { EnclyTheme.spacing.textGap.toPx() } - Layout( - content = { - EnclyCheckbox( - checked = state.checked, - onCheckedChange = null, - size = CheckboxSize.COMPACT, - boxSize = marker.box, - modifier = Modifier.testTag(CHECK_BOX_TAG), - ) - }, - modifier = Modifier - .toggleable( - value = state.checked, - enabled = state.enabled, - role = Role.Checkbox, - onValueChange = state.onToggle, - ) - .semantics { contentDescription = state.label }, - ) { measurables, constraints -> - val box = measurables.single().measure(constraints.copy(minWidth = 0, minHeight = 0)) - val centre = pad + metrics.centre - val top = (centre - box.height / 2f).roundToInt().coerceAtLeast(0) - layout(marker.width.roundToPx(), top + box.height) { box.place(0, top) } - } -} - -private fun checkState(item: ItemListBlock, label: String, locks: ItemLocks, editor: ListEditor) = - CheckState(item.isCheck, label, enabled = !locks.editorLocked, onToggle = { editor.setChecked(item.id, it) }) - -/** What a checklist box shows and does: ticked or not, the item's text for TalkBack, and its toggle. */ -private class CheckState( - val checked: Boolean, - val label: String, - val enabled: Boolean, - val onToggle: (Boolean) -> Unit, -) - -/** The test tag of the drawn checkbox square. */ -internal const val CHECK_BOX_TAG = "checklist-box" - -@Composable -private fun ListItemField( - field: ItemField, - input: BlockInput, - checked: Boolean, - placeholder: String, - locks: ItemLocks, - modifier: Modifier = Modifier, -) { - val state = field.state - val highlight = field.highlight - val metrics = field.metrics - val base = listTextStyle() - val emptyShift = metrics.emptyShift.roundToInt() - val lineHeightDp = with(LocalDensity.current) { metrics.lineHeight.toDp() } - var focused by remember { mutableStateOf(false) } - // Checked items stay editable; they are only muted and struck through. - val mutedColor = MaterialTheme.colorScheme.onSurfaceVariant - val style = if (checked) base.copy(color = mutedColor) else base - val fontPx = with(LocalDensity.current) { base.fontSize.toPx() } - BasicTextField( - state = state, - // A locked checklist's text cannot take focus at all, so no cursor and no keyboard. - enabled = !locks.textLocked, - readOnly = locks.editorLocked, - inputTransformation = input, - textStyle = style, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = WritingKeyboard, - onTextLayout = highlight.onTextLayout, - modifier = modifier - .onFocusChanged { focused = it.isFocused } - .then(highlight.modifier) - .strikeThrough(highlight, checked, mutedColor, fontPx), - decorator = { innerTextField -> - // An empty field is laid out without the line height; it is lowered and given the height of - // a typed line so a new item looks, and has its caret, where it will once typing starts. - Box(modifier = Modifier.fillMaxWidth().heightIn(min = lineHeightDp)) { - if (state.text.isEmpty() && !locks.editorLocked && focused) { - Text( - text = placeholder, - style = base.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), - ), - ) - } - Box(modifier = Modifier.offset { IntOffset(0, if (state.text.isEmpty()) emptyShift else 0) }) { - innerTextField() - } - } - }, - ) -} 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 deleted file mode 100644 index 76be564c..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/QuoteBlock.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.pasich.encly.presentation.editor.blocks - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.Icon -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.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -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.dynamicBlocks.Block -import com.pasich.encly.presentation.designsystem.EDITOR_PLACEHOLDER_ALPHA -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.lineHeight -import com.pasich.encly.presentation.editor.BlockActions -import com.pasich.encly.presentation.screen.editnote.LocalNoteLineSpacing -import com.pasich.encly.presentation.screen.editnote.rememberFontStyles -import com.pasich.encly.ui.theme.EnclyTheme - -/** The closing quote mark is the opening one turned around. */ -private const val HALF_TURN = 180f - -/** - * [focusRequester] and [fieldModifier] reach the quote's text field, [modifier] the card that - * draws the quote. - */ -@Composable -fun QuoteBlock( - block: Block.QuoteBlock, - blockActions: BlockActions, - focusRequester: FocusRequester, - modifier: Modifier = Modifier, - fieldModifier: Modifier = Modifier, - isLocked: Boolean = false, -) { - val fontStyles = rememberFontStyles() - val state = rememberBlockTextFieldState(block.text, blockActions) - 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) - RegisterCaretAtX(blockActions, state, highlight) - var focused by remember { mutableStateOf(false) } - val quoteStyle = EnclyTheme.typography.quote.copy( - color = MaterialTheme.colorScheme.onSurface, - fontSize = fontStyles.sizes.quote, - lineHeight = LocalNoteLineSpacing.current.lineHeight( - fontSize = fontStyles.sizes.quote, - normal = EnclyTheme.typography.quote.lineHeight, - ), - ) - - // A tonal card between an opening and a closing quote mark in `primary`, 8 dp below the block - // above (design spec §4.4). - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.labelGap), - modifier = modifier - .padding(top = EnclyTheme.spacing.xs) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.medium) - .padding(horizontal = EnclyTheme.spacing.fieldGap, vertical = EnclyTheme.spacing.m), - ) { - Icon( - EnclyIcons.Quote, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(EnclyTheme.spacing.iconMedium), - ) - BasicTextField( - state = state, - readOnly = isLocked, - inputTransformation = input, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = WritingKeyboard, - textStyle = quoteStyle, - onTextLayout = highlight.onTextLayout, - modifier = fieldModifier - .focusRequester(focusRequester) - .onFocusChanged { focused = it.isFocused } - .fillMaxWidth() - .then(highlight.modifier) - .textBlockKeys(state, blockActions, highlight), - decorator = { innerTextField -> - QuoteDecoration(state.text.isEmpty() && focused, quoteStyle, innerTextField) - }, - ) - // The closing mark: the opening one turned around, at the end of the quote. - Icon( - EnclyIcons.Quote, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .align(Alignment.End) - .size(EnclyTheme.spacing.iconMedium) - .rotate(HALF_TURN), - ) - } -} - -@Composable -private fun QuoteDecoration(showPlaceholder: Boolean, style: TextStyle, innerTextField: @Composable () -> Unit) { - Box(modifier = Modifier.fillMaxWidth()) { - if (showPlaceholder) { - Text( - text = stringResource(R.string.quote), - style = style.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), - ), - ) - } - innerTextField() - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlock.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlock.kt deleted file mode 100644 index 42855185..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlock.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.pasich.encly.presentation.editor.blocks - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import com.pasich.encly.R -import com.pasich.encly.ui.theme.EnclyTheme - -/** - * A 1 dp rule in a 24 dp tap target that opens the block's sheet. Taller than the rule is only so a - * finger can find it; much taller made it easy to hit by accident when placing the cursor nearby. - */ -@Composable -fun SeparatorBlock(onClick: () -> Unit, modifier: Modifier = Modifier, isLocked: Boolean = false) { - val label = stringResource(R.string.block_separator) - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .fillMaxWidth() - .height(EnclyTheme.spacing.l) - // Its sheet only moves or deletes it: nothing to offer in a locked editor. - .clickable( - enabled = !isLocked, - role = Role.Button, - onClickLabel = stringResource(R.string.more_options), - onClick = onClick, - ) - .semantics { contentDescription = label }, - ) { - HorizontalDivider(thickness = EnclyTheme.spacing.hairline, color = MaterialTheme.colorScheme.outlineVariant) - } -} 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 deleted file mode 100644 index de2e191e..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SlashMenuPopup.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.pasich.encly.presentation.editor.blocks - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.CommandMenuItem -import com.pasich.encly.presentation.designsystem.EnclyCommandMenu -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyToolbarReserve -import com.pasich.encly.presentation.editor.SlashActions -import com.pasich.encly.presentation.editor.state.SlashCommand -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map - -/** - * The slash picker of a paragraph: a small list of block kinds under the field, filtered by what - * was typed after the "/". It never takes focus, so the keyboard stays on the paragraph and the - * user goes on typing; Enter and the arrow keys are handled in the paragraph's key handling and - * in [BlockInput]. It stays hidden until the "/" is committed text, so a character still under IME - * composition never opens it. - */ -@Composable -internal fun SlashMenuPopup(field: TextFieldState, slash: SlashActions, modifier: Modifier = Modifier) { - // Only whether this paragraph owns the menu is observed here, so the other paragraphs do not - // recompose each time the open menu changes (a typed letter, a moved highlight). A menu opens - // only after the "/" is typed, so starting from "not the owner" never hides an open one. - val owns by remember(slash.menu, slash.blockId) { - slash.menu.map { it?.blockId == slash.blockId }.distinctUntilChanged() - }.collectAsState(initial = false) - if (owns) OpenSlashMenu(field, slash, modifier) -} - -@Composable -private fun OpenSlashMenu(field: TextFieldState, slash: SlashActions, modifier: Modifier = Modifier) { - val all by slash.menu.collectAsState() - val menu = all?.takeIf { it.blockId == slash.blockId } - val labels = slashLabels() - val currentSlash by rememberUpdatedState(slash) - - LaunchedEffect(menu != null, labels) { - if (menu != null) currentSlash.setLabels(labels) - } - val awaitingCommit = menu != null && !menu.committed - LaunchedEffect(awaitingCommit) { - if (awaitingCommit) { - snapshotFlow { field.composition == null }.first { it } - currentSlash.commit() - } - } - if (menu == null || !menu.isShown) return - - val density = LocalDensity.current - val reserve = with(density) { EnclyToolbarReserve.roundToPx() } - val bottomInset = WindowInsets.safeDrawing.getBottom(density) - val position = remember(reserve, bottomInset) { UnderOrOverField(bottomLimit = { it - bottomInset - reserve }) } - Popup( - popupPositionProvider = position, - onDismissRequest = { currentSlash.dismiss() }, - // Not focusable: the keyboard and the caret stay in the paragraph. - properties = PopupProperties(focusable = false), - ) { - EnclyCommandMenu( - items = menu.matches.map { CommandMenuItem(it.icon(), labels.getValue(it)) }, - highlighted = menu.highlighted, - title = stringResource(R.string.slash_menu_title), - onSelect = { currentSlash.select(menu.matches[it]) }, - modifier = modifier, - ) - } -} - -/** The labels built once for the whole editor (see [rememberSlashLabels]); null where nothing provides them. */ -internal val LocalSlashLabels = compositionLocalOf?> { null } - -/** The localized label of every command, for the list and for matching the query. */ -@Composable -private fun slashLabels(): Map = LocalSlashLabels.current ?: rememberSlashLabels() - -/** The localized label of every command, built once per locale: provide it as [LocalSlashLabels] above the editor. */ -@Composable -internal fun rememberSlashLabels(): Map { - val resources = LocalResources.current - val configuration = LocalConfiguration.current - return remember(resources, configuration) { - SlashCommand.entries.associateWith { command -> - command.headingLevel?.let { resources.getString(command.label, it) } ?: resources.getString(command.label) - } - } -} - -private fun SlashCommand.icon(): ImageVector = when (this) { - SlashCommand.TEXT -> EnclyIcons.Type - SlashCommand.H1 -> EnclyIcons.Heading1 - SlashCommand.H2 -> EnclyIcons.Heading2 - SlashCommand.H3 -> EnclyIcons.Heading3 - SlashCommand.H4 -> EnclyIcons.Heading4 - SlashCommand.CHECKLIST -> EnclyIcons.Checklist - SlashCommand.BULLETS -> EnclyIcons.ListBullet - SlashCommand.NUMBERED -> EnclyIcons.ListNumbered - SlashCommand.QUOTE -> EnclyIcons.Quote - SlashCommand.LINK -> EnclyIcons.Link - SlashCommand.DIVIDER -> EnclyIcons.Separator -} - -/** - * Under the field when the list fits above [bottomLimit] (the window's height less the keyboard and - * the editor toolbar), else over it; always inside the window's width. - */ -private class UnderOrOverField(private val bottomLimit: (windowHeight: Int) -> Int) : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) - val below = anchorBounds.bottom - val fitsBelow = below + popupContentSize.height <= bottomLimit(windowSize.height) - val y = if (fitsBelow) below else (anchorBounds.top - popupContentSize.height).coerceAtLeast(0) - return IntOffset(x, y) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt deleted file mode 100644 index e050c363..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.pasich.encly.presentation.editor.blocks - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -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.dynamicBlocks.Block -import com.pasich.encly.presentation.designsystem.EDITOR_PLACEHOLDER_ALPHA -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 - -/** - * Keyboard of the note's writing fields: sentence case, suggestions and autocorrect. The app - * already asks keyboards not to learn from what is typed (SecureTextInputBoundary). - */ -internal val WritingKeyboard = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - autoCorrectEnabled = true, - keyboardType = KeyboardType.Text, -) - -/** A paragraph. Its placeholder shows only when [showPlaceholder] (focused, or the note's only block). */ -@Composable -fun TextBlock( - block: Block.TextBlock, - blockActions: BlockActions, - modifier: Modifier = Modifier, - isLocked: Boolean = false, - showPlaceholder: Boolean = true, -) { - val fontStyles = rememberFontStyles() - val spacing = LocalNoteLineSpacing.current - val state = rememberBlockTextFieldState(block.text, blockActions) - val style = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface, - fontSize = fontStyles.sizes.textBlock, - fontFamily = fontStyles.families.body, - lineHeight = spacing.lineHeight(fontStyles.sizes.textBlock, MaterialTheme.typography.bodyLarge.lineHeight), - ) - val actions by rememberUpdatedState(blockActions) - val input = remember { BlockInput(BlockField.Paragraph) { actions } } - val highlight = rememberFieldHighlight(blockActions.highlights, block.id) - RegisterCaretAtX(blockActions, state, highlight) - - BasicTextField( - state = state, - // Read-only, not disabled: a locked note's text can still be selected and copied. - readOnly = isLocked, - inputTransformation = input, - textStyle = style, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = WritingKeyboard, - onTextLayout = highlight.onTextLayout, - modifier = modifier - .fillMaxWidth() - .then(highlight.modifier) - .textBlockKeys(state, blockActions, highlight), - decorator = { innerTextField -> - Box(modifier = Modifier.fillMaxWidth()) { - SlashMenuPopup(field = state, slash = blockActions.slash) - if (showPlaceholder && state.text.isEmpty()) { - Text( - text = stringResource(block.placeholder), - style = style.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), - ), - ) - } - innerTextField() - } - }, - ) -} 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 deleted file mode 100644 index 9f2e2fd2..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlockKeys.kt +++ /dev/null @@ -1,68 +0,0 @@ -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.focus.caretLine -import com.pasich.encly.presentation.editor.state.BlockRemoveAction -import com.pasich.encly.presentation.editor.state.CaretMove - -/** - * Keyboard handling of a text-bearing block editing [state]: Backspace at the start joins the - * block to the one above (a heading or quote first becomes a paragraph), Backspace in an empty - * paragraph removes it. Up from the first visual line and Down from the last move to the block - * above or below at the same column, Left at the start and Right at the end to its end or start; - * blocks without a field are skipped, and in the last block Down does nothing (no block is - * added). Enter is left to the field: its line break reaches [BlockInput], from a hardware key - * and a soft keyboard alike. [highlight] holds the field's measured lines. - */ -internal fun Modifier.textBlockKeys( - state: TextFieldState, - blockActions: BlockActions, - highlight: FieldHighlight, -): Modifier = onPreviewKeyEvent { event -> - val selection = state.selection - // Only the arrow keys need to know where the cursor is among the lines. - val line = if (event.isArrow()) caretLine(highlight.geometry(), state.text.length, selection.start) else null - 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. - cursorPosition = if (selection.collapsed) selection.start else -1, - onBackspaceEmpty = { blockActions.onRemoveBlock(BlockRemoveAction.REMOVE_BACKSPACE) }, - onBackspaceAtStart = { blockActions.onBackspaceAtStart() }, - onNavigateUp = { blockActions.navigate(CaretMove.Up(line?.x)) }, - onNavigateDown = { blockActions.navigate(CaretMove.Down(line?.x)) }, - onNavigateLeft = { blockActions.navigate(CaretMove.Left) }, - onNavigateRight = { blockActions.navigate(CaretMove.Right) }, - line = line, - ) -} - -/** Whether [this] is one of the four arrow keys. */ -internal fun KeyEvent.isArrow(): Boolean = - key == Key.DirectionUp || key == Key.DirectionDown || key == Key.DirectionLeft || key == Key.DirectionRight - -/** - * 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/focus/BlockFocusRegistry.kt b/app/src/main/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistry.kt deleted file mode 100644 index 9389a468..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistry.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.pasich.encly.presentation.editor.focus - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.focus.FocusRequester -import com.pasich.encly.core.AppLogger -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.presentation.editor.state.FocusRequest -import com.pasich.encly.presentation.editor.state.canTakeFocus -import kotlinx.coroutines.delay - -/** How many frames' worth of retries a new block gets to compose before focusing it. */ -private const val FOCUS_RETRIES = 5 -private const val FOCUS_RETRY_DELAY_MS = 50L - -/** Retries before scrolling: a new block on screen needs no scroll, only its first frames. */ -private const val COMPOSE_WAITS = 1 - -private const val TAG = "BlockFocusRegistry" - -/** - * The editor's focus targets, by block id: how to focus each block's field and how to put its - * cursor at the end. It lives in composition ([rememberBlockFocusRegistry]); the ViewModel only - * says *which* block to focus ([FocusRequest]), so no UI object outlives the screen. - * - * Keyed by block id, a request made for a block reaches that block even after blocks around it - * were inserted, removed, moved or replaced. [blocks] is the editor's live block list. - */ -class BlockFocusRegistry(private val blocks: () -> List) { - private val focusTargets = mutableMapOf Unit>() - private val caretCallbacks = mutableMapOf Unit>() - private val caretXCallbacks = mutableMapOf Unit>() - - /** - * Registers how to focus the field of block [blockId]. - * @return a function that removes this registration (not a newer one for the same block) - */ - fun registerFocusTarget(blockId: String, requestFocus: () -> Unit): () -> Unit = - focusTargets.register(blockId) { request -> - requestFocus() - val offset = request.caretOffset - val x = request.caretX - when { - x != null -> caretXCallbacks[blockId]?.invoke(x, request.fromBelow) - offset != null -> caretCallbacks[blockId]?.invoke(offset) - } - } - - /** - * Registers a block with several fields (a list): [requestFocus] focuses the item the - * request names, else its last field with the cursor at the end when asked for the end, - * else its first field. - */ - fun registerFieldsFocusTarget(blockId: String, requestFocus: (FocusRequest) -> Unit): () -> Unit = - focusTargets.register(blockId, requestFocus) - - /** - * Registers how to put the cursor of block [blockId]'s field at an offset ([Int.MAX_VALUE]: - * the end; the field clamps it to its text). - * @return a function that removes this registration (not a newer one for the same block) - */ - fun registerCaret(blockId: String, callback: (Int) -> Unit): () -> Unit = caretCallbacks.register(blockId, callback) - - /** - * Registers how to put the cursor of block [blockId]'s field at a horizontal position, on its - * last line when it arrives from below (the callback's second argument), else on its first. - * @return a function that removes this registration (not a newer one for the same block) - */ - fun registerCaretAtX(blockId: String, callback: (Float, Boolean) -> Unit): () -> Unit = - caretXCallbacks.register(blockId, callback) - - /** Focuses the field of [blockId] now, the cursor at its end with [cursorToEnd]; see [focus]. */ - fun focus(blockId: String, cursorToEnd: Boolean = false): Boolean = focus(FocusRequest(blockId, cursorToEnd)) - - /** - * Carries out [request] now; always issues it, even for the field that has focus already. - * Returns false when the field is not composed (yet). - */ - fun focus(request: FocusRequest): Boolean { - val target = focusTargets[request.blockId] ?: return false - try { - target(request) - } catch (e: IllegalStateException) { - // The FocusRequester is not attached to a focusable node (a saved link). - AppLogger.w(TAG, "Focus request failed: ${e.javaClass.simpleName}") - } - return true - } - - /** - * Carries out [request] as soon as the block's field is composed; gives up after a few frames. - * A block just added is composed within a frame or two where it is on screen; a block of a - * lazy list off screen is not composed at all, so [bringIntoView] then scrolls to it. - */ - suspend fun focusWhenComposed(request: FocusRequest, bringIntoView: suspend (String) -> Unit = {}) { - if (focusWithin(request, attempts = 1 + COMPOSE_WAITS)) return - bringIntoView(request.blockId) - if (!focusWithin(request, attempts = FOCUS_RETRIES)) AppLogger.w(TAG, "Focus target never appeared") - } - - /** Tries [request] up to [attempts] times, a short wait apart; whether it was carried out. */ - private suspend fun focusWithin(request: FocusRequest, attempts: Int): Boolean { - repeat(attempts) { attempt -> - if (attempt > 0) delay(FOCUS_RETRY_DELAY_MS) - if (focus(request)) return true - } - return false - } - - /** - * The next block after [blockId] that has a field to put the cursor in (a separator or a saved - * link card has none); null when there is none. - */ - fun nextFocusableBlock(blockId: String): Block? { - val list = blocks() - val index = list.indexOfFirst { it.id == blockId } - return if (index >= 0) list.drop(index + 1).firstOrNull { it.canTakeFocus() } else null - } - - /** The focusable block above [blockId] (the nearest one with a field); null when there is none. */ - fun previousFocusableBlock(blockId: String): Block? { - val list = blocks() - val index = list.indexOfFirst { it.id == blockId } - return if (index > 0) list.take(index).lastOrNull { it.canTakeFocus() } else null - } -} - -private fun MutableMap.register(blockId: String, action: T): () -> Unit { - this[blockId] = action - return { if (this[blockId] === action) remove(blockId) } -} - -/** The focus registry of one editor showing [blocks] (its live block list). */ -@Composable -fun rememberBlockFocusRegistry(blocks: List): BlockFocusRegistry = - remember(blocks) { BlockFocusRegistry { blocks } } - -/** Registers [focusRequester], the field of block [blockId], while it is composed. */ -@Composable -fun RegisterFocusRequester(blockId: String, registry: BlockFocusRegistry, focusRequester: FocusRequester) { - DisposableEffect(blockId, registry, focusRequester) { - val unregister = registry.registerFocusTarget(blockId) { focusRequester.requestFocus() } - onDispose { unregister() } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt b/app/src/main/java/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt deleted file mode 100644 index fbfbeed8..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt +++ /dev/null @@ -1,411 +0,0 @@ -package com.pasich.encly.presentation.editor.persistence - -import com.pasich.encly.core.AppLogger -import com.pasich.encly.core.common.suspendRunCatching -import com.pasich.encly.core.serialization.BlockConverter -import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.repository.NotesRepository -import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase -import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.hasContent -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext - -enum class SaveStatusNote { - OLD, - SAVING, - SAVED, - LOADING, - - /** The last save failed: the editor still holds changes the database does not. */ - FAILED, -} - -data class LoadNoteState(val note: Note = Note(id = -1), val backupNote: Note = Note(id = -1)) - -/** Quiet time after the last edit before the note is autosaved. */ -const val AUTOSAVE_DEBOUNCE_MS = 2000L - -private const val TAG = "NotePersistence" - -/** - * Reads and writes the note an editor works on: load, the debounced autosave, explicit saves, - * discard, trash/restore, duplicate and delete. [blocks] is the editor's current content. - * - * Every write runs under one mutex, so autosave, an explicit exit-save and the last-chance save - * of a closing screen never interleave. Once the editor is [close]d (after trash, restore, - * delete or discard, or an explicit exit), no queued or later save writes the note again, so it - * cannot bring a trashed note back or write edits over a discard. - */ -@Suppress("TooManyFunctions", "LongParameterList") // Every write of the note shares one mutex and its use cases. -class NotePersistence( - private val notesRepository: NotesRepository, - private val updateNoteTrashStatusUseCase: UpdateNoteTrashStatusUseCase, - private val updateNoteArchiveStatusUseCase: UpdateNoteArchiveStatusUseCase, - private val ioDispatcher: CoroutineDispatcher, - private val readOnly: Boolean, - private val blocks: () -> List, - private val copyTitle: NoteCopyTitle, - loading: Boolean = false, -) { - private val _state = MutableStateFlow(LoadNoteState()) - val state: StateFlow = _state.asStateFlow() - - private val _status = MutableStateFlow(SaveStatusNote.OLD) - val status: StateFlow = _status.asStateFlow() - - // True when a note had stored content that could not be read. Nothing may then be saved, - // so an edit cannot overwrite the unreadable original. - private val _contentLoadFailed = MutableStateFlow(false) - val contentLoadFailed: StateFlow = _contentLoadFailed.asStateFlow() - - private val saveMutex = Mutex() - - @Volatile - private var closed = false - - // False while a stored note (or the source of a copy) is still to be read ([loading]). Until - // then the editor does not hold that note yet, so nothing may be saved: a save would see - // id = -1 and insert a duplicate. - @Volatile - private var loaded = !loading - - // The title, tag and content last written to (or read from) the database. The autosave - // only writes when the editor differs from it. - private var persistedSnapshot: String = snapshotOf(_state.value.note, "[]") - - // The note as the editor opened it, for [hasChanges]. - private var openedSnapshot: String = persistedSnapshot - - private var autosaveJob: Job? = null - - /** - * The editor's content as stored JSON. The blocks' values are copied where this is called - * (cheap), and serialized on [ioDispatcher], so a long note never serializes on the main thread. - */ - private suspend fun contentJson(): String { - val content = blocks().frozen() - return withContext(ioDispatcher) { BlockConverter.blocksToJson(content) } - } - - /** False when nothing may be saved: closed, still loading, read-only or unreadable. */ - private val canPersist: Boolean - get() = !closed && loaded && !readOnly && !_contentLoadFailed.value - - /** - * Reads note [noteId] (as a new, unsaved copy with [isCopy]) and hands its blocks to - * [showBlocks]. Returns false when the note does not exist or could not be read; the - * editor must then stay read-only. - */ - suspend fun load(noteId: Long, isCopy: Boolean, showBlocks: (List) -> Unit): Boolean { - _status.value = SaveStatusNote.LOADING - val note = suspendRunCatching { notesRepository.getNoteById(noteId) } - .onFailure { AppLogger.e(TAG, "Error loading note", it) } - .getOrNull() - if (note == null) { - _contentLoadFailed.value = true - _status.value = SaveStatusNote.OLD - return false - } - - // A valid title-only note ("[]", or no content) keeps the editor's blank block. - val parsed = if (note.value.isEmpty()) { - emptyList() - } else { - withContext(ioDispatcher) { BlockConverter.jsonToBlocksOrNull(note.value) } - } - if (parsed == null) { - _contentLoadFailed.value = true - AppLogger.e(TAG, "Stored note content could not be parsed") - } - // The blocks and the note (its id) are published together, so no save can ever see the - // note's content without its id. - parsed?.takeIf { it.isNotEmpty() }?.let(showBlocks) - val shown = if (isCopy) note.asCopy(copyTitle(note.title)) else note - _state.value = LoadNoteState(note = shown, backupNote = shown) - persistedSnapshot = snapshotOf(shown, contentJson()) - openedSnapshot = persistedSnapshot - loaded = true - _status.value = SaveStatusNote.OLD - // Unreadable content: the note (its title) is shown, but the editor stays read-only. - return parsed != null - } - - /** - * Saves the note [AUTOSAVE_DEBOUNCE_MS] after the last change of [contentChanges], the - * title, tag, pin or colour, if the editor then differs from what is stored. Opening a note saves nothing, and - * the first edit is saved like any other. - */ - @OptIn(FlowPreview::class) - fun startAutosave(scope: CoroutineScope, contentChanges: Flow) { - autosaveJob?.cancel() - autosaveJob = scope.launch { - combine(contentChanges, _state.map { it.note.metaKey() }.distinctUntilChanged()) { _, _ -> } - .debounce(AUTOSAVE_DEBOUNCE_MS) - .collect { save(onlyIfChanged = true) } - } - } - - /** Changes the note's metadata (title, tag, pin, colour) in the editor; it is stored with the next save. */ - fun updateNote(transform: (Note) -> Note) { - _state.value = _state.value.copy(note = transform(_state.value.note)) - } - - /** - * Stores the note and returns once the database reports the result. A blank new note is - * not stored; a save that is not allowed (see [canPersist]) is a successful no-op. With - * [onlyIfChanged] (the autosave), nothing is written when the editor matches what is stored. - */ - suspend fun save(onlyIfChanged: Boolean = false): Boolean = saveMutex.withLock { - if (!canPersist) { - if (_status.value != SaveStatusNote.LOADING && _status.value != SaveStatusNote.FAILED) { - _status.value = SaveStatusNote.OLD - } - return@withLock true - } - val currentNote = _state.value.note - if (currentNote.id == -1L && currentNote.title.isBlank() && blocks().none { it.hasContent() }) { - return@withLock true - } - - val blocksJson = contentJson() - val snapshot = snapshotOf(currentNote, blocksJson) - if (onlyIfChanged && snapshot == persistedSnapshot) return@withLock true - - _status.value = SaveStatusNote.SAVING - val saved = suspendRunCatching { - if (currentNote.id != -1L) { - val stored = withContext(ioDispatcher) { - notesRepository.updateStored(currentNote, _state.value.backupNote, blocksJson) - } - // The editor shows when the note was last edited. - stored?.let { note -> updateNote { it.copy(date = note.date, description = note.description) } } - stored != null - } else { - val insertedId = withContext(ioDispatcher) { notesRepository.insertDraft(currentNote, blocksJson) } - // Merged into the latest state: a title or tag changed while the insert ran is - // kept, and the next autosave stores it. - insertedId?.let { id -> - updateNote { it.copy(id = id, value = blocksJson, date = System.currentTimeMillis()) } - } - insertedId != null - } - }.onFailure { AppLogger.e(TAG, "Note persistence failed", it) }.getOrDefault(false) - - if (saved) persistedSnapshot = snapshot - _status.value = if (saved) SaveStatusNote.SAVED else SaveStatusNote.FAILED - saved - } - - /** - * Puts the stored note back as it was when the editor opened it. A new note (or copy) that - * was already autosaved is deleted instead of leaving a ghost note behind. - */ - suspend fun discard(): Boolean = saveMutex.withLock { - if (!loaded) return@withLock false - val currentNote = _state.value.note - val backupNote = _state.value.backupNote - - val discarded = when { - backupNote.id != -1L -> withContext(ioDispatcher) { notesRepository.updateNote(backupNote) } - currentNote.id != -1L -> withContext(ioDispatcher) { notesRepository.deleteNoteById(currentNote.id) } - else -> Result.success(Unit) - }.isSuccess - - if (discarded) close() - _status.value = SaveStatusNote.OLD - discarded - } - - /** Takes the note out of the trash, with the editor's content. */ - suspend fun restoreFromTrash(): Boolean = saveMutex.withLock { - val currentNote = _state.value.note - if (!loaded || currentNote.id == -1L) return@withLock false - - val restored = currentNote.copy(value = contentJson(), isTrash = false) - val ok = updateNoteTrashStatusUseCase.invoke(restored, false).isSuccess - if (ok) { - _state.value = _state.value.copy(note = restored) - close() - } - ok - } - - /** Moves the note to the trash (a soft delete), with the editor's content. */ - suspend fun moveToTrash(): Boolean = saveMutex.withLock { - val currentNote = _state.value.note - if (!loaded) return@withLock false - if (currentNote.id == -1L) { - close() - return@withLock true - } - - val trashed = currentNote.copy(value = contentJson(), isTrash = true) - val ok = updateNoteTrashStatusUseCase.invoke(trashed, true).isSuccess - if (ok) { - // Keep the in-memory note in sync with the row: nothing may write isTrash=false back. - _state.value = _state.value.copy(note = trashed) - close() - } - ok - } - - /** - * Moves the note to the archive (unpinning it) or back to the list, with the editor's - * content. The editor is closed afterwards, like [moveToTrash]. - */ - suspend fun setArchived(archived: Boolean): Boolean = saveMutex.withLock { - val currentNote = _state.value.note - if (!loaded || currentNote.id == -1L) return@withLock false - - val updated = currentNote.copy( - value = contentJson(), - isArchived = archived, - isPinned = currentNote.isPinned && !archived, - ) - val ok = updateNoteArchiveStatusUseCase.invoke(updated, archived).isSuccess - if (ok) { - _state.value = _state.value.copy(note = updated) - close() - } - ok - } - - /** Whether the editor holds nothing to keep: a new note without a title or content. */ - val isBlankDraft: Boolean - get() = _state.value.note.let { it.id == -1L && it.title.isBlank() } && blocks().none { it.hasContent() } - - /** - * Whether the note differs from how the editor opened it (its title, tag or content), or a - * new note was already stored: what "discard" would undo. - */ - suspend fun hasChanges(): Boolean { - val (note, backup) = _state.value.let { it.note to it.backupNote } - if (note.id != backup.id) return true - return note.title != backup.title || note.tagId != backup.tagId || - note.isPinned != backup.isPinned || note.color != backup.color || - note.textSize != backup.textSize || note.lineSpacing != backup.lineSpacing || - snapshotOf(note, contentJson()) != openedSnapshot - } - - /** Stores a copy of the note, titled by [copyTitle]. Returns its id, or -1 on failure. */ - suspend fun duplicate(): Long = saveMutex.withLock { - val currentNote = _state.value.note - val content = contentJson() - withContext(ioDispatcher) { - notesRepository.insertNote( - Note.new(title = copyTitle(currentNote.title), value = content, tagId = currentNote.tagId) - .copy( - color = currentNote.color, - textSize = currentNote.textSize, - lineSpacing = currentNote.lineSpacing, - ), - ) - }.getOrNull()?.takeIf { it > 0L } ?: -1L - } - - /** Deletes the note from the database for good. */ - suspend fun delete(): Boolean = saveMutex.withLock { - val currentNoteId = _state.value.note.id - val deleted = when { - !loaded -> false - currentNoteId == -1L -> true - else -> notesRepository.deleteNoteById(currentNoteId).isSuccess - } - if (deleted) close() - deleted - } - - /** Ends editing: no queued or later save may write this note again. */ - fun close() { - closed = true - autosaveJob?.cancel() - } -} - -private fun snapshotOf(note: Note, blocksJson: String): String = - "${note.title}\u0000${note.tagId}\u0000${note.isPinned}\u0000${note.color}\u0000" + - "${note.textSize}\u0000${note.lineSpacing}\u0000$blocksJson" - -/** What besides the content makes the editor differ from the stored note; the autosave watches it. */ -private data class NoteMeta( - val title: String, - val tagId: Long?, - val isPinned: Boolean, - val color: String, - val textSize: Int?, - val lineSpacing: Int?, -) - -private fun Note.metaKey() = NoteMeta(title, tagId, isPinned, color, textSize, lineSpacing) - -/** - * Stores [note] with [blocksJson] as its content; returns the stored note, or null. Its date - * moves forward only when the title or content differ from [backup], the note as the editor - * opened it, and then an imported summary ([Note.description]) no longer describes it. - */ -private suspend fun NotesRepository.updateStored(note: Note, backup: Note, blocksJson: String): Note? { - val changed = backup.hasContentChanged(blocksJson, note.title) - val stored = if (changed) { - note.copy(value = blocksJson, date = System.currentTimeMillis(), description = "") - } else { - note.copy(value = blocksJson) - } - return stored.takeIf { updateNote(it).isSuccess } -} - -/** A copy of the blocks holding their current values, to serialize away from the editor. */ -private fun List.frozen(): List = map { block -> - when (block) { - is Block.TextBlock -> block.copy(text = MutableStateFlow(block.text.value)) - - is Block.HBlock -> block.copy(text = MutableStateFlow(block.text.value)) - - is Block.QuoteBlock -> block.copy(text = MutableStateFlow(block.text.value)) - - is Block.LinkBlock -> block.copy(block = MutableStateFlow(block.block.value)) - - is Block.ListBlock -> block.copy( - items = MutableStateFlow(block.items.value), - locked = MutableStateFlow(block.locked.value), - ) - - is Block.SeparatorBlock -> block - } -} - -/** Inserts [note] as a new row with [blocksJson] as its content; returns its id, or null. */ -private suspend fun NotesRepository.insertDraft(note: Note, blocksJson: String): Long? = insertNote( - Note.new(title = note.title, value = blocksJson, tagId = note.tagId) - .copy(isPinned = note.isPinned, color = note.color, textSize = note.textSize, lineSpacing = note.lineSpacing), -) - .getOrNull() - ?.takeIf { it > 0L } - -/** - * [this] note as a new, unsaved copy (id = -1) titled [copyTitle]. "No tag" stays null: it has - * exactly one representation. The copy keeps the colour but starts unpinned and unarchived. - */ -private fun Note.asCopy(copyTitle: String): Note = copy( - id = -1, - title = copyTitle, - date = System.currentTimeMillis(), - uid = "", - isPinned = false, - isArchived = 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 deleted file mode 100644 index 3695a332..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockDuplicate.kt +++ /dev/null @@ -1,43 +0,0 @@ -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(), - locked = MutableStateFlow(locked.value), - ) -} 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 deleted file mode 100644 index 85df4c6b..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorState.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.pasich.encly.presentation.editor.state - -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.SnapshotStateList -import com.pasich.encly.R -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.utils.BlockUtils -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map - -enum class BlockRemoveAction { REMOVE, REMOVE_BACKSPACE, REMOVE_BACKSPACE_LIST } - -/** - * The block editor's content: the blocks, their undo/redo history ([BlockOperations]) and - * which block the user works on ([selection]). Plain state without coroutines or UI objects, - * so it is testable on the JVM. The ViewModel decides *whether* an edit is allowed (a locked or - * read-only editor); this class decides *how* it is applied. - * - * Invariant: the note always has at least one block. - */ -@Suppress("TooManyFunctions") // The editor's single entry point: one function per kind of edit. -class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { - private val _blocks = SnapshotStateList().apply { add(emptyNoteBlock()) } - val blocks: List get() = _blocks - - private val history = BlockOperations(_blocks, clock) - - 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() - - private val _canRedo = MutableStateFlow(false) - val canRedo: StateFlow = _canRedo.asStateFlow() - - /** Emits when blocks are added, removed or moved, or any block's content changes. */ - @OptIn(ExperimentalCoroutinesApi::class) - val contentChanges: Flow = snapshotFlow { _blocks.toList() }.flatMapLatest { current -> - val contentFlows = current.mapNotNull { it.contentFlow() } - // combine() of no flows never emits; a note of only separators still changes. - if (contentFlows.isEmpty()) flowOf(Unit) else combine(contentFlows) { } - } - - /** - * Shows a stored note's [loaded] blocks in place of the current ones. The history starts - * 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() - afterChange() - } - - /** - * Adds [block] after the focused block (or the one the user last worked on) and focuses it. - * An empty first text block at that point is replaced instead, in one undo step, so undo can - * 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) - - val target = if (replacesEmptyFirst) anchor else anchor + 1 - if (replacesEmptyFirst) history.replaceBlock(anchor, block) else history.addBlock(target, block) - afterChange() - - selection.focusAt(target) - } - - /** - * Removes [block] if [action] allows it: one block always remains, and Backspace removes - * only an empty block. With [refocus], the block above (or the first focusable) takes focus. - */ - fun removeBlock(block: Block, action: BlockRemoveAction, refocus: Boolean = true) { - val index = _blocks.indexOfFirst { it.id == block.id } - val allowed = when (action) { - BlockRemoveAction.REMOVE, BlockRemoveAction.REMOVE_BACKSPACE_LIST -> _blocks.size > 1 - BlockRemoveAction.REMOVE_BACKSPACE -> _blocks.size > 1 && BlockUtils.isBlockEmpty(block) - } - if (index == -1 || !allowed) return - - slash.dismiss() - history.removeBlock(index) - afterChange() - if (refocus) selection.focusAt(_blocks.previousFocusableIndex(index), cursorToEnd = true) - } - - /** - * Replaces the block at [index] with [newBlock] and focuses the new field. A converted block keeps - * its text, so its cursor stays where it was: in the same list item, or on the same line. - */ - fun replaceBlock(index: Int, newBlock: Block): Boolean { - if (index !in _blocks.indices) return false - slash.dismiss() - val old = _blocks[index] - val caret = selection.caretOf(old.id) - val item = selection.itemOf(old.id) - val target = carriedCaret(old, newBlock, caret, item) - history.replaceBlock(index, newBlock) - // Undone, the old block comes back with the cursor where it was; redone, where it was carried. - if (target != null && caret != null) attachCaretToLastStep(CaretTarget(old.id, item, caret), target) - afterChange() - selection.focusAt(index, caret = target?.caret, itemId = target?.itemId) - return true - } - - /** 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) - } - - /** - * Sets [state], a field of block [blockId], to [newValue] as one undoable edit. [mergeable] - * edits (typing) in quick succession collapse into one undo step. - */ - fun changeValue( - blockId: String, - state: MutableStateFlow, - newValue: T, - mergeable: Boolean, - step: TypingStep? = null, - ) { - history.changeValue(blockId, state, newValue, mergeable, step) - afterChange() - } - - /** 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() = moveHistory(undo = true) - - fun redo() = moveHistory(undo = false) - - /** - * Undo or redo. The cursor goes where the step itself says ([CaretTarget], from what its fields - * held before and after, or the merge's own record); a step with no text in it leaves the cursor - * alone, and a block that is gone sends it to the nearest one above. - */ - private fun moveHistory(undo: Boolean) { - slash.dismiss() - val focused = selection.focusedBlockId.value - val fields = history.fieldsOfNext(undo) - val before = fields.map { it.second.value } - if (!(if (undo) history.undo() else history.redo())) return - afterChange() - if (focused == null) return - val step = history.lastMovedCaret() - val target = if (step != null) { - if (undo) step.before else step.after - } else { - fields.indices.firstNotNullOfOrNull { caretTargetOf(fields[it].first, before[it], fields[it].second.value) } - } - val index = target?.let { t -> blocks.indexOfFirst { it.id == t.blockId } } ?: -1 - if (target != null && index >= 0 && blocks[index].canTakeFocus()) { - selection.focusAt(index, caret = target.caret, itemId = target.itemId) - } else { - selection.keepFocus(blocks, focused) - } - } - - /** The step recorded last puts the cursor at [before] when undone and at [after] when redone. */ - internal fun attachCaretToLastStep(before: CaretTarget, after: CaretTarget) = - history.attachCaret(BlockOperations.CaretStep(before, after)) - - private fun afterChange() { - if (_blocks.isEmpty()) _blocks.add(emptyNoteBlock()) - _canUndo.value = history.canUndo() - _canRedo.value = history.canRedo() - } -} - -/** The block a note without content shows. */ -private fun emptyNoteBlock(): Block = Block.TextBlock(placeholder = R.string.press_to_edit) - -private fun List.isEmptyFirstTextBlock(index: Int): Boolean { - val block = getOrNull(index) - return index == 0 && block is Block.TextBlock && block.text.value.isEmpty() -} - -/** The content of [this] block, as a flow; null for a block without content (a separator). */ -private fun Block.contentFlow(): Flow? = when (this) { - is Block.TextBlock -> text - - is Block.HBlock -> text - - is Block.QuoteBlock -> text - - is Block.LinkBlock -> block.map { it.url } - - // The lock is content too: locking a list must be saved like typing in it. - is Block.ListBlock -> combine(items, locked) { list, locked -> list to locked } - - is Block.SeparatorBlock -> null -} - -/** - * Undo and redo may rebuild the field the cursor was in (a list item, a block that was replaced), - * and a field that leaves composition takes the keyboard with it. When a field had focus - * ([blockId]), it is asked for again: the same block if it is still there, else the nearest one above. - */ -private fun BlockSelection.keepFocus(blocks: List, blockId: String?) { - if (blockId == null) return - val index = blocks.indexOfFirst { it.id == blockId } - if (index >= 0 && blocks[index].canTakeFocus()) { - // Same block: the cursor goes back where it was (and into the same list item); the field - // clamps it if the undone text is shorter. Only a cursor never seen means "the end". - // An item that has gone (undone away) is not asked for: the list falls back to its last item. - val list = blocks[index] as? Block.ListBlock - val item = itemOf(blockId)?.takeIf { id -> list == null || list.items.value.any { it.id == id } } - val caret = caretOf(blockId)?.takeIf { list == null || item != null } - focusAt(index, cursorToEnd = caret == null, caret = caret, itemId = item) - } else { - // The block is gone: the nearest one above, the cursor at its end. - focusAt(blocks.previousFocusableIndex(interactedIndex + 1), cursorToEnd = true) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorToolbar.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorToolbar.kt deleted file mode 100644 index d5128990..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorToolbar.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.pasich.encly.presentation.editor.state - -import com.pasich.encly.dynamicBlocks.Block - -// Editor toolbar actions: they act on the block the user last worked on. - -/** Whether the block the user works on can move one place [up] (or down). */ -fun BlockEditorState.canMoveInteracted(up: Boolean): Boolean { - val from = selection.interactedIndex - val to = if (up) from - 1 else from + 1 - return from in blocks.indices && to in blocks.indices -} - -/** Moves the block the user works on one place [up] (or down). */ -fun BlockEditorState.moveInteracted(up: Boolean) { - if (!canMoveInteracted(up)) return - val from = selection.interactedIndex - moveBlock(from, if (up) from - 1 else from + 1) -} - -/** Position of block [blockId]; -1 when it is not in the note (any more). */ -fun BlockEditorState.indexOf(blockId: String): Int = blocks.indexOfFirst { it.id == blockId } - -/** Adds [block] after position [afterIndex] and focuses it. */ -fun BlockEditorState.addBlockAfter(afterIndex: Int, block: Block) { - val target = (afterIndex + 1).coerceIn(0, blocks.size) - batch { addBlock(target, block) } - selection.focusAt(target) -} - -/** Moves block [blockId] one place [up] (or down), if there is a place to go. */ -fun BlockEditorState.moveBlockOf(blockId: String, up: Boolean) { - val from = indexOf(blockId) - val to = if (up) from - 1 else from + 1 - if (from in blocks.indices && to in blocks.indices) moveBlock(from, to) -} - -/** Whether the block the user works on can be removed: one block always remains. */ -fun BlockEditorState.canRemoveInteracted(): Boolean = blocks.size > 1 && selection.interactedIndex in blocks.indices - -/** Removes the block the user works on. */ -fun BlockEditorState.removeInteracted() { - if (!canRemoveInteracted()) return - removeBlock(blocks[selection.interactedIndex], BlockRemoveAction.REMOVE) -} - -/** Where the block the user works on is: [BLOCK_AT_TOP], [BLOCK_AT_BOTTOM] or [BLOCK_IN_MIDDLE]. */ -fun BlockEditorState.interactedBlockPosition(): Int = when (selection.interactedIndex) { - 0 -> BLOCK_AT_TOP - blocks.lastIndex -> BLOCK_AT_BOTTOM - else -> BLOCK_IN_MIDDLE -} - -/** Focuses the trailing text block, adding one first unless the note already ends in one. */ -fun BlockEditorState.addBlockToEnd() { - if (blocks.lastOrNull() is Block.TextBlock) { - selection.focusAt(blocks.lastIndex) - } else { - addBlockAfter(blocks.lastIndex, Block.TextBlock()) - } -} - -const val BLOCK_AT_TOP = 1 -const val BLOCK_AT_BOTTOM = 2 -const val BLOCK_IN_MIDDLE = -1 diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockSelection.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockSelection.kt deleted file mode 100644 index b1be4d78..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockSelection.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.pasich.encly.presentation.editor.state - -import com.pasich.encly.dynamicBlocks.Block -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow - -/** - * Asks the editor UI to focus the field of block [blockId]: the cursor at [caret] (clamped to the - * text), else at the end when [cursorToEnd]. In a list, [itemId] names the item to focus. With a - * [caretX] the cursor goes to that horizontal position instead, so Up and Down keep the column. - */ -data class FocusRequest( - val blockId: String, - val cursorToEnd: Boolean = false, - val caret: Int? = null, - val itemId: String? = null, - /** Put the cursor at this horizontal position of the target's first line (last, [fromBelow]). */ - val caretX: Float? = null, - /** The cursor comes up from below: a list takes its last item, a field its last line. */ - val fromBelow: Boolean = false, -) { - /** Where the cursor goes: an offset, [Int.MAX_VALUE] for the end, null to leave it. */ - val caretOffset: Int? get() = caret ?: if (cursorToEnd) Int.MAX_VALUE else null -} - -/** - * Which block the user works on, by block id, and the focus requests the editor UI carries out. - * - * Plain state: the FocusRequesters themselves live in composition (BlockFocusRegistry), so - * nothing here outlives the screen or needs a UI thread. [blocks] is the editor's live list. - */ -class BlockSelection(private val blocks: List) { - private val _focusedBlockId = MutableStateFlow(null) - - /** The block whose field has focus; null while none has (the keyboard toggle reads it). */ - val focusedBlockId: StateFlow = _focusedBlockId.asStateFlow() - - // Where the cursor last was in each block's field, so a block converted by a tool or - // focused again by "Show keyboard" gets its cursor back where the user left it. - private val carets = mutableMapOf() - - private val _interactedBlockId = MutableStateFlow(null) - - /** The block the user last worked on: focused, tapped, or opened the block sheet of. */ - val interactedBlockId: StateFlow = _interactedBlockId.asStateFlow() - - // Where the interacted block was. If it disappears (undo, delete), the block now at that - // position stands in for it, so toolbar actions never read a position past the end. - private var interactedIndexHint = 0 - - private val focusRequests = Channel(Channel.CONFLATED) - - /** - * Carries out a focus request at once where its field is already composed (set by the editor - * screen). Focus then moves before the field being left is disposed, so the keyboard never sees a - * moment with nothing focused; fields not composed yet still get [requests]. - */ - var immediateFocus: ((FocusRequest) -> Unit)? = null - - /** Focus requests for the editor UI; only the latest one matters. Collect from one place. */ - val requests: Flow = focusRequests.receiveAsFlow() - - /** Position of the focused block, or -1 when no block field has focus. */ - val focusedIndex: Int get() = blocks.indexOfFirst { it.id == _focusedBlockId.value } - - /** Position of the block the user works on, always inside the list (0 for an empty one). */ - val interactedIndex: Int - get() { - val index = blocks.indexOfFirst { it.id == _interactedBlockId.value } - return if (index >= 0) index else interactedIndexHint.coerceIn(0, blocks.lastIndex.coerceAtLeast(0)) - } - - /** The focused block, else the one the user last worked on: where toolbar actions apply. */ - val workingIndex: Int get() = focusedIndex.takeIf { it >= 0 } ?: interactedIndex - - /** The field of [blockId] took focus. */ - fun onFocused(blockId: String) { - _focusedBlockId.value = blockId - onInteraction(blockId) - } - - /** - * The field of [blockId] lost focus. It no longer decides where a new block goes; the block - * the user last worked on ([interactedIndex]) does, until another field takes focus. - */ - fun onFocusLost(blockId: String) { - if (_focusedBlockId.value == blockId) _focusedBlockId.value = null - } - - /** The cursor in the field of [blockId] is now at [offset]. */ - fun onCaret(blockId: String, offset: Int) { - carets[blockId] = offset - } - - /** Where the cursor last was in the field of [blockId]; null if it never had one. */ - fun caretOf(blockId: String): Int? = carets[blockId] - - // The item of each list the cursor was last in, so a list can be focused back in the same item. - private val listItems = mutableMapOf() - - /** The cursor in item [itemId] of the list [blockId] is at [offset]. */ - fun onItemCaret(blockId: String, itemId: String, offset: Int) { - listItems[blockId] = itemId - carets[blockId] = offset - } - - /** The item of list [blockId] the cursor was last in; null for a block that is not a list. */ - fun itemOf(blockId: String): String? = listItems[blockId] - - /** The user works on [blockId] without its field taking focus (a block sheet). */ - fun onInteraction(blockId: String) { - val index = blocks.indexOfFirst { it.id == blockId } - if (index < 0) return - _interactedBlockId.value = blockId - interactedIndexHint = index - } - - /** - * Focuses the block now at [index] once its field is composed, the cursor at [caret] (else at - * the end with [cursorToEnd]); [itemId] picks a list's item. - */ - fun focusAt(index: Int, cursorToEnd: Boolean = false, caret: Int? = null, itemId: String? = null) { - val blockId = blocks.getOrNull(index)?.id ?: return - onFocused(blockId) - caret?.let { carets[blockId] = it } - // The item asked for is where the cursor now is, before its field reports it back. - itemId?.let { listItems[blockId] = it } - val request = FocusRequest(blockId, cursorToEnd, caret, itemId) - immediateFocus?.invoke(request) - focusRequests.trySend(request) - } -} - -/** Whether [this] block has a field that can take focus. */ -fun Block.canTakeFocus(): Boolean = when (this) { - is Block.TextBlock, is Block.HBlock, is Block.QuoteBlock -> true - - // A list focuses its own items; a locked checklist has none to focus. - is Block.ListBlock -> !locked.value - - // A link has a field only while its URL is being entered. - is Block.LinkBlock -> block.value.url.isBlank() - - is Block.SeparatorBlock -> false -} - -/** - * The block to focus instead of the one at [index]: the nearest focusable block above it, - * else the first focusable block of the note (0 when there is none). - */ -fun List.previousFocusableIndex(index: Int): Int { - val above = (index - 1 downTo 0).firstOrNull { it in indices && this[it].canTakeFocus() } - return above ?: indexOfFirst { it.canTakeFocus() }.coerceAtLeast(0) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockTools.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockTools.kt deleted file mode 100644 index e1dd56ca..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockTools.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.pasich.encly.presentation.editor.state - -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.dynamicBlocks.TextualBlock -import com.pasich.encly.dynamicBlocks.factory.BlockFactory -import kotlinx.coroutines.flow.MutableStateFlow - -// The toolbar's block tools: they act on the block the user works on. - -/** Focuses the first block that has a field (the title's "Next"); adds a paragraph when none has. */ -fun BlockEditorState.focusFirstBlock() { - val first = blocks.indexOfFirst { it.canTakeFocus() } - if (first >= 0) selection.focusAt(first) else addBlockToEnd() -} - -/** - * Brings the keyboard back to where the user was writing: the block they worked on (or the - * nearest one above that has a field), the cursor where they left it (else at the end); the - * first block if none. - */ -fun BlockEditorState.focusWorkingBlock() { - val index = selection.workingIndex - val target = if (blocks.getOrNull(index)?.canTakeFocus() == true) index else blocks.previousFocusableIndex(index) - val block = blocks.getOrNull(target) - if (block?.canTakeFocus() == true) { - // A list's items keep their own cursors; its field target puts it at the end. - val caret = selection.caretOf(block.id).takeIf { block !is Block.ListBlock } - selection.focusAt(target, cursorToEnd = true, caret = caret) - } else { - focusFirstBlock() - } -} - -/** - * Locks or unlocks checklist [blockId]: its text can no longer be focused or edited while its boxes - * still toggle. One undo step. False when the block is not a checklist. - */ -fun BlockEditorState.toggleChecklistLock(blockId: String): Boolean { - val list = (blocks.firstOrNull { it.id == blockId } as? Block.ListBlock) - ?.takeIf { it.blockType == BlockType.LIST_CHECK } - list?.let { batch { setValue(it.id, it.locked, !it.locked.value) } } - return list != null -} - -/** The Heading tool: headings of every level show it active, and it makes an H2. */ -val HEADING_TOOL = BlockType.H2 - -/** The toolbar tool shown active for [this] block; null for a paragraph. */ -fun Block.toolType(): BlockType? = when (this) { - is Block.TextBlock -> null - is Block.HBlock -> HEADING_TOOL - is Block.QuoteBlock -> BlockType.QUOTE - is Block.ListBlock -> blockType - is Block.LinkBlock -> BlockType.LINK - is Block.SeparatorBlock -> BlockType.SEPARATOR -} - -/** - * Applies toolbar [tool] to the block the user works on: a text block turns into that type and - * keeps its text, and the tool it already shows turns it back into a paragraph ([exact]: only - * the very same type does, so "Heading 3" turns an H1 into an H3). Otherwise (a link or - * separator, or a block that cannot change) a new block of [tool] is added after it. - */ -fun BlockEditorState.applyTool(tool: BlockType, exact: Boolean = false) { - val index = selection.workingIndex - // A locked checklist keeps its kind: converting it would drop the lock the user set. - if ((blocks.getOrNull(index) as? Block.ListBlock)?.locked?.value == true) return - val converted = blocks.getOrNull(index)?.let { convertBlock(it, tool, exact) } - when { - converted != null -> replaceBlock(index, converted) - tool == BlockType.SEPARATOR -> addSeparator() - else -> addBlock(BlockFactory.createBlock(tool)) - } -} - -/** A separator, then a paragraph to go on writing in (the one already after it, if any). */ -private fun BlockEditorState.addSeparator() { - val separator = Block.SeparatorBlock() - addBlock(separator) - val at = blocks.indexOfFirst { it.id == separator.id } - if (blocks.getOrNull(at + 1) is Block.TextBlock) selection.focusAt(at + 1) else addBlockAfter(at, Block.TextBlock()) -} - -/** [block] as [tool], keeping its text; null when it cannot be converted (see [applyTool]). */ -internal fun convertBlock(block: Block, tool: BlockType, exact: Boolean = false): Block? { - val text = block.plainText() - val shown = if (exact) block.exactType() else block.toolType() - return when { - text == null || (block is Block.TextBlock && tool == BlockType.TEXT) -> null - shown == tool -> Block.TextBlock(MutableStateFlow(text)) - else -> convertText(block, text, tool) - } -} - -private fun convertText(block: Block, text: String, tool: BlockType): Block? = when (tool) { - BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4 -> - Block.HBlock(MutableStateFlow(text.replace('\n', ' ')), blockType = tool) - - BlockType.QUOTE -> Block.QuoteBlock(MutableStateFlow(text)) - - BlockType.LIST_CHECK, BlockType.LIST_BULLET, BlockType.LIST_NUMBER -> { - val items = (block as? Block.ListBlock)?.items?.value ?: text.split('\n').map { ItemListBlock(it) } - Block.ListBlock(MutableStateFlow(items), blockType = tool) - } - - // Only an empty paragraph becomes a link; text is never thrown away. - BlockType.LINK -> Block.LinkBlock().takeIf { block is Block.TextBlock && text.isEmpty() } - - BlockType.TEXT -> Block.TextBlock(MutableStateFlow(text)) - - BlockType.SEPARATOR -> null -} - -private fun Block.exactType(): BlockType? = when (this) { - is Block.TextBlock -> BlockType.TEXT - is Block.HBlock -> blockType - else -> toolType() -} - -private fun Block.plainText(): String? = when (this) { - is Block.ListBlock -> items.value.joinToString("\n") { it.value } - is TextualBlock -> text.value - else -> null -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/ListEditing.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/ListEditing.kt deleted file mode 100644 index c72e9749..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/ListEditing.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.pasich.encly.presentation.editor.state - -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import kotlinx.coroutines.flow.MutableStateFlow - -// Enter, Backspace and a paste in a list's items: an item splits at the cursor, an empty item -// leaves the list for a paragraph, and an item joins the one above. Each is one undo step. - -/** - * [lineBreak] in item [itemId] of list [listId]. Enter splits the item at the cursor (the text - * after it goes to a new item); in an empty item it leaves the list. Pasted lines become items. - * Returns the text the item's field keeps; null when the item is gone. - */ -fun BlockEditorState.listLineBreak(listId: String, itemId: String, lineBreak: LineBreak): String? { - val item = itemAt(listId, itemId) ?: return null - return if (lineBreak.isEnter) listEnter(item, lineBreak.head, lineBreak.tail) else listPaste(item, lineBreak) -} - -/** Item [at] of [list], the block at [index]. */ -private data class ListItemAt(val index: Int, val list: Block.ListBlock, val at: Int) - -private fun BlockEditorState.itemAt(listId: String, itemId: String): ListItemAt? { - val index = indexOf(listId) - val list = blocks.getOrNull(index) as? Block.ListBlock - val at = list?.items?.value?.indexOfFirst { it.id == itemId } ?: -1 - return if (list != null && at >= 0) ListItemAt(index, list, at) else null -} - -/** Pasted lines in an item: the first joins it, the others become items after it. */ -private fun BlockEditorState.listPaste(item: ListItemAt, lineBreak: LineBreak): String { - val (index, list, at) = item - val items = list.items.value - val rest = lineBreak.lines.drop(1) - val kept = lineBreak.head + lineBreak.lines.first() - val middle = rest.dropLast(1).filter { it.isNotBlank() }.map(::listItemText) - val lastLine = listItemText(rest.last()) - val last = lastLine.copy(value = lastLine.value + lineBreak.tail) - val newItems = items.toMutableList().apply { - this[at] = items[at].copy(value = kept) - addAll(at + 1, middle + last) - } - batch { setValue(list.id, list.items, newItems) } - selection.focusAt(index, caret = lastLine.value.length, itemId = last.id) - return kept -} - -private fun BlockEditorState.listEnter(where: ListItemAt, head: String, tail: String): String { - val (index, list, at) = where - val items = list.items.value - val item = items[at] - return when { - head.isEmpty() && tail.isEmpty() -> { - exitList(index, list, at, from = CaretTarget(list.id, item.id, 0)) - "" - } - - // At the start of an item: an empty item opens above it. - head.isEmpty() -> { - batch { setValue(list.id, list.items, items.toMutableList().apply { add(at, ItemListBlock("")) }) } - CaretTarget(list.id, item.id, 0).let { attachCaretToLastStep(it, it) } - selection.focusAt(index, caret = 0, itemId = item.id) - tail - } - - else -> { - val next = ItemListBlock(tail) - val newItems = items.toMutableList().apply { - this[at] = item.copy(value = head) - add(at + 1, next) - } - batch { setValue(list.id, list.items, newItems) } - attachCaretToLastStep(CaretTarget(list.id, item.id, head.length), CaretTarget(list.id, next.id, 0)) - selection.focusAt(index, caret = 0, itemId = next.id) - head - } - } -} - -/** - * The empty item [at] leaves the list: a paragraph takes its place, after the list for its last - * item, before it for its first, and in the middle it splits the list in two around it. - */ -private fun BlockEditorState.exitList(index: Int, list: Block.ListBlock, at: Int, from: CaretTarget) { - val items = list.items.value - val before = items.take(at) - val after = items.drop(at + 1) - val paragraph = Block.TextBlock() - batch { - when { - before.isEmpty() && after.isEmpty() -> replaceBlock(index, paragraph) - - before.isEmpty() -> { - setValue(list.id, list.items, after) - addBlock(index, paragraph) - } - - else -> { - setValue(list.id, list.items, before) - addBlock(index + 1, paragraph) - if (after.isNotEmpty()) addBlock(index + 2, Block.ListBlock(MutableStateFlow(after), list.blockType)) - } - } - } - attachCaretToLastStep(from, CaretTarget(paragraph.id, null, 0)) - selection.focusAt(indexOf(paragraph.id), caret = 0) -} - -/** - * Backspace with the cursor at the start of item [itemId]: the first item leaves the list as a - * paragraph above it, an empty last item leaves it as a paragraph below, and any other item joins - * the item above, its text appended there. - */ -fun BlockEditorState.listBackspaceAtStart(listId: String, itemId: String): Boolean { - val where = itemAt(listId, itemId) ?: return false - val (index, list, at) = where - val items = list.items.value - val item = items[at] - when { - at == 0 -> { - val paragraph = Block.TextBlock(MutableStateFlow(item.value)) - batch { - if (items.size == 1) { - replaceBlock(index, paragraph) - } else { - setValue(list.id, list.items, items.drop(1)) - addBlock(index, paragraph) - } - } - attachCaretToLastStep(CaretTarget(list.id, item.id, 0), CaretTarget(paragraph.id, null, 0)) - selection.focusAt(indexOf(paragraph.id), caret = 0) - } - - item.value.isEmpty() && at == items.lastIndex -> exitList( - index, - list, - at, - from = CaretTarget(list.id, item.id, 0), - ) - - else -> { - val previous = items[at - 1] - val newItems = items.toMutableList().apply { - this[at - 1] = previous.copy(value = previous.value + item.value) - removeAt(at) - } - batch { setValue(list.id, list.items, newItems) } - attachCaretToLastStep( - CaretTarget(list.id, item.id, 0), - CaretTarget(list.id, previous.id, previous.value.length), - ) - selection.focusAt(index, caret = previous.value.length, itemId = previous.id) - } - } - return true -} - -/** - * The [TypingStep] of a list edit from [old] to [new] items: typing in one item, else null (a - * check, an item added or removed is a step of its own). - */ -fun listTypingStep(old: List, new: List): TypingStep? { - val sameItems = old.size == new.size && - old.indices.all { old[it].id == new[it].id && old[it].isCheck == new[it].isCheck } - val changed = if (sameItems) old.indices.filter { old[it].value != new[it].value } else emptyList() - val i = changed.singleOrNull() ?: return null - return typingStep(old[i].id, old[i].value, new[i].value) -} 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 deleted file mode 100644 index 007502a4..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteFind.kt +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index cd003950..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteTemplates.kt +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index d22c94c6..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.pasich.encly.presentation.editor.state - -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.TextualBlock -import kotlinx.coroutines.flow.MutableStateFlow - -/** - * Pasted lines in a paragraph, heading or quote (Smart Paste). The text is read as Markdown - * ([pastedBlocks]): a paste that is one paragraph goes into the block at the cursor, line breaks - * and all; otherwise its first paragraph joins the text before the cursor, the other blocks - * follow, and the text after the cursor goes on at the end of the last paragraph, as the kind of - * block it came from (text after the cursor in a heading stays a heading of that level, in a - * quote a quote). When the cursor is after text, the first pasted line joins it as plain text - * and is never read as a mark; so is a single line with a trailing line break, next to any text. - * Whitespace-only lines at either end of the paste are dropped; everything else is kept. One - * undo step. - */ -internal fun BlockEditorState.pasteLines(index: Int, block: TextualBlock, lineBreak: LineBreak): String { - val pasted = trimBlankLines(lineBreak.lines) - // One content line is only a line break at the end of the copied text: no marks to read, - // unless it lands in an empty block, where a lone address or a mark is what the user meant. - val inText = lineBreak.head.isNotEmpty() || lineBreak.tail.isNotEmpty() - val mapped = if (pasted.size == 1 && inText) emptyList() else pastedBlocks(pasted, lineBreak.head.isNotEmpty()) - return when { - pasted.isEmpty() -> { - // Nothing but blank lines: no content to paste. - selection.focusAt(index, caret = lineBreak.head.length) - lineBreak.head + lineBreak.tail - } - - // One line (its line break is just the end of the copied text), or text the mapper left - // nothing of: pasted as typed. - mapped.isEmpty() || mapped.singleOrNull() is Block.TextBlock -> pasteParagraph(index, block, lineBreak, pasted) - - else -> pasteBlocks(index, block, lineBreak, pasted, mapped) - } -} - -/** A paste that is one paragraph: it goes into the block at the cursor, line breaks and all. */ -private fun BlockEditorState.pasteParagraph( - index: Int, - block: TextualBlock, - lineBreak: LineBreak, - pasted: List, -): String { - val text = pasted.joinToString("\n").let { if (block is Block.HBlock) it.replace('\n', ' ') else it } - val kept = lineBreak.head + text + lineBreak.tail - batch { setValue(blocks[index].id, block.text, kept) } - selection.focusAt(index, caret = lineBreak.head.length + text.length) - return kept -} - -/** A paste of several paragraphs or marks: see [pasteLines]. */ -private fun BlockEditorState.pasteBlocks( - index: Int, - block: TextualBlock, - lineBreak: LineBreak, - pasted: List, - mapped: List, -): String { - val (head, _, tail) = lineBreak - val first = mapped.first() as? Block.TextBlock - val lead = when (first) { - null -> head - else -> head + pasted.first().takeWhile { it.isWhitespace() } + first.text.value.flattenFor(block) - } - var rest = if (first == null) mapped else mapped.drop(1) - var caret = 0 - if (tail.isEmpty()) { - rest = withTrailingParagraph(rest) - } else { - // The pasted text goes on into what followed the cursor. - val last = rest.last() as? Block.TextBlock - if (last == null) { - rest = rest + block.sameKind(tail) - } else { - val joined = last.text.value + pasted.last().takeLastWhile { it.isWhitespace() } - caret = joined.length - rest = rest.dropLast(1) + block.sameKind(joined + tail) - } - } - // Nothing before the cursor and nothing to join: the pasted blocks take the block's place. - val replaceCurrent = first == null && head.isEmpty() - batch { - if (replaceCurrent) { - replaceBlock(index, rest.first()) - rest.drop(1).forEachIndexed { i, added -> addBlock(index + 1 + i, added) } - } else { - setValue(blocks[index].id, block.text, lead) - rest.forEachIndexed { i, added -> addBlock(index + 1 + i, added) } - } - } - val last = if (replaceCurrent) index + rest.lastIndex else index + rest.size - if (tail.isEmpty()) selection.focusAt(last, cursorToEnd = true) else selection.focusAt(last, caret = caret) - return if (replaceCurrent) "" else lead -} - -/** A heading is one line: line breaks of pasted text become spaces there. */ -private fun String.flattenFor(block: TextualBlock): String = if (block is Block.HBlock) replace('\n', ' ') else this - -/** A block of [this] block's kind (paragraph, quote or heading of the same level) holding [text]. */ -private fun TextualBlock.sameKind(text: String): Block = when (this) { - is Block.HBlock -> Block.HBlock(MutableStateFlow(text.replace('\n', ' ')), blockType) - is Block.QuoteBlock -> Block.QuoteBlock(MutableStateFlow(text)) - else -> Block.TextBlock(MutableStateFlow(text)) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt deleted file mode 100644 index 083052e9..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index fd738a0c..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.pasich.encly.presentation.editor.state - -import com.pasich.encly.data.importer.MarkdownBlockMapper -import com.pasich.encly.domain.model.LinkDataBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import kotlinx.coroutines.flow.MutableStateFlow - -// Smart Paste: what pasted plain text becomes. Only the plain-text clipboard is read; HTML is never parsed. - -/** - * [lines] without the whitespace-only lines before the first and after the last one that has - * content. Blank lines between content stay: they are the author's paragraph breaks. - */ -fun trimBlankLines(lines: List): List { - val first = lines.indexOfFirst { it.isNotBlank() } - if (first < 0) return emptyList() - return lines.subList(first, lines.indexOfLast { it.isNotBlank() } + 1) -} - -/** - * Pasted [lines] as blocks, read as Markdown by the importer's [MarkdownBlockMapper] in its - * paste mode (nothing is rewritten or dropped): marks become their blocks, a lone web address a - * link, a rule a separator, anything else a paragraph. A blank line (any number of them) starts - * the next paragraph while a single line break stays inside its paragraph, and consecutive - * items of one kind of list become one list. Whitespace-only lines at either end are not - * content and are dropped; none in between is. With [plainFirstLine] the first line stays plain - * text, marks and all: a paste that continues what is already on the line must not turn it into - * a heading, list or link card. - */ -fun pastedBlocks(lines: List, plainFirstLine: Boolean = false): List { - val text = trimBlankLines(lines).withIndex() - .joinToString("\n") { (i, line) -> if (i == 0 && plainFirstLine) line else asMarkdownMark(line) } - return MarkdownBlockMapper.pasteBlocks(text, plainFirstLine, ::pastedAddress) -} - -/** [line] with the editor's own short marks ("[] ", "[x] ", "• ") written the way Markdown does. */ -private fun asMarkdownMark(line: String): String { - val shortcut = leadingShortcut(line) ?: return line - val rest by lazy { line.removePrefix(shortcut.marker) } - return when { - shortcut.type == BlockType.LIST_CHECK && !shortcut.marker.startsWith("-") -> - (if (shortcut.checked) "- [x] " else "- [ ] ") + rest - - shortcut.marker == "• " -> "- $rest" - - else -> line - } -} - -private fun pastedAddress(line: String): Block? = line.trim().takeIf(::isLoneWebAddress) - ?.let { Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "", url = it))) } diff --git a/app/src/main/java/com/pasich/encly/presentation/effects/Animation.kt b/app/src/main/java/com/pasich/encly/presentation/effects/Animation.kt deleted file mode 100644 index 7df4c9b5..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/effects/Animation.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.pasich.encly.presentation.effects - -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally - -private const val AXIS_DURATION = 300 -private const val AXIS_FADE_IN = 210 -private const val AXIS_FADE_IN_DELAY = 60 -private const val AXIS_FADE_OUT = 120 - -/** How far a screen travels on the shared X axis: an eighth of the width. */ -private const val AXIS_SHIFT_FRACTION = 8 - -/** Material's emphasized decelerate: screens arrive fast and settle gently. */ -private val AxisEasing = CubicBezierEasing(a = 0.05f, b = 0.7f, c = 0.1f, d = 1f) - -/** - * Shared X axis (Material motion): going forward, the new screen slides in from the right as it - * fades in and the old one drifts left as it fades out; [forward] false mirrors it for Back. - */ -fun axisEnter(forward: Boolean): EnterTransition = - slideInHorizontally(tween(AXIS_DURATION, easing = AxisEasing)) { width -> - (if (forward) width else -width) / AXIS_SHIFT_FRACTION - } + fadeIn(tween(AXIS_FADE_IN, delayMillis = AXIS_FADE_IN_DELAY)) - -fun axisExit(forward: Boolean): ExitTransition = - slideOutHorizontally(tween(AXIS_DURATION, easing = AxisEasing)) { width -> - (if (forward) -width else width) / AXIS_SHIFT_FRACTION - } + fadeOut(tween(AXIS_FADE_OUT)) - -/** - * Duration of the card-to-editor container transform. Longer than a screen slide: the card travels - * and grows across the whole window, and at 300 ms that read as a jump rather than a growth. - */ -const val CONTAINER_DURATION = 450 - -/** The editor fades in over this part of the transform, starting [CONTAINER_FADE_IN_DELAY] in. */ -const val CONTAINER_FADE_IN = 300 - -/** The card grows a little before the editor shows in it, so the motion leads and the content follows. */ -const val CONTAINER_FADE_IN_DELAY = 90 - -/** Closing, the editor fades out quickly so the shrinking card is what the eye follows. */ -const val CONTAINER_FADE_OUT = 180 - -/** - * The cross-fade of the screens behind a container transform, paced with it: the screen being left - * fades over the card's growth instead of blinking out under it, and the new one comes in a beat late. - */ -fun fadeScreenEnter(): EnterTransition = - fadeIn(tween(CONTAINER_FADE_IN, delayMillis = CONTAINER_FADE_IN_DELAY, easing = LinearOutSlowInEasing)) - -fun fadeScreenExit(): ExitTransition = fadeOut(tween(CONTAINER_FADE_IN, easing = FastOutLinearInEasing)) - -/** - * The easing of the container transform's bounds and shape: Material's emphasized curve, which - * starts gently and settles slowly, unlike the axis curve's sharp start. - */ -val ContainerEasing = CubicBezierEasing(a = 0.2f, b = 0f, c = 0f, d = 1f) diff --git a/app/src/main/java/com/pasich/encly/presentation/effects/AnimationScreens.kt b/app/src/main/java/com/pasich/encly/presentation/effects/AnimationScreens.kt deleted file mode 100644 index 4f5f36ea..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/effects/AnimationScreens.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.pasich.encly.presentation.effects - -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.navigation.NamedNavArgument -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDeepLink -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable - -/** The enter transition for a swap of the destinations whose routes are [from] and [to]. */ -fun enterFor(from: String?, to: String?, forward: Boolean): EnterTransition = when (screenMotion(from, to)) { - ScreenMotion.NONE -> EnterTransition.None - ScreenMotion.FADE -> fadeScreenEnter() - ScreenMotion.AXIS -> axisEnter(forward) -} - -/** The exit transition for a swap of the destinations whose routes are [from] and [to]. */ -fun exitFor(from: String?, to: String?, forward: Boolean): ExitTransition = when (screenMotion(from, to)) { - ScreenMotion.NONE -> ExitTransition.None - ScreenMotion.FADE -> fadeScreenExit() - ScreenMotion.AXIS -> axisExit(forward) -} - -/** - * A destination with the app's screen motion (see [screenMotion]). Its [content] also gets the - * destination's [AnimatedVisibilityScope] through [LocalNavAnimatedVisibilityScope], for shared - * elements. - */ -fun NavGraphBuilder.animationScreens( - route: String, - arguments: List = emptyList(), - deepLinks: List = emptyList(), - content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, -) = composable( - route = route, - arguments = arguments, - deepLinks = deepLinks, - // Re-lock swaps straight to the lock screen: no cross-fade that would keep the previous, - // decrypted screen on display (MainActivity's shield covers it until then). Unlock swaps - // straight away too: it happens under the reveal overlay (see UnlockRevealOverlay). - enterTransition = { enterFor(initialState.destination.route, targetState.destination.route, forward = true) }, - exitTransition = { exitFor(initialState.destination.route, targetState.destination.route, forward = true) }, - popEnterTransition = { enterFor(initialState.destination.route, targetState.destination.route, forward = false) }, - popExitTransition = { exitFor(initialState.destination.route, targetState.destination.route, forward = false) }, - content = { entry -> - CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) { content(entry) } - }, -) diff --git a/app/src/main/java/com/pasich/encly/presentation/effects/ContainerTransform.kt b/app/src/main/java/com/pasich/encly/presentation/effects/ContainerTransform.kt deleted file mode 100644 index 296f0c18..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/effects/ContainerTransform.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.pasich.encly.presentation.effects - -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.BoundsTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.RoundRect -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import com.pasich.encly.presentation.components.swipe.rememberReducedMotion - -/** - * The scope the navigation host's screens share elements in, provided once by AppNavHost. Null - * outside it (previews, tests): nothing is shared there. - */ -val LocalSharedTransitionScope = staticCompositionLocalOf { null } - -/** The visibility scope of the destination being composed, provided by [animationScreens]. */ -val LocalNavAnimatedVisibilityScope = staticCompositionLocalOf { null } - -/** Which end of the card-to-editor container transform the modifier goes on. */ -enum class ContainerEnd { CARD, EDITOR } - -/** - * The container transform between a note's card and its editor: both carry a shared-bounds - * modifier keyed by the note, so the card grows into the full-screen editor (and shrinks back), - * its corner radius easing between the card's and 0 (see [cornerClip]) while the colours cross over. - * - * - The card re-measures to the animated bounds (`RemeasureToBounds`), so its surface fills the - * growing container instead of leaving a scaled card in the corner. The editor is scaled - * (`ScaleToBounds`) from its own full-size layout: re-measuring a whole text editor on every - * frame is wasteful, and its width is close to the card's, so the scale stays near 1. - * - The editor is always drawn above the card in the overlay and is the one that fades; the card - * stays opaque beneath it, so no frame lets the screen behind show through a cross-fade. - * - Nothing is shared (an empty modifier) without both scopes, with the system's "remove - * animations", or without a [noteId] (a new note has no card to grow from). When the other end - * is not composed (the card scrolled off, moved, archived or deleted from the editor), no match - * is found: the content stays in its place and the editor just fades. - */ -@Composable -fun rememberNoteContainerModifier(noteId: Long, end: ContainerEnd): Modifier { - val sharedScope = LocalSharedTransitionScope.current - val visibility = LocalNavAnimatedVisibilityScope.current - val plain = rememberReducedMotion() || noteId <= 0L - return if (sharedScope != null && visibility != null && !plain) { - rememberContainerBounds(sharedScope, visibility, noteId, end) - } else { - Modifier - } -} - -@Composable -private fun rememberContainerBounds( - sharedScope: SharedTransitionScope, - visibility: AnimatedVisibilityScope, - noteId: Long, - end: ContainerEnd, -): Modifier { - val corner = MaterialTheme.shapes.medium.topStart - val windowSize = LocalWindowInfo.current.containerSize - val clip = remember(corner, windowSize) { cornerClip(corner, windowSize) } - val state = with(sharedScope) { rememberSharedContentState(key = "note-$noteId") } - return with(sharedScope) { - Modifier.sharedBounds( - sharedContentState = state, - animatedVisibilityScope = visibility, - enter = if (end == ContainerEnd.EDITOR) fadeIn(ContainerFadeIn) else EnterTransition.None, - exit = if (end == ContainerEnd.EDITOR) fadeOut(ContainerFadeOut) else ExitTransition.None, - boundsTransform = ContainerBounds, - resizeMode = if (end == ContainerEnd.CARD) { - SharedTransitionScope.ResizeMode.RemeasureToBounds - } else { - SharedTransitionScope.ResizeMode.scaleToBounds(ContentScale.FillWidth, Alignment.TopStart) - }, - zIndexInOverlay = if (end == ContainerEnd.EDITOR) 1f else 0f, - clipInOverlayDuringTransition = clip, - ) - } -} - -private val ContainerFadeIn = - tween(CONTAINER_FADE_IN, delayMillis = CONTAINER_FADE_IN_DELAY, easing = LinearOutSlowInEasing) - -private val ContainerFadeOut = tween(CONTAINER_FADE_OUT, easing = FastOutLinearInEasing) - -private val ContainerBounds = BoundsTransform { _, _ -> tween(CONTAINER_DURATION, easing = ContainerEasing) } - -/** Below this share of the window a container still has the card's full corner radius. */ -private const val ROUND_UNTIL_COVERAGE = 0.35f - -/** - * A rounded-rectangle clip of the animated bounds. The radius follows how much of the window the - * bounds cover: the card's own while they are card-sized, easing to 0 as they fill the window. - * It is computed here, while the content is lifted into the overlay, rather than by animating - * a value on the destination's transition: any animation added to that transition would keep a - * leaving screen composed until it ends, which a re-lock must never wait for. - */ -private fun cornerClip(corner: CornerSize, window: IntSize) = object : SharedTransitionScope.OverlayClip { - override fun getClipPath( - sharedContentState: SharedTransitionScope.SharedContentState, - bounds: Rect, - layoutDirection: LayoutDirection, - density: Density, - ): Path { - val windowArea = (window.width.toFloat() * window.height).coerceAtLeast(1f) - val coverage = bounds.width * bounds.height / windowArea - val roundness = 1f - ((coverage - ROUND_UNTIL_COVERAGE) / (1f - ROUND_UNTIL_COVERAGE)).coerceIn(0f, 1f) - val radius = corner.toPx(bounds.size, density) * roundness - return Path().apply { addRoundRect(RoundRect(bounds, CornerRadius(radius))) } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/DrawerNavItem.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/DrawerNavItem.kt deleted file mode 100644 index 71af04a5..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/DrawerNavItem.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.pasich.encly.presentation.navigation - -import androidx.annotation.StringRes -import androidx.compose.ui.graphics.vector.ImageVector -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.EnclyIcons - -/** - * A drawer destination. [groupEnd] draws a hairline after it. A [highlighted] one is drawn as a - * call-to-action button instead of a plain row (Support). - */ -data class DrawerNavItem( - @param:StringRes val titleRes: Int, - val icon: ImageVector, - val route: String, - val badgeCount: Int? = null, - val groupEnd: Boolean = false, - val highlighted: Boolean = false, -) - -/** - * The drawer's destinations, in order: Notes (always first), Tasks, Tags, Archive and Trash, a - * hairline, then Import notes directly above Settings, a hairline, Support (highlighted), FAQ - * and About. Tasks is always listed (the home card hides itself with nothing open, and the drawer - * is then the only way in); its badge counts open tasks. [donations] lists Support, which only - * the builds that allow external donations carry; the Play build has none. - */ -fun drawerNavItems(tagCount: Int, openTasks: Int, donations: Boolean): List = buildList { - add(DrawerNavItem(R.string.notes_title, EnclyIcons.Paper, NavRoutes.HomeRoute.name)) - add(DrawerNavItem(R.string.main_drawer_tasks, EnclyIcons.Checklist, NavRoutes.TasksRoute.name, openTasks)) - add(DrawerNavItem(R.string.main_drawer_tags, EnclyIcons.Tag, NavRoutes.EditTagRoute.name, tagCount)) - add(DrawerNavItem(R.string.main_drawer_archive, EnclyIcons.Archive, NavRoutes.ArchiveRoute.name)) - add(DrawerNavItem(R.string.main_drawer_trash, EnclyIcons.Trash, NavRoutes.TrashRoute.name, groupEnd = true)) - add(DrawerNavItem(R.string.import_title, EnclyIcons.Download, NavRoutes.ImportNotesRoute.name)) - add( - DrawerNavItem( - R.string.main_drawer_settings, - EnclyIcons.Sliders, - NavRoutes.SettingsRoute.name, - groupEnd = true, - ), - ) - if (donations) { - add( - DrawerNavItem( - R.string.main_drawer_support, - EnclyIcons.Coffee, - NavRoutes.SupportRoute.name, - highlighted = true, - ), - ) - } - add(DrawerNavItem(R.string.main_drawer_faq, EnclyIcons.Help, NavRoutes.FaqRoute.name)) - add(DrawerNavItem(R.string.about, EnclyIcons.Info, NavRoutes.AboutRoute.name)) -} 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 deleted file mode 100644 index 41aad8c5..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt +++ /dev/null @@ -1,245 +0,0 @@ -package com.pasich.encly.presentation.navigation - -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.navArgument -import com.pasich.encly.BuildConfig -import com.pasich.encly.presentation.effects.LocalSharedTransitionScope -import com.pasich.encly.presentation.effects.animationScreens -import com.pasich.encly.presentation.effects.enterFor -import com.pasich.encly.presentation.effects.exitFor -import com.pasich.encly.presentation.screen.AboutScreen -import com.pasich.encly.presentation.screen.EditTagScreen -import com.pasich.encly.presentation.screen.FaqScreen -import com.pasich.encly.presentation.screen.LicensesScreen -import com.pasich.encly.presentation.screen.LockScreen -import com.pasich.encly.presentation.screen.LossReason -import com.pasich.encly.presentation.screen.LossRecoveryScreen -import com.pasich.encly.presentation.screen.MainRootScreen -import com.pasich.encly.presentation.screen.PinCodeConfigScreen -import com.pasich.encly.presentation.screen.SupportScreen -import com.pasich.encly.presentation.screen.TasksScreen -import com.pasich.encly.presentation.screen.archive.ArchiveScreen -import com.pasich.encly.presentation.screen.backup.BackupScreen -import com.pasich.encly.presentation.screen.editnote.EditNoteScreen -import com.pasich.encly.presentation.screen.importnotes.ImportNotesScreen -import com.pasich.encly.presentation.screen.onboarding.OnboardingScreen -import com.pasich.encly.presentation.screen.settings.AppearanceScreen -import com.pasich.encly.presentation.screen.settings.DuressPinScreen -import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen -import com.pasich.encly.presentation.screen.settings.SettingsScreen -import com.pasich.encly.presentation.screen.settings.WipePinScreen -import com.pasich.encly.presentation.screen.trash.TrashScreen -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import com.pasich.encly.presentation.viewmodel.LossRecoveryViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel - -@Composable -fun AppNavHost(navController: NavHostController, startDestination: String = NavRoutes.HomeRoute.name) { - // The scope lets a note card and the editor share their bounds (see rememberNoteContainerModifier). - SharedTransitionLayout { - CompositionLocalProvider(LocalSharedTransitionScope provides this) { - NavHost( - navController, - startDestination = startDestination, - // The back gesture plays the same motion as a tap on Back (shared axis, a fade for the - // editor, none for the lock screen). Navigation's own default for the gesture (since 2.10) - // shrinks the leaving screen into the middle of the window. - predictivePopEnterTransition = { - enterFor(initialState.destination.route, targetState.destination.route, forward = false) - }, - predictivePopExitTransition = { - exitFor(initialState.destination.route, targetState.destination.route, forward = false) - }, - ) { - animationScreens(NavRoutes.HomeRoute.name) { - MainRootScreen( - navController, - ) - } - animationScreens(NavRoutes.ArchiveRoute.name) { - ArchiveScreen(navController) - } - animationScreens(NavRoutes.TrashRoute.name) { - TrashScreen( - navController, - ) - } - - animationScreens(NavRoutes.SettingsRoute.name) { - SettingsScreen( - navController, - ) - } - animationScreens(NavRoutes.EditTagRoute.name) { - EditTagScreen( - navController, - ) - } - - // Donations are an F-Droid build feature; the Play build has no route to them. - if (BuildConfig.DONATIONS_ENABLED) { - animationScreens(NavRoutes.SupportRoute.name) { - SupportScreen(navController) - } - } - - animationScreens( - route = "${NavRoutes.TasksRoute.name}?$TASKS_ADD_ARG={$TASKS_ADD_ARG}", - arguments = listOf( - navArgument(TASKS_ADD_ARG) { - type = NavType.BoolType - defaultValue = false - }, - ), - ) { entry -> - TasksScreen(navController, openNewTask = entry.arguments?.getBoolean(TASKS_ADD_ARG) == true) - } - - animationScreens(NavRoutes.AboutRoute.name) { - AboutScreen( - navController, - ) - } - - animationScreens(NavRoutes.LicensesRoute.name) { - LicensesScreen(navController) - } - - animationScreens(NavRoutes.FaqRoute.name) { - FaqScreen( - navController, - ) - } - - animationScreens(NavRoutes.SecuritySettingsRoute.name) { - SecuritySettingsScreen( - navController, - ) - } - - animationScreens(NavRoutes.AppearanceRoute.name) { - AppearanceScreen(navController) - } - - animationScreens(NavRoutes.BackupRoute.name) { - 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. - OnboardingScreen(onComplete = { - navController.navigate(NavRoutes.HomeRoute.name) { - popUpTo(NavRoutes.OnboardingRoute.name) { inclusive = true } - } - }) - } - - animationScreens(NavRoutes.LossDataRoute.name) { - LossRoute(navController, NavRoutes.LossDataRoute, LossReason.DAMAGED) - } - animationScreens(NavRoutes.LegacyVaultRoute.name) { - LossRoute(navController, NavRoutes.LegacyVaultRoute, LossReason.OLDER_VERSION) - } - animationScreens(NavRoutes.PinCodeConfig.name) { - PinCodeConfigScreen(navController) - } - - animationScreens(NavRoutes.WipePinRoute.name) { - WipePinScreen(navController) - } - - animationScreens(NavRoutes.DuressPinRoute.name) { - DuressPinScreen(navController) - } - - animationScreens(NavRoutes.LockRoute.name) { - LockScreen(navController) - } - - animationScreens( - route = "${NavRoutes.EditNoteRoute.name}/{idNote}" + - "?isReadTrashOnly={isReadTrashOnly}©Source={copySource}&addTag={addTag}", - arguments = listOf( - navArgument("idNote") { - type = NavType.LongType - defaultValue = -1L - }, - navArgument("isReadTrashOnly") { - type = NavType.BoolType - defaultValue = false - }, - navArgument("copySource") { - type = NavType.LongType - defaultValue = -1L - }, - navArgument("addTag") { - type = NavType.LongType - defaultValue = 0L - }, - ), - ) { entry -> - EditNoteDestination(navController, entry) - } - } - } - } -} - -/** - * The note editor. Records the id of its note on [entry] (also the id a new note gets on its - * first save), so a background re-lock can return to it (see [RelockReturn]), and the vault's - * erase epoch ([LocalEraseEpoch]) when it first showed it: the note is reopened only in that - * vault. - */ -@Composable -private fun EditNoteDestination( - navController: NavHostController, - entry: NavBackStackEntry, - viewModel: EditNoteViewModel = enclyViewModel(), -) { - val noteState by viewModel.state.collectAsState() - val eraseEpoch = LocalEraseEpoch.current - LaunchedEffect(entry, noteState.note.id, eraseEpoch) { - val state = entry.savedStateHandle - state[RelockReturn.OPEN_NOTE_ID] = noteState.note.id - // Kept from the first time: an erase while the editor stays open does not adopt it. - if (eraseEpoch != null && !state.contains(RelockReturn.OPEN_NOTE_EPOCH)) { - state[RelockReturn.OPEN_NOTE_EPOCH] = eraseEpoch() - } - } - // The screen resolves the same ViewModel instance: it is scoped to [entry]. - EditNoteScreen(navController) -} - -/** The vault cannot be opened ([reason]): wiping it, once confirmed, starts onboarding afresh. */ -@Composable -private fun LossRoute( - navController: NavHostController, - route: NavRoutes, - reason: LossReason, - recoveryViewModel: LossRecoveryViewModel = enclyViewModel(), -) { - LossRecoveryScreen( - reason = reason, - onRecoveryConfirm = { - recoveryViewModel.wipeAllData() - navController.navigate(NavRoutes.OnboardingRoute.name) { - popUpTo(route.name) { inclusive = true } - } - }, - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/AboutScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/AboutScreen.kt deleted file mode 100644 index 16b1ec69..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/AboutScreen.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.pasich.encly.presentation.screen - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -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.rememberScrollState -import androidx.compose.foundation.verticalScroll -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.core.net.toUri -import androidx.navigation.NavHostController -import com.pasich.encly.BuildConfig -import com.pasich.encly.R -import com.pasich.encly.core.LINK_FEEDBACK -import com.pasich.encly.core.LINK_PRIVACY_POLICY -import com.pasich.encly.core.LINK_REPOSITORY -import com.pasich.encly.core.MAIL_DEVELOPMENT -import com.pasich.encly.presentation.designsystem.EnclyCallout -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.EnclyNavigationRow -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.EnclyWordmark -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.ui.theme.EnclyTheme - -@Composable -fun AboutScreen(navController: NavHostController, modifier: Modifier = Modifier) { - val context = LocalContext.current - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.surface, - topBar = { EnclyTopBar(title = stringResource(R.string.about), onBack = { navController.popBackStack() }) }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), - ) { - HeroSection() - EnclyCallout( - title = stringResource(R.string.about_data_protection), - text = stringResource(R.string.about_encryption_note), - icon = EnclyIcons.Lock, - ) - DeveloperActionsSection(context, onSupport = { navController.navigate(NavRoutes.SupportRoute.name) }) - EnclyGroup { - EnclyNavigationRow( - title = stringResource(R.string.licenses_title), - supporting = stringResource(R.string.licenses_row_desc), - icon = EnclyIcons.File, - onClick = { navController.navigate(NavRoutes.LicensesRoute.name) }, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - } - } - } -} - -@Composable -private fun HeroSection() { - val versionName = BuildConfig.VERSION_NAME - - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - Row(verticalAlignment = Alignment.CenterVertically) { - EnclyWordmark(appName = stringResource(R.string.about_app_title), modifier = Modifier.weight(1f)) - Text( - text = "v$versionName", - style = EnclyTheme.typography.dataSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = stringResource(R.string.about_app_description), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -@Composable -private fun DeveloperActionsSection(context: Context, onSupport: () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(stringResource(R.string.about_support_title)) - EnclyGroup { - val rowModifier = Modifier.padding(horizontal = EnclyTheme.spacing.s) - EnclyNavigationRow( - title = stringResource(R.string.about_privacy_policy), - supporting = stringResource(R.string.about_privacy_policy_desc), - icon = EnclyIcons.Privacy, - onClick = { openExternalLink(context, LINK_PRIVACY_POLICY) }, - modifier = rowModifier, - ) - // Rate App: store builds only. The fdroid flavor carries no Google Play link. - if (BuildConfig.STORE_RATING_ENABLED) { - EnclyGroupDivider() - EnclyNavigationRow( - title = stringResource(R.string.about_rate_app), - supporting = stringResource(R.string.about_rate_app_desc), - icon = EnclyIcons.Star, - onClick = { openStorePage(context) }, - modifier = rowModifier, - ) - } - // Support the developer: fdroid only, same page as the drawer entry. - if (BuildConfig.DONATIONS_ENABLED) { - EnclyGroupDivider() - EnclyNavigationRow( - title = stringResource(R.string.main_drawer_support), - supporting = stringResource(R.string.about_support_dev_desc), - icon = EnclyIcons.Coffee, - onClick = onSupport, - modifier = rowModifier, - ) - } - EnclyGroupDivider() - // The code is public: a plain link in every flavor, not a donation or store link. - EnclyNavigationRow( - title = stringResource(R.string.about_source_code), - supporting = stringResource(R.string.about_source_code_desc), - icon = EnclyIcons.External, - onClick = { openExternalLink(context, LINK_REPOSITORY) }, - modifier = rowModifier, - ) - EnclyGroupDivider() - // Feedback: public GitHub issue tracker, no third-party form service. - EnclyNavigationRow( - title = stringResource(R.string.about_feedback), - supporting = stringResource(R.string.about_feedback_desc), - icon = EnclyIcons.Send, - onClick = { openExternalLink(context, LINK_FEEDBACK) }, - modifier = rowModifier, - ) - EnclyGroupDivider() - EnclyNavigationRow( - title = stringResource(R.string.about_write_developer), - supporting = stringResource(R.string.about_write_developer_desc), - icon = EnclyIcons.Mail, - onClick = { writeToDeveloper(context) }, - modifier = rowModifier, - ) - } - } -} - -private fun openStorePage(context: Context) { - val appPackageName = context.packageName - val marketIntent = Intent(Intent.ACTION_VIEW, "market://details?id=$appPackageName".toUri()) - try { - context.startActivity(marketIntent) - } catch (_: ActivityNotFoundException) { - openExternalLink(context, "https://play.google.com/store/apps/details?id=$appPackageName") - } -} - -private fun writeToDeveloper(context: Context) { - val intent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:$MAIL_DEVELOPMENT".toUri() - } - try { - context.startActivity(intent) - } catch (_: ActivityNotFoundException) { - Toast.makeText(context, context.getString(R.string.about_no_mail_client), Toast.LENGTH_SHORT).show() - } -} - -/** Opens [url] in the user's browser; Encly itself never performs the request. */ -internal fun openExternalLink(context: Context, url: String) { - try { - context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) - } catch (_: ActivityNotFoundException) { - Toast.makeText( - context, - context.getString(R.string.about_no_browser, url), - Toast.LENGTH_LONG, - ).show() - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/FaqScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/FaqScreen.kt deleted file mode 100644 index e1826044..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/FaqScreen.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -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.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.navigation.NavHostController -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.EnclyExpandableRow -import com.pasich.encly.presentation.designsystem.EnclyGroup -import com.pasich.encly.presentation.designsystem.EnclyGroupDivider -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.designsystem.StepHeading -import com.pasich.encly.ui.theme.EnclyTheme - -/** One FAQ section: its title and its question/answer pairs. */ -private class FaqSection(@param:StringRes val title: Int, val items: List>) - -private val faqSections = listOf( - FaqSection( - R.string.faq_section_basics, - listOf( - R.string.faq_new_note_q to R.string.faq_new_note_a, - R.string.faq_saving_q to R.string.faq_saving_a, - R.string.faq_note_actions_q to R.string.faq_note_actions_a, - R.string.faq_lock_note_q to R.string.faq_lock_note_a, - R.string.faq_trash_q to R.string.faq_trash_a, - R.string.faq_tags_q to R.string.faq_tags_a, - R.string.faq_tasks_q to R.string.faq_tasks_a, - ), - ), - FaqSection( - R.string.faq_section_security, - listOf( - R.string.faq_storage_q to R.string.faq_storage_a, - R.string.faq_backup_q to R.string.faq_backup_a, - R.string.faq_markdown_q to R.string.faq_markdown_a, - R.string.faq_swipe_q to R.string.faq_swipe_a, - R.string.faq_db_key_q to R.string.faq_db_key_a, - R.string.faq_recovery_phrase_q to R.string.faq_recovery_phrase_a, - R.string.faq_forgot_pin_q to R.string.faq_forgot_pin_a, - R.string.faq_screenshots_q to R.string.faq_screenshots_a, - R.string.faq_background_q to R.string.faq_background_a, - ), - ), - FaqSection( - R.string.faq_section_settings, - listOf( - R.string.faq_change_pin_q to R.string.faq_change_pin_a, - R.string.faq_biometrics_q to R.string.faq_biometrics_a, - R.string.faq_theme_q to R.string.faq_theme_a, - R.string.faq_language_q to R.string.faq_language_a, - ), - ), - FaqSection( - R.string.faq_section_troubleshooting, - listOf( - R.string.faq_vault_damaged_q to R.string.faq_vault_damaged_a, - R.string.faq_report_bug_q to R.string.faq_report_bug_a, - ), - ), -) - -@Composable -fun FaqScreen(navController: NavHostController, modifier: Modifier = Modifier) { - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - EnclyTopBar(title = stringResource(R.string.about_faq_title), onBack = { navController.popBackStack() }) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), - ) { - StepHeading( - title = stringResource(R.string.faq_screen_subtitle), - body = stringResource(R.string.faq_screen_description), - ) - faqSections.forEach { section -> - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(stringResource(section.title)) - EnclyGroup { - section.items.forEachIndexed { index, (question, answer) -> - if (index > 0) EnclyGroupDivider() - EnclyExpandableRow(question = stringResource(question), answer = stringResource(answer)) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt deleted file mode 100644 index 308474f2..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.navigation.NavHostController -import com.pasich.encly.R -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.EnclyTopBar -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.designsystem.StepHeading -import com.pasich.encly.ui.theme.EnclyTheme - -/** A third-party work Encly ships: its name, its licence and where to read both. */ -private class OpenSourceWork(val name: String, val license: String, val url: String) - -private const val APACHE_2 = "Apache License 2.0" -private const val OFL = "SIL Open Font License 1.1" - -private class LicenseSection(@param:StringRes val title: Int, val works: List) - -private val bundledFonts = listOf( - OpenSourceWork("Playfair Display", OFL, "https://github.com/clauseggers/Playfair"), - OpenSourceWork("Source Sans 3", OFL, "https://github.com/adobe-fonts/source-sans"), - OpenSourceWork("IBM Plex Sans", OFL, "https://github.com/IBM/plex"), - OpenSourceWork("Poppins", OFL, "https://github.com/itfoundry/Poppins"), -) - -private val bundledLibraries = listOf( - OpenSourceWork("AndroidX and Jetpack Compose", APACHE_2, "https://github.com/androidx/androidx"), - OpenSourceWork("Kotlin and kotlinx", APACHE_2, "https://github.com/JetBrains/kotlin"), - OpenSourceWork("Dagger Hilt", APACHE_2, "https://github.com/google/dagger"), - OpenSourceWork("SQLCipher for Android", "BSD 3-Clause", "https://github.com/sqlcipher/sqlcipher-android"), - OpenSourceWork("kotlin-bip39", "MIT License", "https://github.com/Electric-Coin-Company/kotlin-bip39"), - OpenSourceWork("Gson", APACHE_2, "https://github.com/google/gson"), - OpenSourceWork("Okio", APACHE_2, "https://github.com/square/okio"), - OpenSourceWork("Guava ListenableFuture", APACHE_2, "https://github.com/google/guava"), - OpenSourceWork("Reorderable", APACHE_2, "https://github.com/Calvin-LL/Reorderable"), - OpenSourceWork("Lucide", "ISC License", "https://github.com/lucide-icons/lucide"), -) - -/** The fonts and libraries Encly is built with, each linking to its licence. */ -@Composable -fun LicensesScreen(navController: NavHostController, modifier: Modifier = Modifier) { - val context = LocalContext.current - val sections = listOf( - LicenseSection(R.string.licenses_libraries, bundledLibraries), - LicenseSection(R.string.licenses_fonts, bundledFonts), - ) - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - EnclyTopBar(title = stringResource(R.string.licenses_title), onBack = { navController.popBackStack() }) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), - ) { - StepHeading( - title = stringResource(R.string.licenses_heading), - body = stringResource(R.string.licenses_intro), - ) - sections.forEach { section -> - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(stringResource(section.title)) - EnclyGroup { - section.works.forEachIndexed { index, work -> - if (index > 0) EnclyGroupDivider() - EnclyListRow( - title = work.name, - supporting = work.license, - onClick = { openExternalLink(context, work.url) }, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) { - Icon( - EnclyIcons.External, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(EnclyTheme.spacing.iconSmall), - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt deleted file mode 100644 index 5f7163a9..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.annotation.StringRes -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.RecoveryPhraseState -import com.pasich.encly.presentation.screen.pincode.PinBuffer -import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft -import com.pasich.encly.presentation.viewmodel.PinUnlockResult -import com.pasich.encly.presentation.viewmodel.SeedUnlockResult - -/** - * What the lock screen's forms show: the PIN typed so far, the recovery phrase, and the last - * error of each. Held by [LockScreen] itself, above the "Unlocking…" view that replaces the - * forms while a credential is checked, so a wrong PIN or phrase is still shown afterwards - * instead of being lost with the form that asked for it. - */ -@Stable -internal class LockFormState { - /** The recovery-phrase form is shown instead of the PIN pad. */ - var useRecovery by mutableStateOf(false) - - /** The digits typed so far, wiped when taken or cleared (see [PinBuffer]). */ - private val pinDigits = PinBuffer() - - /** How many PIN digits are typed. */ - val pinLength: Int get() = pinDigits.length - - @get:StringRes - var pinError by mutableStateOf(null) - private set - - /** Changes on every wrong PIN, so the dots shake again. */ - var shakeKey by mutableIntStateOf(0) - private set - - var lockoutSeconds by mutableLongStateOf(0L) - - /** The recovery words typed so far. */ - val phrase = RecoveryPhraseState() - - @get:StringRes - var phraseError by mutableStateOf(null) - private set - - val lockedOut: Boolean get() = lockoutSeconds > 0L - - /** A digit on the keypad; the first digit of a new attempt clears the last error. */ - fun typeDigit(digit: Int) { - if (pinDigits.isFull || lockedOut || digit !in 0..MAX_DIGIT) return - if (pinLength == 0) pinError = null - pinDigits.add(digit) - } - - fun deleteDigit() = pinDigits.deleteLast() - - /** The full PIN, taken out of the form to be checked. The caller wipes the copy. */ - fun takePin(): CharArray = pinDigits.take() - - /** Forgets the typed digits. */ - fun clearPin() = pinDigits.clear() - - fun onPinResult(result: PinUnlockResult, lockoutRemainingMillis: Long) { - when (result) { - PinUnlockResult.WRONG_PIN -> { - pinError = R.string.lock_wrong_pin - shakeKey++ - lockoutSeconds = lockoutSecondsLeft(lockoutRemainingMillis) - } - - PinUnlockResult.DB_ERROR -> pinError = R.string.error_database_open - - PinUnlockResult.LOCKED_OUT -> lockoutSeconds = lockoutSecondsLeft(lockoutRemainingMillis) - - // The PIN can no longer unlock here: the recovery form says why, when there is one. - PinUnlockResult.KEY_LOST -> { - pinError = R.string.lock_pin_key_lost - phraseError = R.string.lock_pin_key_lost - } - - PinUnlockResult.SUCCESS, PinUnlockResult.BACKGROUNDED -> Unit - } - } - - /** Any edit of the words clears the last error. */ - fun onPhraseEdited() { - phraseError = null - } - - /** System back: from the recovery form to the PIN pad; the PIN pad itself stays. */ - fun back() { - if (useRecovery) leaveRecovery() - } - - /** Back to the PIN pad; the typed words are dropped. */ - fun leaveRecovery() { - useRecovery = false - phrase.clear() - phraseError = null - } - - /** A wrong phrase stays in the field, with the error under it, so it can be corrected. */ - fun onSeedResult(result: SeedUnlockResult) { - when (result) { - SeedUnlockResult.WRONG_SEED -> phraseError = R.string.lock_wrong_recovery_phrase - SeedUnlockResult.DB_ERROR -> phraseError = R.string.error_database_open - SeedUnlockResult.SUCCESS, SeedUnlockResult.BACKGROUNDED -> Unit - } - } - - private companion object { - const val MAX_DIGIT = 9 - } -} 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 deleted file mode 100644 index 983ac589..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt +++ /dev/null @@ -1,344 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -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.ui.Modifier -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.navigation.NavHostController -import com.pasich.encly.R -import com.pasich.encly.core.security.PIN_LENGTH -import com.pasich.encly.core.security.SensitiveDataCleaner -import com.pasich.encly.presentation.designsystem.EnclyButton -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput -import com.pasich.encly.presentation.effects.LocalUnlockReveal -import com.pasich.encly.presentation.effects.UnlockRevealState -import com.pasich.encly.presentation.effects.revealThen -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.navigation.RelockReturn -import com.pasich.encly.presentation.screen.pincode.AuthLoading -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.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 = enclyViewModel()::canReopenNote - val unlockReveal = LocalUnlockReveal.current - val exits = remember(navController, unlockReveal) { - LockExits( - onUnlock = { isSessionLocked -> - navController.leaveLockScreen(unlockReveal, isSessionLocked, canReopenNote) - }, - // A recovery-phrase unlock means the PIN was forgotten: set a new one before going on. - onRecoveryUnlock = { - navController.navigate(NavRoutes.PinCodeConfig.name) { - popUpTo(NavRoutes.LockRoute.name) { inclusive = true } - } - }, - onVaultLost = { - navController.navigate(NavRoutes.LossDataRoute.name) { - popUpTo(NavRoutes.LockRoute.name) { inclusive = true } - } - }, - ) - } - LockScreen(exits = exits, modifier = modifier) -} - -@Composable -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. - val form = remember { LockFormState() } - - // From the recovery form Back returns to the PIN pad; from the PIN pad it goes to - // [LockExits.onBack], or nowhere: by default Back never leaves the lock screen. - BackHandler(enabled = true) { onLockBack(busy, form, exits.onBack) } - - val biometricEnabled = remember { - viewModel.biometricEnabled() && viewModel.biometricAvailable() - } - - fun goHome() = exits.onUnlock(viewModel::isSessionLocked) - - fun goToPinReset() = exits.onRecoveryUnlock(viewModel::isSessionLocked) - - fun onPinKeyLoss() = handlePinKeyLoss(form, viewModel.recoveryAvailable(), biometricEnabled, exits.onVaultLost) - - fun promptBiometric() { - if (activity != null && viewModel.lockoutRemainingMillis() <= 0) { - viewModel.authenticateBiometric(activity) { unlocked -> - if (unlocked) goHome() - } - } - } - - LaunchedEffect(Unit) { - if (biometricEnabled) promptBiometric() - } - - Box(modifier = modifier.fillMaxSize()) { - if (form.useRecovery) { - SeedLockContent( - form = form, - busy = busy, - authenticateSeed = viewModel::authenticateSeed, - onUnlock = ::goToPinReset, - ) - // Drawn over the phrase form rather than instead of it, so it keeps its state. The PIN - // form shows the check in place (full dots, breathing logo) and hands over to the reveal. - if (busy) AuthLoading(stringResource(R.string.lock_unlocking)) - } else { - PinLockContent( - form = form, - capabilities = LockCapabilities( - biometricEnabled = biometricEnabled && activity != null, - recoveryAvailable = viewModel.recoveryAvailable(), - ), - pinAuth = PinAuth( - busy = busy, - lockoutRemainingMillis = viewModel::lockoutRemainingMillis, - authenticate = viewModel::authenticatePin, - onKeyLoss = ::onPinKeyLoss, - ), - onUnlock = ::goHome, - onPromptBiometric = ::promptBiometric, - ) - } - } -} - -private fun onLockBack(busy: Boolean, form: LockFormState, onBack: (() -> Unit)?) { - when { - busy -> Unit - form.useRecovery -> form.back() - else -> onBack?.invoke() - } -} - -private data class LockCapabilities(val biometricEnabled: Boolean, val recoveryAvailable: Boolean) - -/** The LockViewModel state and operations the PIN form needs, so the ViewModel itself stays in [LockScreen]. */ -private class PinAuth( - val busy: Boolean, - val lockoutRemainingMillis: () -> Long, - val authenticate: (pin: CharArray, onResult: (PinUnlockResult) -> Unit) -> Unit, - /** The PIN key is gone for good (see [PinUnlockResult.KEY_LOST]). */ - val onKeyLoss: () -> Unit, -) - -/** - * Leaves the lock screen for Home, and the note that was open when the app re-locked (see - * MainActivity), once the reveal covers the window so Home composes out of sight. If the - * session closed again while the reveal played (e.g. the app went to the background), it stays - * on the lock screen rather than open Home over a locked vault. The note is not reopened when - * [canReopenNote] says the open vault is not the one it was in (a wipe-PIN erase since). - */ -private fun NavHostController.leaveLockScreen( - reveal: UnlockRevealState?, - isSessionLocked: () -> Boolean, - canReopenNote: (savedEpoch: Long?) -> Boolean, -) { - val lockState = currentBackStackEntry?.savedStateHandle - val returnRoute = lockState?.get(RelockReturn.RETURN_ROUTE) - val returnEpoch = lockState?.get(RelockReturn.RETURN_EPOCH) - reveal.revealThen { - if (!isSessionLocked()) { - navigate(NavRoutes.HomeRoute.name) { - popUpTo(NavRoutes.LockRoute.name) { inclusive = true } - } - // Checked after the unlock: an erase is what changes the epoch. - if (returnRoute != null && canReopenNote(returnEpoch)) { - navigate(returnRoute) - currentBackStackEntry?.savedStateHandle?.set(RelockReturn.PLAIN_ENTRY, true) - } - } - } -} - -/** - * The PIN can no longer unlock on this device. With a recovery phrase the lock screen switches - * to it (the form shows why); with only a fingerprint the PIN pad keeps the message; with - * neither, nothing can open the vault here any more ([onVaultLost]). - */ -private fun handlePinKeyLoss( - form: LockFormState, - recoveryAvailable: Boolean, - biometric: Boolean, - onVaultLost: () -> Unit, -) { - if (recoveryAvailable) { - form.useRecovery = true - } else if (!biometric) { - onVaultLost() - } -} - -@Composable -private fun PinLockContent( - form: LockFormState, - capabilities: LockCapabilities, - pinAuth: PinAuth, - onUnlock: () -> Unit, - onPromptBiometric: () -> Unit, -) { - val busy = pinAuth.busy - PinLockoutTicker(form.lockedOut, pinAuth.lockoutRemainingMillis) { form.lockoutSeconds = it } - PinAuthenticationEffect(form = form, pinAuth = pinAuth, onUnlock = onUnlock) - - val shownError = form.pinError - PinEntryScaffold( - title = stringResource(R.string.lock_title), - bodyAtBottom = true, - // The sub-heading slot carries the lockout countdown or the last error. - subtitle = when { - form.lockedOut -> pinLockoutText(form.lockoutSeconds) - shownError != null -> stringResource(shownError) - else -> stringResource(R.string.lock_subtitle) - }, - subtitleIsError = form.lockedOut || shownError != null, - busy = busy, - ) { - PinEntry( - // The PIN is taken out of the form for the check; keep the dots full meanwhile. - entered = if (busy) PIN_LENGTH else form.pinLength, - error = shownError != null && form.pinLength == 0, - shakeKey = form.shakeKey, - enabled = !form.lockedOut && !busy, - actions = PinEntryActions( - onDigit = form::typeDigit, - onBackspace = form::deleteDigit, - onBiometric = onPromptBiometric.takeIf { capabilities.biometricEnabled }, - ), - ) - if (capabilities.recoveryAvailable) RecoveryLink(onUseRecovery = { form.useRecovery = true }) - } -} - -@Composable -private fun PinAuthenticationEffect(form: LockFormState, pinAuth: PinAuth, onUnlock: () -> Unit) { - val currentOnUnlock by rememberUpdatedState(onUnlock) - val currentOnKeyLoss by rememberUpdatedState(pinAuth.onKeyLoss) - LaunchedEffect(form.pinLength) { - if (form.pinLength != PIN_LENGTH) return@LaunchedEffect - val pin = form.takePin() - if (pinAuth.lockoutRemainingMillis() > 0) { - SensitiveDataCleaner.clear(pin) - return@LaunchedEffect - } - - // The ViewModel wipes [pin] once it is checked. - pinAuth.authenticate(pin) { result -> - when (result) { - PinUnlockResult.SUCCESS -> currentOnUnlock() - - PinUnlockResult.KEY_LOST -> { - form.onPinResult(result, pinAuth.lockoutRemainingMillis()) - currentOnKeyLoss() - } - - else -> form.onPinResult(result, pinAuth.lockoutRemainingMillis()) - } - } - } - // Digits typed but never submitted do not outlive the lock screen. - DisposableEffect(form) { onDispose { form.clearPin() } } -} - -@Composable -private fun SeedLockContent( - form: LockFormState, - busy: Boolean, - authenticateSeed: (phrase: CharArray, onResult: (SeedUnlockResult) -> Unit) -> Unit, - onUnlock: () -> Unit, -) { - // The loading view covers this form but not the keyboard: while the phrase is checked, the - // field lets go of focus, so the keyboard closes and cannot type into the phrase meanwhile. - val focusManager = LocalFocusManager.current - val keyboard = LocalSoftwareKeyboardController.current - LaunchedEffect(busy) { - if (busy) { - focusManager.clearFocus(force = true) - keyboard?.hide() - } - } - val submit = { - if (form.phrase.canSubmit && !busy) { - authenticateSeed(form.phrase.toCharArray()) { result -> - if (result == SeedUnlockResult.SUCCESS) onUnlock() else form.onSeedResult(result) - } - } - } - PinEntryScaffold( - title = stringResource(R.string.lock_recovery_title), - subtitle = stringResource(R.string.lock_recovery_subtitle), - icon = EnclyIcons.Key, - // The 12 cells need the room: no 96 dp top, so they stay above the keyboard. - compact = true, - // Pinned above the keyboard: typing the phrase must never hide the button. - footer = { - EnclyButton( - text = stringResource(R.string.lock_recover_access), - onClick = submit, - enabled = form.phrase.canSubmit && !busy, - ) - EnclyTextButton( - text = stringResource(R.string.lock_back_to_pin), - onClick = form::leaveRecovery, - modifier = Modifier.fillMaxWidth(), - enabled = !busy, - ) - }, - ) { - RecoveryPhraseInput( - state = form.phrase, - enabled = !busy, - error = form.phraseError?.let { stringResource(it) }, - onEdit = form::onPhraseEdited, - onDone = submit, - modifier = Modifier - .padding(horizontal = EnclyTheme.spacing.gutter) - .padding(top = EnclyTheme.spacing.l), - ) - } -} - -/** "Forgot your PIN? Use your recovery phrase" under the keypad. */ -@Composable -private fun RecoveryLink(onUseRecovery: () -> Unit) { - EnclyTextButton( - text = stringResource(R.string.lock_use_recovery_phrase), - onClick = onUseRecovery, - modifier = Modifier.padding( - top = EnclyTheme.spacing.l, - start = EnclyTheme.spacing.gutter, - end = EnclyTheme.spacing.gutter, - ), - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LossReason.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LossReason.kt deleted file mode 100644 index 19b0dc49..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LossReason.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.annotation.StringRes -import com.pasich.encly.R - -/** Why the vault cannot be opened. */ -enum class LossReason( - @param:StringRes val title: Int, - @param:StringRes val message: Int, - @param:StringRes val action: Int, -) { - /** The encrypted data or its keys were changed or lost. */ - DAMAGED(R.string.loss_title, R.string.loss_message, R.string.loss_hold_to_wipe), - - /** The data was written by Encly 1.x, which 2.0 cannot open (no migration). */ - OLDER_VERSION(R.string.legacy_vault_title, R.string.legacy_vault_message, R.string.legacy_vault_hold_to_start), -} 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 deleted file mode 100644 index ed45e916..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt +++ /dev/null @@ -1,299 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState -import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController -import androidx.navigation.NavOptions -import com.pasich.encly.R -import com.pasich.encly.domain.model.SwipeSettings -import com.pasich.encly.presentation.components.HomeTaskWidget -import com.pasich.encly.presentation.designsystem.EnclyFab -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclySearchField -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.IconWithTooltip -import com.pasich.encly.presentation.dialogs.NotesSortBottomSheet -import com.pasich.encly.presentation.drawer.MainDrawer -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.navigation.TASKS_ADD_ARG -import com.pasich.encly.presentation.viewmodel.LockNowViewModel -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 - -/** The scrim over the notes while the drawer is open. */ -private const val DRAWER_SCRIM_ALPHA = 0.32f - -@Composable -fun MainRootScreen(navController: NavHostController, modifier: Modifier = Modifier) { - // Not saved: coming back from a page opened in the drawer shows the notes with it closed. - val drawerState = remember { DrawerState(DrawerValue.Closed) } - val scope = rememberCoroutineScope() - // The screen draws under the status bar: the scrim dims the whole window, and the drawer - // pads its own content below the bar. - val statusBarDp = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } - - ModalNavigationDrawer( - modifier = modifier, - drawerState = drawerState, - gesturesEnabled = true, - scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = DRAWER_SCRIM_ALPHA), - drawerContent = { - MainDrawer( - drawerState = drawerState, - topInset = statusBarDp, - onItemClick = { selectedItem -> - scope.launch { - val navOptions = NavOptions.Builder() - .setLaunchSingleTop(true) - .setRestoreState(true) - .build() - navController.navigate(selectedItem.route, navOptions) - } - }, - ) - }, - ) { - MainScreen( - drawerState = drawerState, - navController = navController, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainScreen( - navController: NavHostController, - drawerState: DrawerState, - modifier: Modifier = Modifier, - 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() - // The field reads local state (a flow round trip can drop fast keystrokes); the ViewModel - // gets every change. - // Plain remember: a search query is note content and must not land in saved state. - var query by remember { mutableStateOf("") } - val searchResults by searchViewModel.results.collectAsStateWithLifecycle() - - // State restoration for scroll states - val listScrollState = rememberLazyListState() - val gridScrollState = rememberLazyStaggeredGridState() - val atTop by rememberAtTop(isGrid, listScrollState, gridScrollState) - val scrollRequest by noteListViewModel.scrollToTopRequest.collectAsStateWithLifecycle() - ScrollToTopOnRequest(scrollRequest, isGrid, listScrollState, gridScrollState, noteListViewModel::onScrolledToTop) - - var viewOptionsVisible by rememberSaveable { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val navigation = remember(navController) { MainNavigation(navController) } - - if (viewOptionsVisible) { - ViewOptionsSheet( - isGrid = isGrid, - onToggleView = mainListStateViewModel::updateGridNoteList, - onClose = { viewOptionsVisible = false }, - ) - } - - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.surface, - floatingActionButton = { - NewNoteFab(expanded = atTop, onClick = { navigation.newNote(noteListViewModel.state.value.selectedTag) }) - }, - ) { padding -> - // The Scaffold has no top bar slot, so `padding` carries the status bar; consuming it - // keeps the notes bar below from padding the same inset again. - Column(modifier = Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding)) { - NotesTopBar( - onMenu = { scope.launch { drawerState.open() } }, - onLockNow = lockNowViewModel::lockNow, - onSettings = { navigation.open(NavRoutes.SettingsRoute.name) }, - ) - NotesList( - isGrid = isGrid, - listScrollState = listScrollState, - gridScrollState = gridScrollState, - search = NotesSearch(query, searchResults), - swipe = settingsViewModel.swipeSettings.collectAsState().value ?: SwipeSettings.Off, - onItemClick = { note -> navigation.editNote(note.id) }, - onSecondActionClick = { note -> navigation.editNote(-1, "?copySource=${note.id}") }, - header = { - NotesHeader( - query = query, - onQueryChange = { - query = it - searchViewModel.onQueryChange(it) - }, - onViewOptions = { viewOptionsVisible = true }, - tasks = HomeTasks( - onOpen = { navigation.open(NavRoutes.TasksRoute.name) }, - onNewTask = { navigation.open("${NavRoutes.TasksRoute.name}?$TASKS_ADD_ARG=true") }, - ).takeIf { showTasks && query.isBlank() }, - ) - }, - ) - } - } -} - -/** Whether the notes are scrolled to the top; the FAB then shows its label. */ -@Composable -private fun rememberAtTop(isGrid: Boolean, list: LazyListState, grid: LazyStaggeredGridState): State = - remember(isGrid, list, grid) { - derivedStateOf { if (isGrid) grid.firstVisibleItemIndex == 0 else list.firstVisibleItemIndex == 0 } - } - -/** - * Scrolls the notes back to the top while the ViewModel has a [request] pending (a new sort order - * or a new note), then reports it done through [onScrollComplete]. The request stays pending while the - * editor is open, so coming back to a list that got a new note meanwhile scrolls too; an - * unchanged list, or one restored after process death, keeps its position. - */ -@Composable -private fun ScrollToTopOnRequest( - request: Int?, - isGrid: Boolean, - list: LazyListState, - grid: LazyStaggeredGridState, - onScrollComplete: (Int) -> Unit, -) { - val currentOnScrollComplete by rememberUpdatedState(onScrollComplete) - LaunchedEffect(request) { - if (request == null) return@LaunchedEffect - if (isGrid) grid.scrollToItem(0) else list.scrollToItem(0) - currentOnScrollComplete(request) - } -} - -/** "New note", collapsed to its icon once the list scrolls. */ -@Composable -private fun NewNoteFab(expanded: Boolean, onClick: () -> Unit) { - EnclyFab( - text = stringResource(R.string.note_add), - icon = EnclyIcons.PlusBold, - expanded = expanded, - onClick = onClick, - ) -} - -/** The view options sheet (sort order, list or grid); [onClose] runs once it has slid away. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ViewOptionsSheet(isGrid: Boolean, onToggleView: () -> Unit, onClose: () -> Unit) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - NotesSortBottomSheet( - isBottomSheetVisible = true, - sheetState = sheetState, - isGrid = isGrid, - onToggleView = onToggleView, - onDismiss = { scope.launch { sheetState.hide() }.invokeOnCompletion { onClose() } }, - ) -} - -/** Where the notes screen goes: a note (new, existing or a copy) or another screen. */ -private class MainNavigation(private val navController: NavHostController) { - fun editNote(noteId: Long, params: String = "") { - val navOptions = NavOptions.Builder().setLaunchSingleTop(true).build() - navController.navigate("${NavRoutes.EditNoteRoute.name}/$noteId$params", navOptions) - } - - /** A new note, tagged with the chip selected in the list (0: none). */ - fun newNote(selectedTag: Long) = editNote(-1, if (selectedTag != 0L) "?addTag=$selectedTag" else "") - - fun open(route: String) { - val navOptions = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true).build() - navController.navigate(route, navOptions) - } -} - -/** The root bar of the notes screen: menu, "Notes", Lock now and Settings. */ -@Composable -private fun NotesTopBar(onMenu: () -> Unit, onLockNow: () -> Unit, onSettings: () -> Unit) { - EnclyTopBar( - title = stringResource(R.string.notes_title), - navigation = { - IconWithTooltip(EnclyIcons.Menu, stringResource(R.string.open_menu), onMenu) - }, - actions = { - IconWithTooltip(EnclyIcons.Lock, stringResource(R.string.lock_now), onLockNow) - IconWithTooltip(EnclyIcons.Sliders, stringResource(R.string.main_drawer_settings), onSettings) - }, - ) -} - -/** Where the home tasks card leads: all tasks, or straight to a new one. Null hides the card. */ -private class HomeTasks(val onOpen: () -> Unit, val onNewTask: () -> Unit) - -/** Above the notes: the search field with its view options, the tag chips and the tasks card. */ -@Composable -private fun NotesHeader(query: String, onQueryChange: (String) -> Unit, onViewOptions: () -> Unit, tasks: HomeTasks?) { - val gutter = EnclyTheme.spacing.listGutter - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.rowGap)) { - EnclySearchField( - value = query, - onValueChange = onQueryChange, - placeholder = stringResource(R.string.search_placeholder), - modifier = Modifier.padding(horizontal = gutter), - trailing = { - if (query.isNotEmpty()) { - IconWithTooltip(EnclyIcons.Close, stringResource(R.string.search_clear), onClick = { - onQueryChange("") - }) - } else { - IconWithTooltip(EnclyIcons.Filter, stringResource(R.string.view_options), onViewOptions) - } - }, - ) - if (query.isBlank()) TagsList() - if (tasks != null) { - HomeTaskWidget( - onTasksClick = tasks.onOpen, - onNewTask = tasks.onNewTask, - modifier = Modifier.padding(horizontal = gutter), - ) - } - } -} 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 deleted file mode 100644 index 195ecf4a..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt +++ /dev/null @@ -1,429 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.pasich.encly.R -import com.pasich.encly.core.common.LoadState -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.domain.model.NoteListItem -import com.pasich.encly.domain.model.SwipeSettings -import com.pasich.encly.presentation.components.swipe.SwipeContext -import com.pasich.encly.presentation.components.swipe.SwipeableNote -import com.pasich.encly.presentation.components.swipe.rememberSwipeContext -import com.pasich.encly.presentation.components.tiles.NoteItem -import com.pasich.encly.presentation.designsystem.EnclyEmptyState -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.NoteSkeleton -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.designsystem.bleed -import com.pasich.encly.presentation.dialogs.NoteAction -import com.pasich.encly.presentation.dialogs.NoteCardBottomSheet -import com.pasich.encly.presentation.viewmodel.NoteListEvent -import com.pasich.encly.presentation.viewmodel.NoteListEvent.ChangeTag -import com.pasich.encly.presentation.viewmodel.NoteListEvent.NoteToTrash -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.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.launch - -private const val SHEET_CLEAR_DELAY_MS = 200L -private const val FADE_MS = 300 -private const val SKELETON_CARDS = 4 - -/** A grid card shows more of its note than a list card. */ -private const val GRID_EXCERPT_LINES = 4 - -/** What the search field of the notes screen holds, and the matches for it. */ -class NotesSearch(val query: String, val results: NoteSearchViewModel.SearchResults) - -@Composable -fun NotesList( - isGrid: Boolean, - listScrollState: LazyListState, - gridScrollState: LazyStaggeredGridState, - onItemClick: (Note) -> Unit, - modifier: Modifier = Modifier, - search: NotesSearch = NotesSearch("", NoteSearchViewModel.SearchResults()), - swipe: SwipeSettings = SwipeSettings.Off, - onSecondActionClick: (Note) -> Unit = {}, - header: @Composable () -> Unit = {}, - noteListViewModel: NoteListViewModel = enclyViewModel(), -) { - val state by noteListViewModel.state.collectAsStateWithLifecycle() - val sheet = remember { NoteSheetState() } - - NoteActionsSheet( - sheet = sheet, - observeNote = noteListViewModel::observeNote, - onEvent = noteListViewModel::onEvent, - onEdit = onItemClick, - onDuplicate = onSecondActionClick, - ) - - // While a search is typed the list shows its matches, highlighted, instead of the tag's notes. - val searching = search.query.isNotBlank() - val shown = if (searching) search.results.notes else state.notes - val content = NotesContent( - // Search results come in match order, without the pinned/other sections. - entries = remember(shown, searching) { noteEntries(shown, sections = !searching) }, - highlight = if (searching) search.results.query else "", - onItemClick = onItemClick, - onItemLongClick = sheet::open, - swipe = rememberSwipeContext(swipe, noteListViewModel::perform), - ) - val status: @Composable () -> Unit = { - NotesStatus( - state = state, - searching = searching, - search = search, - onShowAll = { noteListViewModel.onEvent(NoteListEvent.SelectTag(Tag(id = 0, nameTag = "All"))) }, - ) - } - - if (isGrid && !searching) { - NotesGrid(content, gridScrollState, NotesTop(header, status), modifier) - } else { - NotesColumn(content, listScrollState, NotesTop(header, status), modifier) - } -} - -/** The long-pressed note whose actions sheet is open. */ -@Stable -internal class NoteSheetState { - var active by mutableStateOf(null) - var visible by mutableStateOf(false) - - fun open(item: NoteListItem) { - active = item - visible = true - } -} - -/** A note's actions: edit, pin, archive, duplicate, delete, its tag and description. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun NoteActionsSheet( - sheet: NoteSheetState, - observeNote: (Long) -> Flow, - onEvent: (NoteListEvent) -> Unit, - onEdit: (Note) -> Unit, - onDuplicate: (Note) -> Unit, -) { - val scope = rememberCoroutineScope() - val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - // The sheet shows the note as stored now, not as the list held it when the sheet opened: a tag - // changed here moves the note out of this list, and the next action must still act on the right row. - val activeId = sheet.active?.note?.id - val live by remember(activeId) { activeId?.let(observeNote) ?: emptyFlow() }.collectAsState(initial = null) - - fun close() { - sheet.visible = false - scope.launch { - bottomSheetState.hide() - delay(SHEET_CLEAR_DELAY_MS) - sheet.active = null - } - } - - val item = live ?: sheet.active?.noteWithTag ?: return - NoteCardBottomSheet( - isVisible = sheet.visible, - item = item, - sheetState = bottomSheetState, - onAction = { action -> - when (action) { - NoteAction.Edit -> onEdit(item.note) - NoteAction.Duplicate -> onDuplicate(item.note) - else -> action.toEvent(item.note)?.let(onEvent) - } - // Changing the tag, the colour or the description keeps the sheet open. - if (action.closesSheet()) close() - }, - onDismiss = ::close, - ) -} - -private fun NoteAction.closesSheet(): Boolean = - this !is NoteAction.ChangeTag && this !is NoteAction.ChangeColor && this !is NoteAction.ChangeDescription - -/** The list event a sheet [NoteAction] on [note] stands for; null for the ones that open another screen. */ -private fun NoteAction.toEvent(note: Note): NoteListEvent? = when (this) { - NoteAction.Edit, NoteAction.Duplicate -> null - NoteAction.Delete -> NoteToTrash(note) - is NoteAction.Pin -> NoteListEvent.PinNote(note, pinned) - is NoteAction.Archive -> NoteListEvent.ArchiveNote(note, archived) - is NoteAction.ChangeColor -> NoteListEvent.ChangeColor(note, color) - is NoteAction.ChangeTag -> ChangeTag(note = note, idTag = tagId) - is NoteAction.ChangeDescription -> NoteListEvent.ChangeDescription(note = note, description = description) -} - -/** What sits above the cards: the caller's header, then the loading, empty or search status. */ -private class NotesTop(val header: @Composable () -> Unit, val status: @Composable () -> Unit) - -/** The cards to show and what a tap or long-press on one does. */ -private class NotesContent( - val entries: List, - val highlight: String, - val onItemClick: (Note) -> Unit, - val onItemLongClick: (NoteListItem) -> Unit, - val swipe: SwipeContext, -) - -/** Room for the last card above the FAB. */ -@Composable -private fun notesBottomPadding() = EnclyTheme.spacing.fabHeight + EnclyTheme.spacing.l + EnclyTheme.spacing.m - -@Composable -private fun NotesColumn(content: NotesContent, state: LazyListState, top: NotesTop, modifier: Modifier = Modifier) { - val spacing = EnclyTheme.spacing - LazyColumn( - modifier = modifier.fillMaxSize(), - state = state, - contentPadding = PaddingValues(top = spacing.xs, bottom = notesBottomPadding()), - verticalArrangement = Arrangement.spacedBy(spacing.cardGap), - ) { - item { - Column(verticalArrangement = Arrangement.spacedBy(spacing.cardGap)) { - top.header() - top.status() - } - } - items(items = content.entries, key = { it.key }) { entry -> - when (entry) { - is NoteSection -> SectionOverline( - text = stringResource(entry.title), - modifier = Modifier - .padding(horizontal = spacing.listGutter) - .animateItem(), - ) - - is NoteRow -> SwipeableNote( - note = entry.item.note, - swipe = content.swipe, - modifier = Modifier - .padding(horizontal = spacing.listGutter) - .animateItem(), - ) { cardModifier -> - NoteItem( - item = entry.item, - highlight = content.highlight, - modifier = cardModifier, - onItemClick = content.onItemClick, - onItemLongClick = { content.onItemLongClick(entry.item) }, - ) - } - } - } - } -} - -@Composable -private fun NotesGrid( - content: NotesContent, - state: LazyStaggeredGridState, - top: NotesTop, - modifier: Modifier = Modifier, -) { - val spacing = EnclyTheme.spacing - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(2), - modifier = modifier.fillMaxSize(), - state = state, - contentPadding = PaddingValues( - start = spacing.listGutter, - end = spacing.listGutter, - top = spacing.xs, - bottom = notesBottomPadding(), - ), - verticalItemSpacing = spacing.cardGap, - horizontalArrangement = Arrangement.spacedBy(spacing.cardGap), - ) { - // Full-width rows lay themselves out on the gutter, as in the list. - item(span = StaggeredGridItemSpan.FullLine) { - Column( - verticalArrangement = Arrangement.spacedBy(spacing.cardGap), - modifier = Modifier.bleed(spacing.listGutter), - ) { - top.header() - top.status() - } - } - items( - items = content.entries, - key = { it.key }, - span = { entry -> - if (entry is NoteSection) StaggeredGridItemSpan.FullLine else StaggeredGridItemSpan.SingleLane - }, - ) { entry -> - when (entry) { - is NoteSection -> SectionOverline(text = stringResource(entry.title), modifier = Modifier.animateItem()) - - is NoteRow -> SwipeableNote( - note = entry.item.note, - swipe = content.swipe, - modifier = Modifier.animateItem(), - ) { cardModifier -> - NoteItem( - item = entry.item, - excerptLines = GRID_EXCERPT_LINES, - modifier = cardModifier, - onItemClick = content.onItemClick, - onItemLongClick = { content.onItemLongClick(entry.item) }, - ) - } - } - } - } -} - -/** The skeleton while notes load, the empty or failed state, or "nothing matches" for a search. */ -@Composable -private fun NotesStatus(state: NoteListState, searching: Boolean, search: NotesSearch, onShowAll: () -> Unit) { - val spacing = EnclyTheme.spacing - val notesLoad = state.notesLoad - when { - searching -> { - val resultsAreCurrent = search.results.query == search.query.trim() - if (resultsAreCurrent && search.results.notes.isEmpty()) { - Text( - text = stringResource(R.string.search_nothing_matches, search.results.query), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = spacing.listGutter, vertical = spacing.l), - ) - } - } - - notesLoad is LoadState.Loading -> Column( - verticalArrangement = Arrangement.spacedBy(spacing.cardGap), - modifier = Modifier.padding(horizontal = spacing.listGutter), - ) { - repeat(SKELETON_CARDS) { NoteSkeleton() } - } - - else -> NotesEmptyOrFailed(state, onShowAll) - } -} - -/** A row of the notes list: a section heading, or a note. [key] is stable across reorders. */ -internal sealed interface NoteEntry { - val key: Any -} - -/** The "Pinned" / "Others" heading above a group of notes. */ -internal data class NoteSection(@StringRes val title: Int) : NoteEntry { - override val key: Any get() = "section-$title" -} - -@Stable -internal data class NoteRow(val item: NoteListItem) : NoteEntry { - override val key: Any get() = item.note.id -} - -/** - * [notes] (already ordered: pinned first, then by the chosen sort, archived last) as list rows. With - * [sections], pinned notes sit under "Pinned", the rest under "Others" and, in a hidden tag's view, its - * archived notes under "Archive"; with neither pinned nor archived notes there are no headings at all. - */ -internal fun noteEntries(notes: List, sections: Boolean): List { - val rows = notes.map(::NoteRow) - // An archived note is never pinned; should a stored one be both, it is listed once, as archived. - val pinned = rows.filter { it.item.note.isPinned && !it.item.note.isArchived } - val archived = rows.filter { it.item.note.isArchived } - val others = rows - pinned.toSet() - archived.toSet() - // Plain notes alone need no heading; next to pinned or archived ones they are "Others". - if (!sections || (pinned.isEmpty() && archived.isEmpty())) return rows - return buildList { - if (pinned.isNotEmpty()) { - add(NoteSection(R.string.notes_section_pinned)) - addAll(pinned) - } - if (others.isNotEmpty()) { - add(NoteSection(R.string.notes_section_other)) - addAll(others) - } - if (archived.isNotEmpty()) { - add(NoteSection(R.string.main_drawer_archive)) - addAll(archived) - } - } -} - -/** - * "No notes yet" once loading finished with nothing, "No notes tagged …" (with Show all) while a - * tag chip filters, or the read error when loading failed. A failed read is never shown as - * "No notes": the vault must not look emptied. - */ -@Composable -private fun NotesEmptyOrFailed(state: NoteListState, onShowAll: () -> Unit) { - val failure = (state.notesLoad as? LoadState.Failed)?.error - val empty = state.emptyState - AnimatedVisibility( - visible = failure != null || empty != null, - enter = fadeIn(animationSpec = tween(durationMillis = FADE_MS)), - exit = fadeOut(animationSpec = tween(durationMillis = FADE_MS)), - ) { - when { - failure != null -> EnclyEmptyState( - icon = EnclyIcons.Paper, - title = failure.title.asString(), - body = failure.message.asString(), - error = true, - ) - - empty is NotesEmptyState.NoneTagged -> EnclyEmptyState( - icon = EnclyIcons.Tag, - title = stringResource(R.string.empty_notes_tagged, empty.tagName), - body = stringResource(R.string.empty_notes_tagged_desc), - actionLabel = stringResource(R.string.show_all_notes), - onAction = onShowAll, - ) - - else -> EnclyEmptyState( - icon = EnclyIcons.Paper, - title = stringResource(R.string.empty_notes), - body = stringResource(R.string.empty_notes_desc), - ) - } - } -} 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 deleted file mode 100644 index be632cc9..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ /dev/null @@ -1,335 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -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.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 -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.navigation.NavRoutes -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.delay - -private const val PIN_SUCCESS_DELAY_MS = 1_000L -private const val SUCCESS_SCALE_MS = 500 - -enum class PinAnimationState { - Entering, - SuccessAnimation, -} - -/** - * The three-step PIN change: current PIN (skipped after a recovery-phrase unlock), new PIN, - * confirmation. Holds the screen state so the composable only renders it. The PINs are - * CharArrays that are wiped once used, never Strings (see [PinBuffer]). - */ -private class PinChangeState(val isReset: Boolean) { - var step by mutableIntStateOf(if (isReset) 1 else 0) - - /** The new PIN typed at step 1, until the confirmation is compared with it. */ - private var firstPin: CharArray? = null - - /** The digits of the current step. */ - val input = PinBuffer() - var errorText by mutableStateOf(null) - var animationState by mutableStateOf(PinAnimationState.Entering) - var lockoutSeconds by mutableLongStateOf(0L) - - /** Changes on every wrong entry, so the dots shake again. */ - var shakeKey by mutableIntStateOf(0) - - /** Digits are refused while the current PIN is locked out. */ - val keysEnabled: Boolean get() = !(step == 0 && lockoutSeconds > 0L) - - /** A digit on the keypad; refused during a lockout. */ - 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) { - 0 -> viewModel.verifyCurrentPin(pin) { ok -> onCurrentPinChecked(ok, viewModel) } - - 1 -> { - firstPin?.let(SensitiveDataCleaner::clear) - firstPin = pin - errorText = null - step = 2 - } - - else -> confirmNewPin(pin, viewModel) - } - } - - private fun confirmNewPin(pin: CharArray, viewModel: SecuritySettingsViewModel) { - val first = firstPin - firstPin = null - val matches = first != null && pin.contentEquals(first) - first?.let(SensitiveDataCleaner::clear) - if (matches) { - 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 { - SensitiveDataCleaner.clear(pin) - restartNewPin(R.string.pin_mismatch_retry) - } - } - - /** Typed or kept digits do not outlive the screen. */ - fun clear() { - input.clear() - firstPin?.let(SensitiveDataCleaner::clear) - firstPin = null - } - - private fun onCurrentPinChecked(ok: Boolean, viewModel: SecuritySettingsViewModel) { - val lockout = lockoutSecondsLeft(viewModel.pinLockoutRemainingMillis()) - errorText = null - when { - ok -> step = 1 - - // 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 restartNewPin(@StringRes error: Int) { - errorText = error - shakeKey++ - firstPin?.let(SensitiveDataCleaner::clear) - firstPin = null - step = 1 - } -} - -@OptIn(ExperimentalAnimationApi::class) -@Composable -fun PinCodeConfigScreen( - navController: NavHostController, - modifier: Modifier = Modifier, - 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. - val pinState = remember { PinChangeState(isReset = !securityViewModel.requiresCurrentPin()) } - - BackHandler(enabled = pinState.isReset) { } - PinLockoutTicker(pinState.lockoutSeconds > 0L, securityViewModel::pinLockoutRemainingMillis) { - pinState.lockoutSeconds = it - } - - LaunchedEffect(pinState.input.length) { - if (pinState.input.isFull && pinState.animationState == PinAnimationState.Entering) { - pinState.onPinComplete(securityViewModel) - } - } - DisposableEffect(pinState) { onDispose { pinState.clear() } } - - LaunchedEffect(pinState.animationState) { - if (pinState.animationState == PinAnimationState.SuccessAnimation) { - delay(PIN_SUCCESS_DELAY_MS) - navController.finishPinChange(pinState.isReset) - } - } - - Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { - Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) { - // From Settings there is a way back; a forgotten-PIN reset must end with a new PIN. - if (!pinState.isReset) { - EnclyTopBar( - title = stringResource(R.string.pin_change_bar_title), - onBack = { navController.popBackStack() }, - ) - } - Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { - if (pinState.animationState == PinAnimationState.Entering) { - AnimatedContent( - targetState = pinState.step, - transitionSpec = { - (slideInHorizontally { width -> width } + fadeIn()) - .togetherWith(slideOutHorizontally { width -> -width } + fadeOut()) - }, - label = "StepAnimation", - ) { currentStep -> - MainPinContent( - text = pinStepText(currentStep, pinState), - entered = pinState.input.length, - entry = PinEntryState( - enabled = pinState.keysEnabled, - shakeKey = pinState.shakeKey, - compact = !pinState.isReset, - ), - onInput = pinState::typeDigit, - onDelete = pinState.input::deleteLast, - ) - } - } else { - SuccessAnimation() - } - } - } - } -} - -/** A reset (forgotten PIN) ends in the app; a change from Settings goes back there. */ -private fun NavHostController.finishPinChange(isReset: Boolean) { - if (isReset) { - navigate(NavRoutes.HomeRoute.name) { - popUpTo(NavRoutes.PinCodeConfig.name) { inclusive = true } - } - } else { - popBackStack() - } -} - -@Composable -private fun pinStepText(step: Int, state: PinChangeState): PinStepText { - val lockout = state.lockoutSeconds.takeIf { it > 0L && step == 0 }?.let { pinLockoutText(it) } - return PinStepText( - title = pinStepTitle(step), - subtitle = if (state.isReset && step == 1) R.string.pin_reset_subtitle else pinStepSubtitle(step), - message = lockout ?: state.errorText?.let { stringResource(it) }, - ) -} - -@StringRes -private fun pinStepTitle(step: Int): Int = when (step) { - 0 -> R.string.pin_change_current_title - 1 -> R.string.pin_create_title - else -> R.string.pin_change_confirm_title -} - -@StringRes -private fun pinStepSubtitle(step: Int): Int = when (step) { - 0 -> R.string.pin_change_current_subtitle - 1 -> R.string.pin_change_new_subtitle - else -> R.string.pin_change_confirm_subtitle -} - -/** What a PIN step shows: its title and subtitle, and an error or lockout message. */ -data class PinStepText(@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, the shake, a compact top. */ -private class PinEntryState(val enabled: Boolean, val shakeKey: Int, val compact: Boolean) - -@Composable -private fun MainPinContent( - text: PinStepText, - entered: Int, - entry: PinEntryState, - onInput: (Int) -> Unit, - onDelete: () -> Unit, -) { - 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 = entry.compact, - bodyAtBottom = true, - ) { - PinEntry( - entered = entered, - error = message != null && entered == 0, - shakeKey = entry.shakeKey, - enabled = entry.enabled, - actions = PinEntryActions( - onDigit = onInput, - onBackspace = onDelete, - ), - ) - } -} - -@Composable -private fun SuccessAnimation() { - val scale = remember { Animatable(0f) } - val description = stringResource(R.string.pin_changed) - - LaunchedEffect(Unit) { - scale.animateTo( - targetValue = 1f, - animationSpec = tween(durationMillis = SUCCESS_SCALE_MS, easing = FastOutSlowInEasing), - ) - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.m, Alignment.CenterVertically), - modifier = Modifier - .fillMaxSize() - .semantics(mergeDescendants = true) { contentDescription = description }, - ) { - EnclyIconTile(icon = EnclyIcons.Check, modifier = Modifier.scale(scale.value)) - Text( - text = description, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.alpha(scale.value.coerceIn(0f, 1f)), - ) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/SupportScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/SupportScreen.kt deleted file mode 100644 index 029bc88e..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/SupportScreen.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.pasich.encly.presentation.screen - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -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.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.navigation.NavHostController -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.EnclyIconTile -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.FactRow -import com.pasich.encly.presentation.designsystem.StepHeading -import com.pasich.encly.ui.theme.EnclyTheme - -private const val KOFI_URL = "https://ko-fi.com/pasichdev" - -/** - * Support the developer (F-Droid build only): why donations matter, then one Ko-fi card that - * opens the page in the browser; the app never handles payments. - */ -@Composable -fun SupportScreen(navController: NavHostController, modifier: Modifier = Modifier) { - val context = LocalContext.current - val spacing = EnclyTheme.spacing - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - EnclyTopBar(title = stringResource(R.string.support_title), onBack = { navController.popBackStack() }) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .navigationBarsPadding() - .padding(horizontal = spacing.gutter, vertical = spacing.l), - verticalArrangement = Arrangement.spacedBy(spacing.section), - ) { - EnclyIconTile(icon = EnclyIcons.Heart) - StepHeading( - title = stringResource(R.string.donation_title), - body = stringResource(R.string.donation_description), - large = true, - ) - SupportFacts() - Column(verticalArrangement = Arrangement.spacedBy(spacing.s)) { - KofiCard(onClick = { openExternalLink(context, KOFI_URL) }) - Text( - text = stringResource(R.string.support_browser_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -/** Why support matters, as three Welcome-style facts. */ -@Composable -private fun SupportFacts() { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.factGap)) { - FactRow( - icon = EnclyIcons.Star, - title = stringResource(R.string.support_fact_free_title), - description = stringResource(R.string.support_fact_free_desc), - ) - FactRow( - icon = EnclyIcons.Privacy, - title = stringResource(R.string.support_fact_private_title), - description = stringResource(R.string.support_fact_private_desc), - ) - FactRow( - icon = EnclyIcons.Heart, - title = stringResource(R.string.support_fact_uses_title), - description = stringResource(R.string.support_fact_uses_desc), - ) - } -} - -/** The Ko-fi card, filled with `primaryContainer` so it reads as the screen's one action. */ -@Composable -private fun KofiCard(onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val spacing = EnclyTheme.spacing - Surface( - shape = MaterialTheme.shapes.medium, - color = colors.primaryContainer, - contentColor = colors.onPrimaryContainer, - modifier = Modifier - .fillMaxWidth() - .clickable(role = Role.Button, onClick = onClick), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.m), - modifier = Modifier.padding(spacing.m), - ) { - Surface(shape = MaterialTheme.shapes.small, color = colors.primary) { - Icon( - imageVector = EnclyIcons.Heart, - contentDescription = null, - tint = colors.onPrimary, - modifier = Modifier - .padding(spacing.s) - .size(spacing.iconMedium), - ) - } - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(spacing.textGap)) { - Text(text = "Ko-fi", style = MaterialTheme.typography.titleMedium) - Text( - text = stringResource(R.string.support_method_kofi_hint), - style = MaterialTheme.typography.bodySmall, - ) - } - Icon( - imageVector = EnclyIcons.External, - contentDescription = null, - modifier = Modifier.size(spacing.iconSmall), - ) - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt deleted file mode 100644 index d1618987..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt +++ /dev/null @@ -1,419 +0,0 @@ -package com.pasich.encly.presentation.screen.backup - -import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalActivity -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.fragment.app.FragmentActivity -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.CalloutTone -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyCallout -import com.pasich.encly.presentation.designsystem.EnclyDialog -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.EnclyPillButton -import com.pasich.encly.presentation.designsystem.EnclySheetRow -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.LabelHeader -import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput -import com.pasich.encly.presentation.designsystem.RecoveryPhraseState -import com.pasich.encly.presentation.designsystem.StepHeading -import com.pasich.encly.presentation.designsystem.WordGrid -import com.pasich.encly.presentation.screen.onboarding.FooterSpec -import com.pasich.encly.presentation.screen.onboarding.OnboardingFooter -import com.pasich.encly.presentation.screen.onboarding.PhraseCheckFields -import com.pasich.encly.presentation.screen.onboarding.allChecksCorrect -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.pinLockoutText -import com.pasich.encly.presentation.viewmodel.BackupStep -import com.pasich.encly.presentation.viewmodel.ReauthFlow -import com.pasich.encly.ui.theme.EnclyTheme - -/** - * The dialog or full-screen step for the current [step] of a backup or vault flow, if it has - * one. Phrase steps and the import choice cover the whole screen like the onboarding steps, so - * callers draw this last, over their content (in a Box). [busy] shows progress on the primary. - */ -@Composable -fun BackupDialogs(actions: BackupDialogActions, step: BackupStep, busy: Boolean = false) { - when (step) { - is BackupStep.Reauth -> ReauthDialog(actions.reauth, step, actions.cancel) - - BackupStep.NeedsPhrase -> MessageDialog( - title = R.string.backup_needs_phrase_title, - body = R.string.backup_needs_phrase_body, - confirm = R.string.backup_create_phrase, - onConfirm = actions.phrase::create, - onDismiss = actions.cancel, - ) - - is BackupStep.ShowNewPhrase -> NewPhraseStep(step.words, step.replacing, actions) - - BackupStep.ConfirmReplacePhrase -> ReplacePhraseDialog(actions) - - is BackupStep.CheckNewPhrase -> PhraseCheckStep(step, actions, busy) - - is BackupStep.ConfirmExistingPhrase -> PhraseEntryStep( - texts = PhraseEntryTexts( - R.string.backup_needs_phrase_title, - R.string.backup_confirm_phrase_title, - R.string.backup_confirm_phrase_body, - ), - error = step.error, - busy = busy, - actions = actions, - onSubmit = actions.phrase::submitExisting, - ) - - is BackupStep.EnterImportPhrase -> PhraseEntryStep( - texts = PhraseEntryTexts( - R.string.backup_import_choose, - R.string.backup_enter_phrase_title, - R.string.backup_enter_phrase_body, - R.string.backup_decrypt, - ), - error = step.error, - busy = busy, - actions = actions, - onSubmit = actions.importing::submitPhrase, - ) - - is BackupStep.ChooseImportMode -> ImportModeStep(step, actions, busy) - - BackupStep.ConfirmReplace -> MessageDialog( - title = R.string.backup_replace_confirm_title, - body = R.string.backup_replace_confirm_body, - confirm = R.string.backup_replace, - onConfirm = actions.importing::confirmReplace, - destructive = true, - // Cancel goes back to the choice: the backup was decrypted with 12 typed words. - onDismiss = actions.importing::backToChoice, - ) - - else -> Unit - } -} - -/** Before new recovery words: the old ones stop working here, old backups keep needing them. */ -@Composable -private fun ReplacePhraseDialog(actions: BackupDialogActions) = MessageDialog( - title = R.string.security_recovery_replace_confirm_title, - body = R.string.security_recovery_replace_confirm_body, - confirm = R.string.security_recovery_replace_confirm, - onConfirm = actions.phrase::replace, - onDismiss = actions.cancel, -) - -@Composable -private fun MessageDialog( - title: Int, - body: Int, - confirm: Int, - onConfirm: () -> Unit, - onDismiss: () -> Unit, - destructive: Boolean = false, -) { - EnclyDialog( - title = stringResource(title), - text = stringResource(body), - onDismissRequest = onDismiss, - confirm = DialogAction(stringResource(confirm), onConfirm, destructive = destructive), - dismiss = DialogAction(stringResource(R.string.cancel), onDismiss), - ) -} - -/** - * Full-screen PIN entry (plus the fingerprint key, when enabled) before an export or import, run - * by [reauth]; [onCancel] closes it. The one re-authentication every export goes through. - */ -@Composable -internal fun ReauthDialog(reauth: ReauthFlow, step: BackupStep.Reauth, onCancel: () -> Unit) { - val activity = LocalActivity.current as? FragmentActivity - // Wiped once submitted or when the dialog closes, never a String (see PinBuffer). - val input = remember { PinBuffer() } - DisposableEffect(input) { onDispose { input.clear() } } - var lockoutSeconds by remember { mutableLongStateOf(0L) } - // Keyed on the step too: a wrong PIN (a new step) may just have started a lockout. - PinLockoutTicker(step to (lockoutSeconds > 0L), reauth::pinLockoutRemainingMillis) { - lockoutSeconds = it - } - val lockedOut = lockoutSeconds > 0L - val error = step.error?.let { stringResource(it) } - Dialog( - onDismissRequest = onCancel, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - PinEntryScaffold( - title = stringResource(R.string.backup_reauth_title), - subtitle = when { - lockedOut -> pinLockoutText(lockoutSeconds) - error != null -> error - else -> stringResource(R.string.backup_reauth_subtitle) - }, - subtitleIsError = lockedOut || error != null, - bodyAtBottom = true, - modifier = Modifier.fillMaxSize(), - ) { - PinEntry( - entered = input.length, - error = error != null && input.length == 0, - shakeKey = step.failures, - enabled = !lockedOut, - actions = PinEntryActions( - onDigit = { digit -> - if (!lockedOut) input.add(digit) - // The flow wipes the PIN it is given. - if (input.isFull) reauth.submitPin(input.take()) - }, - onBackspace = input::deleteLast, - onBiometric = if (step.biometric && activity != null) { - { reauth.withBiometric(activity) } - } else { - null - }, - ), - ) - EnclyTextButton( - text = stringResource(R.string.cancel), - onClick = onCancel, - modifier = Modifier.padding(top = EnclyTheme.spacing.l), - ) - } - } -} - -/** - * A backup step laid out like an onboarding step, over the whole screen: a back header with - * [label], the scrolling [content] in the gutter, and [footer] pinned above the keyboard. - * System back runs [onBack] too. - */ -@Composable -private fun FullScreenStep( - label: String, - onBack: () -> Unit, - footer: @Composable () -> Unit, - content: @Composable ColumnScope.() -> Unit, -) { - BackHandler(onBack = onBack) - Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing) - .imePadding(), - ) { - LabelHeader(label = label, onBack = onBack) - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.l), - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = EnclyTheme.spacing.gutter) - .padding(top = EnclyTheme.spacing.m, bottom = EnclyTheme.spacing.l), - content = content, - ) - footer() - } - } -} - -/** - * The new recovery phrase to write down, with a Hide/Show pill, as in onboarding. A - * [replacing] phrase also says what happens to the old words and the backups made with them. - */ -@Composable -private fun NewPhraseStep(words: List, replacing: Boolean, actions: BackupDialogActions) { - var hidden by remember { mutableStateOf(false) } - FullScreenStep( - label = stringResource( - if (replacing) R.string.security_recovery_replace_title else R.string.backup_needs_phrase_title, - ), - onBack = actions.cancel, - footer = { OnboardingFooter(FooterSpec(R.string.backup_phrase_written, actions.phrase::writtenDown)) }, - ) { - StepHeading( - title = stringResource(R.string.onboarding_phrase_title), - body = stringResource(R.string.backup_phrase_write_down), - ) - if (replacing) { - EnclyCallout( - title = stringResource(R.string.security_recovery_replace_warning_title), - text = stringResource(R.string.security_recovery_replace_warning_body), - tone = CalloutTone.WARNING, - ) - } - WordGrid(words = words, hidden = hidden) - EnclyPillButton( - text = stringResource(if (hidden) R.string.onboarding_phrase_show else R.string.onboarding_phrase_hide), - leadingIcon = if (hidden) EnclyIcons.Eye else EnclyIcons.EyeOff, - onClick = { hidden = !hidden }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } -} - -/** Three of the new words typed back, each checked as it is typed; Confirm once all are right. */ -@Composable -private fun PhraseCheckStep(step: BackupStep.CheckNewPhrase, actions: BackupDialogActions, busy: Boolean) { - val checks = remember(step.positions, step.expected) { step.positions.zip(step.expected) } - val answers = remember(step.positions) { mutableStateMapOf() } - FullScreenStep( - label = stringResource(R.string.backup_needs_phrase_title), - onBack = actions.phrase::showAgain, - footer = { - OnboardingFooter( - FooterSpec( - primary = R.string.onboarding_verify_confirm, - onPrimary = { actions.phrase.submitCheck(step.positions.map { answers[it].orEmpty() }) }, - enabled = allChecksCorrect(checks, answers), - loading = busy, - secondary = R.string.onboarding_verify_show_again, - onSecondary = actions.phrase::showAgain, - ), - ) - }, - ) { - StepHeading( - title = stringResource(R.string.onboarding_verify_title), - body = stringResource(R.string.onboarding_verify_body), - ) - PhraseCheckFields( - checks = checks, - answers = answers, - onAnswer = { index, answer -> - answers[index] = answer.trim().lowercase() - actions.clearError() - }, - enabled = !busy, - ) - } -} - -private data class PhraseEntryTexts( - val label: Int, - val title: Int, - val body: Int, - val confirm: Int = R.string.action_continue, -) - -/** The 12 words, one numbered cell each; the primary waits for a valid phrase. */ -@Composable -private fun PhraseEntryStep( - texts: PhraseEntryTexts, - error: Int?, - busy: Boolean, - actions: BackupDialogActions, - onSubmit: (CharArray) -> Unit, -) { - // Never saved state: the words live only while this step does. - val phrase = remember { RecoveryPhraseState() } - val submit = { if (phrase.canSubmit && !busy) onSubmit(phrase.toCharArray()) } - FullScreenStep( - label = stringResource(texts.label), - onBack = actions.cancel, - footer = { - OnboardingFooter(FooterSpec(texts.confirm, submit, enabled = phrase.canSubmit, loading = busy)) - }, - ) { - StepHeading(title = stringResource(texts.title), body = stringResource(texts.body)) - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - Text( - text = stringResource(R.string.recovery_phrase_entry_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - RecoveryPhraseInput( - state = phrase, - enabled = !busy, - error = error?.let { stringResource(it) }, - onEdit = actions.clearError, - onDone = submit, - ) - } - } -} - -/** - * What the decrypted backup holds, and the choice: merge, or replace everything (confirmed - * again). Only Cancel, back and the header drop the decrypted backup. - */ -@Composable -private fun ImportModeStep(step: BackupStep.ChooseImportMode, actions: BackupDialogActions, busy: Boolean) { - val summary = stringResource( - R.string.backup_import_summary, - pluralStringResource(R.plurals.backup_count_notes, step.notes, step.notes), - pluralStringResource(R.plurals.backup_count_tasks, step.tasks, step.tasks), - pluralStringResource(R.plurals.backup_count_tags, step.tags, step.tags), - ) - FullScreenStep( - label = stringResource(R.string.backup_import_choose), - onBack = actions.cancel, - footer = { - EnclyTextButton( - text = stringResource(R.string.cancel), - onClick = actions.cancel, - enabled = !busy, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = EnclyTheme.spacing.gutter) - .padding(top = EnclyTheme.spacing.s, bottom = EnclyTheme.spacing.stepGap), - ) - }, - ) { - StepHeading(title = stringResource(R.string.backup_import_mode_title), body = summary) - EnclyGroup { - EnclySheetRow( - title = stringResource(R.string.backup_merge), - supporting = stringResource(R.string.backup_merge_row_desc), - icon = EnclyIcons.Plus, - enabled = !busy, - onClick = actions.importing::merge, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - EnclyGroupDivider() - EnclySheetRow( - title = stringResource(R.string.backup_replace_all), - supporting = stringResource(R.string.backup_replace_row_desc), - icon = EnclyIcons.Restore, - destructive = true, - enabled = !busy, - onClick = actions.importing::askReplace, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupErrors.kt b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupErrors.kt deleted file mode 100644 index 5f73790c..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupErrors.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.pasich.encly.presentation.screen.backup - -import androidx.annotation.StringRes -import com.pasich.encly.R -import com.pasich.encly.core.backup.BackupError - -/** The message shown for a failed export/import. Never includes file content or words. */ -@StringRes -fun backupErrorMessage(error: BackupError): Int = when (error) { - BackupError.NOT_A_BACKUP -> R.string.backup_error_not_a_backup - - BackupError.UNSUPPORTED_VERSION -> R.string.backup_error_unsupported - - BackupError.TRUNCATED, BackupError.CORRUPTED, BackupError.INVALID_PAYLOAD -> - R.string.backup_error_corrupted - - BackupError.TOO_LARGE -> R.string.backup_error_too_large - - BackupError.TARGET_EXISTS -> R.string.backup_error_target_exists - - BackupError.WRONG_SECRET -> R.string.backup_error_wrong_phrase - - BackupError.INVALID_PHRASE -> R.string.backup_error_invalid_phrase - - BackupError.VAULT_UNAVAILABLE -> R.string.backup_error_locked - - BackupError.IO -> R.string.backup_error_io -} - -/** Export-specific wording: there the vault, not a picked file, is what is too large. */ -@StringRes -fun backupExportErrorMessage(error: BackupError): Int = when (error) { - BackupError.TOO_LARGE -> R.string.backup_error_export_too_large - else -> backupErrorMessage(error) -} 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 deleted file mode 100644 index 70f0d125..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt +++ /dev/null @@ -1,275 +0,0 @@ -package com.pasich.encly.presentation.screen.backup - -import android.content.ActivityNotFoundException -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.LinearProgressIndicator -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.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -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.backup.BackupFormat -import com.pasich.encly.presentation.designsystem.EnclyCallout -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyPillButton -import com.pasich.encly.presentation.designsystem.EnclySnackbarHost -import com.pasich.encly.presentation.designsystem.EnclyTonalButton -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.viewmodel.BackupAction -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.MarkdownExportTarget -import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel -import com.pasich.encly.ui.theme.EnclyTheme -import com.pasich.encly.utils.formatNoteDate -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Settings → Backup: encrypted export and import. Files go through the Storage Access - * Framework only, so Encly needs no storage permission and never learns more than the one - * document the user picked. - */ -@Composable -fun BackupScreen( - navController: NavHostController, - modifier: Modifier = Modifier, - viewModel: BackupViewModel = enclyViewModel(), - markdownViewModel: MarkdownExportViewModel = enclyViewModel(), -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - - BackupPickers( - step = state.step, - onExportTarget = viewModel.exportFlow::onTarget, - onImportFile = viewModel.importFlow::onFile, - onPickerLaunch = viewModel::onSystemPickerLaunched, - onPickerUnavailable = viewModel::onSystemPickerUnavailable, - ) - val dialogActions = rememberBackupDialogActions(viewModel) - - // Shown from a screen-wide scope: clearing the message restarts this effect, which must not - // cancel the snackbar it just opened. - val snackbarScope = rememberCoroutineScope() - LaunchedEffect(state.message) { - val message = state.message ?: return@LaunchedEffect - viewModel.clearMessage() - snackbarScope.launch { snackbarHostState.showSnackbar(message.resolve(context)) } - } - - Box(modifier = modifier.fillMaxSize()) { - Scaffold( - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - EnclyTopBar(title = stringResource(R.string.backup_title), onBack = { navController.popBackStack() }) - }, - snackbarHost = { EnclySnackbarHost(snackbarHostState) }, - ) { padding -> - Column(modifier = Modifier.padding(padding)) { - // A fixed slot: the page does not jump when the progress bar comes and goes. - Box(modifier = Modifier.fillMaxWidth().height(EnclyTheme.spacing.xxs)) { - if (state.busy) { - LinearProgressIndicator( - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.fillMaxSize(), - ) - } - } - BackupContent( - state = state, - onStart = viewModel::start, - onMarkdown = { markdownViewModel.start(MarkdownExportTarget.AllData) }, - ) - } - } - // Drawn last: the phrase steps cover the whole screen. - BackupDialogs(dialogActions, state.step, state.busy) - MarkdownExportHost(onMessage = { text -> snackbarScope.launch { snackbarHostState.showSnackbar(text) } }) - } -} - -@Composable -private fun BackupContent(state: BackupUiState, onStart: (BackupAction) -> Unit, onMarkdown: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), - ) { - Text( - text = stringResource(R.string.backup_intro_short), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(text = stringResource(R.string.backup_section_export)) - ExportAction( - lastExportAt = state.lastExportAt, - enabled = !state.busy, - onExport = { onStart(BackupAction.EXPORT) }, - ) - } - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(text = stringResource(R.string.backup_section_import)) - ImportAction(enabled = !state.busy, onImport = { onStart(BackupAction.IMPORT) }) - } - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(text = stringResource(R.string.backup_section_markdown)) - MarkdownAction(enabled = !state.busy, onExport = onMarkdown) - } - EnclyCallout( - title = stringResource(R.string.backup_intro_title), - text = stringResource(R.string.backup_intro_body), - icon = EnclyIcons.Shield, - ) - } -} - -@Composable -private fun ExportAction(lastExportAt: Long?, enabled: Boolean, onExport: () -> Unit) { - val lastExport = lastExportAt?.let { - stringResource(R.string.backup_last_export, formatNoteDate(Date(it))) - } ?: stringResource(R.string.backup_never_exported) - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - EnclyTonalButton(text = stringResource(R.string.backup_export), onClick = onExport, enabled = enabled) - Text(text = lastExport, style = EnclyTheme.typography.meta, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -/** The plaintext export: a `.zip` of Markdown files, which is not encrypted (a warning comes first). */ -@Composable -private fun MarkdownAction(enabled: Boolean, onExport: () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - EnclyPillButton( - text = stringResource(R.string.markdown_export), - onClick = onExport, - enabled = enabled, - leadingIcon = EnclyIcons.Upload, - ) - Text( - text = stringResource(R.string.markdown_export_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -@Composable -private fun ImportAction(enabled: Boolean, onImport: () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - EnclyPillButton( - text = stringResource(R.string.backup_import), - onClick = onImport, - enabled = enabled, - leadingIcon = EnclyIcons.Restore, - ) - Text( - text = stringResource(R.string.backup_import_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -/** Opens the system "create document" / "open document" picker when a flow asks for it. */ -@Composable -private fun BackupPickers( - step: BackupStep, - onExportTarget: (Uri?) -> Unit, - onImportFile: (Uri?) -> Unit, - onPickerLaunch: () -> Unit, - onPickerUnavailable: () -> Unit, -) { - val exportLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument(BackupFormat.MIME_TYPE), - onExportTarget, - ) - val importLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument(), - onImportFile, - ) - // The effect restarts only on [step]; always report to the latest callbacks. - val currentOnPickerLaunched by rememberUpdatedState(onPickerLaunch) - val currentOnPickerUnavailable by rememberUpdatedState(onPickerUnavailable) - LaunchedEffect(step) { - when (step) { - BackupStep.PickExportTarget -> - launchPicker(currentOnPickerLaunched, currentOnPickerUnavailable) { - exportLauncher.launch(defaultFileName()) - } - - BackupStep.PickImportFile -> - launchPicker(currentOnPickerLaunched, currentOnPickerUnavailable) { - importLauncher.launch(arrayOf("*/*")) - } - - else -> Unit - } - } -} - -/** - * Keeps the vault open for the picker only once it has really started: a device without a - * documents app throws [ActivityNotFoundException], which ends the flow with a message - * instead of crashing and leaves the next backgrounding locking as usual. - */ -private fun launchPicker(onLaunched: () -> Unit, onUnavailable: () -> Unit, launch: () -> Unit) { - try { - launch() - } catch (_: ActivityNotFoundException) { - onUnavailable() - return - } - onLaunched() -} - -/** "encly-backup-2026-09-23.enclybak": a date only, never a title or other content. */ -private fun defaultFileName(): String { - val date = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT).format(Date()) - return "encly-backup-$date.${BackupFormat.FILE_EXTENSION}" -} - -private fun BackupMessage.resolve(context: Context): String = when (this) { - is BackupMessage.Text -> context.getString(id) - - is BackupMessage.Imported -> context.getString( - R.string.backup_import_done, - summary.notesAdded, - summary.tasksAdded, - summary.tagsAdded, - summary.skipped, - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/MarkdownExportHost.kt b/app/src/main/java/com/pasich/encly/presentation/screen/backup/MarkdownExportHost.kt deleted file mode 100644 index 5659cfe5..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/MarkdownExportHost.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.pasich.encly.presentation.screen.backup - -import android.content.ActivityNotFoundException -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.pasich.encly.R -import com.pasich.encly.data.export.MarkdownExport -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog -import com.pasich.encly.presentation.viewmodel.BackupMessage -import com.pasich.encly.presentation.viewmodel.BackupStep -import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel - -/** - * The screen side of the Markdown export, drawn by the Backup screen and by the editor: the - * warning that the file is not encrypted, the PIN (or biometric) re-authentication every export - * uses, and the system save dialog the file is written through. [onMessage] shows the result. - * Nothing else leaves the app: no share sheet, no temp file. - */ -@Composable -internal fun MarkdownExportHost(onMessage: (String) -> Unit, viewModel: MarkdownExportViewModel = enclyViewModel()) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val resources = LocalResources.current - val noteLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument(MarkdownExport.MIME_NOTE), - viewModel::onTarget, - ) - val zipLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument(MarkdownExport.MIME_ZIP), - viewModel::onTarget, - ) - val currentOnMessage by rememberUpdatedState(onMessage) - - LaunchedEffect(state.step) { - val step = state.step as? BackupStep.PickMarkdownTarget ?: return@LaunchedEffect - try { - (if (step.zip) zipLauncher else noteLauncher).launch(step.name) - } catch (_: ActivityNotFoundException) { - viewModel.onSystemPickerUnavailable() - return@LaunchedEffect - } - viewModel.onSystemPickerLaunched() - } - LaunchedEffect(state.message) { - val message = state.message as? BackupMessage.Text ?: return@LaunchedEffect - viewModel.clearMessage() - currentOnMessage(resources.getString(message.id)) - } - - when (val step = state.step) { - BackupStep.ConfirmPlainExport -> PlainExportWarning( - onContinue = viewModel::confirmWarning, - onCancel = viewModel::cancel, - ) - - is BackupStep.Reauth -> ReauthDialog(viewModel.reauthFlow, step, viewModel::cancel) - - else -> Unit - } -} - -/** Before any plaintext export: the file is not encrypted, wherever it is saved. */ -@Composable -private fun PlainExportWarning(onContinue: () -> Unit, onCancel: () -> Unit) { - EnclyDialog( - title = stringResource(R.string.markdown_export_warning_title), - text = stringResource(R.string.markdown_export_warning_body), - onDismissRequest = onCancel, - confirm = DialogAction(stringResource(R.string.action_continue), onContinue), - dismiss = DialogAction(stringResource(R.string.cancel), onCancel), - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/CopyMarkdown.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/CopyMarkdown.kt deleted file mode 100644 index 4060d5c1..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/CopyMarkdown.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.pasich.encly.presentation.screen.editnote - -import android.content.ClipboardManager -import android.content.Context -import android.os.Build -import com.pasich.encly.R -import com.pasich.encly.data.export.MarkdownNotes -import com.pasich.encly.presentation.components.SensitiveClip -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel - -/** - * Copies the note as the editor shows it now (unsaved edits included) as Markdown, through - * [SensitiveClip]: marked sensitive and cleared after a minute. The note is already open, so no - * re-authentication is asked; this is the text a selection would copy. - * - * Returns the confirmation to show, or null when there is nothing to say: nothing was written - * (a blank note, an unreadable one) or the system already confirms the copy (Android 13+). - */ -fun copyNoteAsMarkdown(context: Context, viewModel: EditNoteViewModel): String? { - val clipboard = context.getSystemService(ClipboardManager::class.java) - if (clipboard == null || viewModel.isBlankDraft || viewModel.contentLoadFailed.value) return null - val markdown = MarkdownNotes.renderBody(viewModel.state.value.note.title, viewModel.blocks) - SensitiveClip.copy(clipboard, context.getString(R.string.markdown_copy), markdown) - return if (Build.VERSION.SDK_INT < - Build.VERSION_CODES.TIRAMISU - ) { - context.getString(R.string.markdown_copied) - } else { - null - } -} 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 deleted file mode 100644 index 82755b85..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt +++ /dev/null @@ -1,464 +0,0 @@ -package com.pasich.encly.presentation.screen.editnote - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.animateColorAsState -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 -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -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.graphics.Color -import androidx.compose.ui.input.key.onPreviewKeyEvent -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.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.NoteColor -import com.pasich.encly.presentation.components.editNote.NoteBottomBar -import com.pasich.encly.presentation.components.editNote.NoteOverline -import com.pasich.encly.presentation.components.editNote.TitleField -import com.pasich.encly.presentation.designsystem.CalloutTone -import com.pasich.encly.presentation.designsystem.EnclyCallout -import com.pasich.encly.presentation.designsystem.EnclySnackbarHost -import com.pasich.encly.presentation.designsystem.NoteSkeleton -import com.pasich.encly.presentation.designsystem.blockGap -import com.pasich.encly.presentation.designsystem.isCompact -import com.pasich.encly.presentation.dialogs.ConfirmDialog -import com.pasich.encly.presentation.dialogs.EditNoteBottomSheet -import com.pasich.encly.presentation.dialogs.EditNoteBottomSheetAction -import com.pasich.encly.presentation.editor.EditorBlocksHost -import com.pasich.encly.presentation.editor.FindHighlights -import com.pasich.encly.presentation.editor.editorBlocks -import com.pasich.encly.presentation.editor.focus.rememberBlockFocusRegistry -import com.pasich.encly.presentation.editor.persistence.SaveStatusNote -import com.pasich.encly.presentation.effects.ContainerEnd -import com.pasich.encly.presentation.effects.rememberNoteContainerModifier -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.navigation.RelockReturn -import com.pasich.encly.presentation.screen.backup.MarkdownExportHost -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import com.pasich.encly.presentation.viewmodel.MarkdownExportTarget -import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel -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.ui.theme.noteColorStyle -import kotlinx.coroutines.launch - -/** Items above the blocks: the overline and the title. */ -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 = enclyViewModel(), -) { - val scope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val resources = LocalResources.current - val lifecycleOwner = LocalLifecycleOwner.current - val snackbarHostState = remember { SnackbarHostState() } - val entry = lifecycleOwner as? NavBackStackEntry - // The route's argument, not the ViewModel's id: a new note gets its id on the first save, and - // only an existing, editable note has a card to grow from. - val plain = viewModel.isReadTrashOnly || entry?.savedStateHandle?.contains(RelockReturn.PLAIN_ENTRY) == true - val cardNoteId = if (plain) -1L else entry?.arguments?.getLong("idNote", -1L) ?: -1L - val container = rememberNoteContainerModifier(cardNoteId, ContainerEnd.EDITOR) - val exits = remember(viewModel, navController, resources) { - EditorExits( - viewModel = viewModel, - navController = navController, - entry = entry, - scope = scope, - onFailure = { message -> scope.launch { snackbarHostState.showSnackbar(resources.getString(message)) } }, - beforeLeaving = { - keyboardController?.hide() - focusManager.clearFocus() - }, - ) - } - val fontSize by viewModel.fontSize.collectAsStateWithLifecycle() - val fontStyle by viewModel.fontStyle.collectAsStateWithLifecycle() - val lineSpacing by viewModel.noteLineSpacing.collectAsStateWithLifecycle() - val simpleEdit by viewModel.simpleEdit.collectAsStateWithLifecycle() - val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() - val overlays = rememberEditorOverlays() - val listState = rememberLazyListState() - val focusRegistry = rememberBlockFocusRegistry(viewModel.blocks) - val 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() - 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 = overlays, - exits = exits, - onManageTags = { navController.navigate(NavRoutes.EditTagRoute.name) }, - onMessage = { text -> scope.launch { snackbarHostState.showSnackbar(text) } }, - ) - - EditNoteSettingsProvider( - baseFontSize = fontSize.sp, - fontStyle = fontStyle, - simpleEdit = simpleEdit, - lineSpacing = lineSpacing, - ) { - EditorScaffold( - actions = EditorBarActions( - onBack = exits::back, - onRestore = exits::restore, - onDelete = { overlays.isDeleteDialogVisible = true }, - onTags = { overlays.isTagSheetVisible = true }, - onMenu = { overlays.isEditMenuVisible = true }, - onLockToggle = viewModel::toggleLockEditor, - ), - listState = listState, - snackbarHostState = snackbarHostState, - modifier = modifier.then(container), - ) { padding -> - EditorContent( - host = host, - listState = listState, - titleFocus = titleFocus, - modifier = Modifier.padding(padding), - ) - } - } -} - -/** - * The editor's frame: the pinned bar, and the formatting toolbar, always there while the note - * can be edited, keyboard or not (a hardware keyboard never shows one); it lifts itself above - * the keyboard. - */ -@Composable -private fun EditorScaffold( - actions: EditorBarActions, - listState: LazyListState, - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), - content: @Composable (PaddingValues) -> Unit, -) { - val lockEditor by viewModel.lockEditor.collectAsState() - val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() - val findOpen by rememberFindOpen() - val pageColor = rememberPageColor() - Scaffold( - modifier = modifier.onPreviewKeyEvent { event -> - viewModel.handleHistoryKey(event) || viewModel.handleFindKey(event) - }, - containerColor = pageColor, - // Status and navigation bars, a display cutout and the keyboard all stay clear of text. - contentWindowInsets = WindowInsets.safeDrawing, - topBar = { - Column { - EditorTopBar( - state = EditorBarState( - isReadTrashOnly = viewModel.isReadTrashOnly, - lockEditor = lockEditor, - canToggleLock = !contentLoadFailed, - containerColor = pageColor, - ), - listState = listState, - actions = actions, - ) - if (findOpen) EditorFindBar() - } - }, - bottomBar = { - if (!lockEditor && !viewModel.isReadTrashOnly) NoteBottomBar(simpleEdit = LocalSimpleEdit.current) - }, - snackbarHost = { EnclySnackbarHost(snackbarHostState) }, - content = content, - ) -} - -/** - * The editor's page colour: the theme's surface, tinted by the note's colour. Read through derived - * state, so a title keystroke does not recompose the editor; a colour change fades in. - */ -@Composable -private fun rememberPageColor(viewModel: EditNoteViewModel = enclyViewModel()): Color { - val noteState = viewModel.state.collectAsState() - val colorKey by remember(noteState) { derivedStateOf { noteState.value.note.color } } - val scheme = MaterialTheme.colorScheme - val target = remember(colorKey, scheme) { - noteColorStyle(NoteColor.fromKey(colorKey), scheme)?.page ?: scheme.surface - } - return animateColorAsState(target, label = "pageColor").value -} - -/** Which of the editor's sheets and dialogs are open. */ -internal class EditorOverlaysState( - val blockSheet: BlockSheetState, - editMenu: MutableState, - tagSheet: MutableState, -) { - var isEditMenuVisible by editMenu - var isTagSheetVisible by tagSheet - var isDeleteDialogVisible by mutableStateOf(false) - var isDiscardDialogVisible by mutableStateOf(false) -} - -@Composable -private fun rememberEditorOverlays(): EditorOverlaysState { - val blockSheet = rememberBlockSheetState() - val editMenu = rememberSaveable { mutableStateOf(false) } - val tagSheet = rememberSaveable { mutableStateOf(false) } - return remember { EditorOverlaysState(blockSheet, editMenu, tagSheet) } -} - -/** The editor's sheets and dialogs: block sheets, tags, More actions, delete and discard. */ -@Composable -private fun EditorOverlays( - overlays: EditorOverlaysState, - exits: EditorExits, - onManageTags: () -> Unit, - onMessage: (String) -> Unit, - viewModel: EditNoteViewModel = enclyViewModel(), - markdownViewModel: MarkdownExportViewModel = enclyViewModel(), -) { - val context = LocalContext.current - val lockEditor by viewModel.lockEditor.collectAsState() - EditorConfirmDialogs( - isDeleteVisible = overlays.isDeleteDialogVisible, - isDiscardVisible = overlays.isDiscardDialogVisible, - onDelete = exits::delete, - onDiscard = exits::discard, - onDismiss = { - overlays.isDeleteDialogVisible = false - overlays.isDiscardDialogVisible = false - }, - ) - EditorBlockSheets(overlays.blockSheet, canEdit = !lockEditor && !viewModel.isReadTrashOnly) - if (overlays.isTagSheetVisible) { - EditorTagSheet( - onManageTags = { - overlays.isTagSheetVisible = false - onManageTags() - }, - onDismiss = { overlays.isTagSheetVisible = false }, - ) - } - MarkdownExportHost(onMessage) - EditNoteBottomSheet( - isVisible = overlays.isEditMenuVisible, - onDismiss = { overlays.isEditMenuVisible = false }, - onAction = { action -> - when (action) { - EditNoteBottomSheetAction.CLOSE_NO_SAVE -> exits.discardOrConfirm { - overlays.isDiscardDialogVisible = - true - } - - EditNoteBottomSheetAction.DUPLICATE -> exits.duplicate() - - EditNoteBottomSheetAction.FIND -> viewModel.openFind() - - EditNoteBottomSheetAction.TRASH -> exits.trash() - - EditNoteBottomSheetAction.EXPORT_MARKDOWN -> exits.exportMarkdown { id -> - markdownViewModel.start(MarkdownExportTarget.SingleNote(id)) - } - - EditNoteBottomSheetAction.COPY_MARKDOWN -> copyNoteAsMarkdown(context, viewModel)?.let(onMessage) - - EditNoteBottomSheetAction.ARCHIVE -> exits.archive(archived = true) - - EditNoteBottomSheetAction.UNARCHIVE -> exits.archive(archived = false) - } - }, - ) -} - -/** The editor's list: overline, title, a notice when the content is unreadable, then the blocks. */ -@Composable -private fun EditorContent( - host: EditorBlocksHost, - listState: LazyListState, - titleFocus: FocusRequester, - modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), -) { - val spacing = LocalNoteLineSpacing.current - val headerGap = if (spacing.isCompact) EnclyTheme.spacing.labelGap else 0.dp - val lockEditor by viewModel.lockEditor.collectAsState() - val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() - val editingLinkIds by viewModel.editingLinkIds.collectAsState() - val templatesOffered by viewModel.templatesOffered.collectAsState() - // Only whether it is loading: a save changing the status must not recompose the list. - val status = viewModel.status.collectAsState() - val loading by remember(status) { derivedStateOf { status.value == SaveStatusNote.LOADING } } - - CompositionLocalProvider(LocalOverscrollFactory provides null) { - LazyColumn( - state = listState, - // Default block frames and this gap give 14 dp between blocks (spec §4.4). - verticalArrangement = Arrangement.spacedBy(spacing.blockGap(EnclyTheme.spacing.labelGap)), - contentPadding = PaddingValues(top = EnclyTheme.spacing.xxs), - modifier = modifier - .fillMaxSize() - // The empty page under a short note is part of the note: a tap there writes at its end, keyboard up. - .tapBelowBlocks(listState, enabled = !lockEditor, onTap = rememberWriteAtEnd(viewModel)), - ) { - if (loading) { - item(key = "skeleton") { - NoteSkeleton(modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter)) - } - return@LazyColumn - } - 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, - vertical = headerGap, - ), - ) - } - } - editorBlocks( - host, - viewModel.blocks, - isLocked = lockEditor, - editingLinkIds = editingLinkIds, - afterBlocks = if (templatesOffered) ({ TemplateChooser() }) else null, - ) - } - } -} - -/** The title field, bound to the note's title; "Next" moves on to the first block. */ -@Composable -private fun EditorTitle( - readOnly: Boolean, - modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), -) { - TitleField( - title = { viewModel.state.value.note.title }, - titleChanges = viewModel.state, - onTitleChange = viewModel::updateTitle, - onNext = viewModel::focusFirstBlock, - readOnly = readOnly, - modifier = modifier, - ) -} - -/** The overline: the note's tag and when it was edited, and "Not saved" after a failed save. */ -@Composable -private fun EditorOverline( - modifier: Modifier = Modifier, - 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() - val status = viewModel.status.collectAsState() - val meta by remember(noteState) { derivedStateOf { noteState.value.note.let { it.tagId to it.date } } } - val notSaved by remember(status) { derivedStateOf { status.value == SaveStatusNote.FAILED } } - val tagState by tagViewModel.state.collectAsState() - val (tagId, date) = meta - NoteOverline( - tagName = tagState.listTags.firstOrNull { it.id == tagId }?.nameTag, - date = date, - notSaved = notSaved, - modifier = modifier.padding( - start = EnclyTheme.spacing.gutter, - end = EnclyTheme.spacing.gutter, - bottom = EnclyTheme.spacing.xs, - ), - ) -} - -/** Delete (from the trash) and discard both ask first. */ -@Composable -private fun EditorConfirmDialogs( - isDeleteVisible: Boolean, - isDiscardVisible: Boolean, - onDelete: () -> Unit, - onDiscard: () -> Unit, - onDismiss: () -> Unit, -) { - ConfirmDialog( - isVisible = isDeleteVisible, - titleText = stringResource(R.string.dialog_title), - messageText = stringResource(R.string.dialog_message), - onConfirm = { - onDismiss() - onDelete() - }, - onDismiss = onDismiss, - destructive = true, - ) - ConfirmDialog( - isVisible = isDiscardVisible, - titleText = stringResource(R.string.note_discard_confirm_title), - messageText = stringResource(R.string.note_discard_confirm_message), - onConfirm = { - onDismiss() - onDiscard() - }, - onDismiss = onDismiss, - destructive = true, - ) -} 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 deleted file mode 100644 index b0f6e4a6..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt +++ /dev/null @@ -1,194 +0,0 @@ -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 -import androidx.compose.runtime.rememberUpdatedState -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 -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.pasich.encly.domain.enums.BottomSheetsOpenType -import com.pasich.encly.presentation.editor.focus.BlockFocusRegistry -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map - -private const val INITIAL_FOCUS_FRAMES = 5 - -/** - * Flushes the editor as soon as it leaves the foreground: ProcessLifecycleOwner re-locks and - * closes SQLCipher later in the background transition, so this closes the autosave window first. - */ -@Composable -internal fun SaveWhenPaused(viewModel: EditNoteViewModel = enclyViewModel()) { - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, viewModel) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE) viewModel.saveForBackground() - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } -} - -/** - * The ViewModel says which block to focus; [registry], which holds the fields' FocusRequesters, - * does it once that block is composed, scrolling a block off screen into view first - * ([blocksStart]: the list items above the blocks). A newer request replaces an older one. - */ -@Composable -internal fun CarryOutFocusRequests( - registry: BlockFocusRegistry, - listState: LazyListState, - blocksStart: Int, - viewModel: EditNoteViewModel = enclyViewModel(), -) { - val currentBlocksStart by rememberUpdatedState(blocksStart) - DisposableEffect(viewModel, registry) { - viewModel.immediateFocus = { request -> registry.focus(request) } - onDispose { viewModel.immediateFocus = null } - } - LaunchedEffect(viewModel, registry) { - viewModel.focusRequests.collectLatest { request -> - registry.focusWhenComposed(request) { blockId -> - val index = viewModel.blocks.indexOfFirst { it.id == blockId } - if (index >= 0) listState.reveal(currentBlocksStart + index) - } - } - } -} - -/** - * Scrolls item [index] into view. An item just below the screen (the paragraph Enter adds under - * the last line, with the keyboard up) is scrolled up from the bottom by a little, as typing - * would, instead of jumping it to the top; its field then keeps its cursor in view itself. - */ -private suspend fun LazyListState.reveal(index: Int) { - val info = layoutInfo - val last = info.visibleItemsInfo.lastOrNull() - if (last != null && index > last.index && index - last.index <= NEAR_ITEMS) { - val hidden = (last.offset + last.size - info.viewportEndOffset).coerceAtLeast(0) - animateScrollBy((hidden + info.viewportSize.height / REVEAL_FRACTION).toFloat()) - } else if (info.visibleItemsInfo.none { it.index == index }) { - animateScrollToItem(index) - } -} - -/** How far past the last item on screen an item counts as just below it. */ -private const val NEAR_ITEMS = 2 - -/** An item just below the screen is revealed by this fraction of the screen's height. */ -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 = enclyViewModel()) { - val keyboardController = LocalSoftwareKeyboardController.current - var done by rememberSaveable { mutableStateOf(!viewModel.isNewNote) } - LaunchedEffect(done) { - if (done) return@LaunchedEffect - repeat(INITIAL_FOCUS_FRAMES) { - withFrameNanos { } - if (runCatching { titleFocus.requestFocus() }.isSuccess) { - keyboardController?.show() - done = true - return@LaunchedEffect - } - } - done = true - } -} - -/** Which block sheet is open, and for which block (by id). */ -internal class BlockSheetState(type: MutableState, blockId: MutableState) { - var type by type - var blockId by blockId - - fun open(sheet: BottomSheetsOpenType, id: String) { - type = sheet - blockId = id - } - - fun close() { - type = BottomSheetsOpenType.NONE - blockId = null - } -} - -@Composable -internal fun rememberBlockSheetState(): BlockSheetState { - val type = rememberSaveable { mutableStateOf(BottomSheetsOpenType.NONE) } - 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/EditorExits.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorExits.kt deleted file mode 100644 index 51477f86..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorExits.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.pasich.encly.presentation.screen.editnote - -import androidx.annotation.StringRes -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavHostController -import com.pasich.encly.R -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** - * Every way out of the editor: back (saves), restore, delete, discard, trash, archive and duplicate. Each - * writes first and leaves only once the write succeeded; a failure keeps the editor open and is - * reported through [onFailure]. The first exit wins: a second Back (gesture and arrow) or any - * other exit tapped meanwhile is ignored, so the editor never pops past the notes list. - */ -@Suppress("TooManyFunctions") // One function per way out. -internal class EditorExits( - private val viewModel: EditNoteViewModel, - private val navController: NavHostController, - private val entry: NavBackStackEntry?, - private val scope: CoroutineScope, - private val onFailure: (Int) -> Unit, - private val beforeLeaving: () -> Unit, -) { - private var leaving = false - - fun back() = leave(R.string.note_save_failed, viewModel::saveNote) - - fun restore() = leave(R.string.note_restore_failed, viewModel::noteRestore) - - fun delete() = leave(R.string.note_delete_failed, viewModel::noteDelete) - - fun trash() = leave(R.string.note_trash_failed, viewModel::noteMoveToTrash) - - /** Moves the note to the archive (or, when it is archived, back to the list) and leaves. */ - fun archive(archived: Boolean) { - if (viewModel.isBlankDraft) return - leave( - failure = if (archived) R.string.note_archive_failed else R.string.note_unarchive_failed, - action = { viewModel.saveNote() && viewModel.noteSetArchived(archived) }, - ) - } - - /** - * Saves the note, then hands its id to [start], which begins the plaintext export - * (warning, PIN, save dialog). The editor stays open. Nothing to export from a blank new note - * or a note whose stored content could not be read. - */ - fun exportMarkdown(start: (id: Long) -> Unit) { - if (viewModel.isBlankDraft || viewModel.contentLoadFailed.value) return - scope.launch { - if (viewModel.saveNote() && viewModel.state.value.note.id > 0L) { - start(viewModel.state.value.note.id) - } else { - onFailure(R.string.note_save_failed) - } - } - } - - fun discard() = leave(R.string.note_discard_failed, viewModel::discardChanges) - - /** Discards at once when nothing would be lost; otherwise asks first ([confirm]). */ - fun discardOrConfirm(confirm: () -> Unit) { - scope.launch { if (viewModel.hasChanges()) confirm() else discard() } - } - - /** Stores a copy and opens it in place of this editor. A blank new note has nothing to copy. */ - fun duplicate() { - if (viewModel.isBlankDraft) return - var copyId = -1L - leave( - failure = R.string.note_duplicate_failed, - action = { - copyId = if (viewModel.saveNote()) viewModel.noteDuplicate() else -1L - copyId > 0L - }, - onDone = { openCopy(copyId) }, - ) - } - - private fun leave(@StringRes failure: Int, action: suspend () -> Boolean, onDone: () -> Unit = ::pop) { - if (leaving) return - leaving = true - scope.launch { - if (action()) { - onDone() - } else { - leaving = false - onFailure(failure) - } - } - } - - // Pops only this editor, and only while it is on top. - private fun pop() { - viewModel.markExitHandled() - beforeLeaving() - if (entry == null || navController.currentBackStackEntry == entry) navController.popBackStack() - } - - private fun openCopy(copyId: Long) { - viewModel.markExitHandled() - beforeLeaving() - navController.navigate("${NavRoutes.EditNoteRoute.name}/$copyId") { - entry?.let { popUpTo(it.destination.id) { inclusive = true } } - } - } -} 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 deleted file mode 100644 index 1fb14a02..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.pasich.encly.presentation.screen.editnote - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -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.foundation.lazy.LazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.IconToggleButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.domain.enums.BottomSheetsOpenType -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.presentation.components.editNote.NoteTagSheet -import com.pasich.encly.presentation.designsystem.EnclyBackButton -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.EnclyTooltip -import com.pasich.encly.presentation.designsystem.IconWithTooltip -import com.pasich.encly.presentation.dialogs.blocks.ActionBlockDialog -import com.pasich.encly.presentation.dialogs.blocks.ActionLinkBottomSheet -import com.pasich.encly.presentation.dialogs.blocks.ActionOtherBottomSheet -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 - -/** - * The sheet of one block (a link's, or a separator's), found by the block's id on every - * composition, so it can never act on a position that undo, redo or a delete already changed. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun EditorBlockSheets( - sheet: BlockSheetState, - canEdit: Boolean, - viewModel: EditNoteViewModel = enclyViewModel(), -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val block = sheet.blockId?.let { id -> viewModel.blocks.firstOrNull { it.id == id } } - if (block == null || sheet.type == BottomSheetsOpenType.NONE) return - - fun dismiss() { - scope.launch { sheetState.hide() }.invokeOnCompletion { sheet.close() } - } - - 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) - ActionBlockDialog.CopyLink -> copyNoteLink(context, link) - ActionBlockDialog.EditLink -> (block as? Block.LinkBlock)?.let(viewModel::editLink) - } - if (action !is ActionBlockDialog.Move) dismiss() - } - - val settings = SettingsBlockDialog( - block = block, - isBottomSheetVisible = true, - blockMove = viewModel.getMoveBlockState(), - canEdit = canEdit, - ) - if (sheet.type == BottomSheetsOpenType.ACTION_LINK) { - ActionLinkBottomSheet( - settings = settings, - sheetState = sheetState, - onDismiss = ::dismiss, - onAction = ::onAction, - ) - } else { - ActionOtherBottomSheet( - settings = settings, - sheetState = sheetState, - onDismiss = ::dismiss, - onAction = ::onAction, - ) - } -} - -/** Chooses the note's tag; "Manage tags" leaves for the tags screen ([onManageTags]). */ -@Composable -internal fun EditorTagSheet( - onManageTags: () -> Unit, - onDismiss: () -> Unit, - viewModel: EditNoteViewModel = enclyViewModel(), - tagViewModel: TagListViewModel = enclyViewModel(), -) { - val tagState by tagViewModel.state.collectAsState() - val noteState by viewModel.state.collectAsState() - NoteTagSheet( - tags = tagState.listTags, - selectedTagId = noteState.note.tagId, - onSelect = { tagId -> - viewModel.updateTagNote(tagId) - onDismiss() - }, - onManageTags = onManageTags, - onDismiss = onDismiss, - ) -} - -/** What the editor bar shows: trash actions, or tags, lock (unless the note is unreadable) and more. */ -internal class EditorBarState( - val isReadTrashOnly: Boolean, - val lockEditor: Boolean, - val canToggleLock: Boolean, - val containerColor: Color, -) - -/** - * The editor bar: back (saves and closes), then Edit tags, Lock editing and More actions. In - * the trash: back, Restore and Delete. - */ -internal class EditorBarActions( - val onBack: () -> Unit, - val onRestore: () -> Unit, - val onDelete: () -> Unit, - val onTags: () -> Unit, - val onMenu: () -> Unit, - val onLockToggle: () -> Unit, -) - -/** Pinned above the note; a hairline appears once the note scrolls under it. */ -@Composable -internal fun EditorTopBar( - state: EditorBarState, - listState: LazyListState, - actions: EditorBarActions, - modifier: Modifier = Modifier, -) { - val scrolled by remember(listState) { derivedStateOf { listState.canScrollBackward } } - val colors = MaterialTheme.colorScheme - Surface(color = state.containerColor, modifier = modifier) { - Column( - modifier = Modifier.windowInsetsPadding( - WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(EnclyTheme.spacing.topBarHeight) - .padding(horizontal = EnclyTheme.spacing.xs), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), - ) { - EnclyBackButton(onClick = actions.onBack) - Spacer(modifier = Modifier.weight(1f)) - if (state.isReadTrashOnly) { - EnclyTextButton(text = stringResource(R.string.restore), onClick = actions.onRestore) - EnclyTextButton( - text = stringResource(R.string.delete_from_trash), - onClick = actions.onDelete, - destructive = true, - ) - } else { - EditorBarButtons(state, actions) - } - } - HorizontalDivider( - thickness = EnclyTheme.spacing.hairline, - color = if (scrolled) colors.outlineVariant else state.containerColor, - ) - } - } -} - -@Composable -private fun EditorBarButtons(state: EditorBarState, actions: EditorBarActions, modifier: Modifier = Modifier) { - val colors = MaterialTheme.colorScheme - Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs)) { - if (state.canToggleLock) { - val tagsLabel = stringResource(R.string.edit_tags) - EnclyTooltip(tagsLabel) { - IconButton( - onClick = actions.onTags, - enabled = !state.lockEditor, - colors = IconButtonDefaults.iconButtonColors(contentColor = colors.onSurface), - ) { - Icon(EnclyIcons.Tag, contentDescription = tagsLabel) - } - } - // A toggle, so TalkBack announces whether editing is locked. - val lockLabel = stringResource(R.string.editor_lock) - EnclyTooltip(lockLabel) { - IconToggleButton( - checked = state.lockEditor, - onCheckedChange = { actions.onLockToggle() }, - colors = IconButtonDefaults.iconToggleButtonColors( - contentColor = colors.onSurface, - checkedContentColor = colors.primary, - ), - ) { - Icon( - if (state.lockEditor) EnclyIcons.Lock else EnclyIcons.LockOpen, - contentDescription = lockLabel, - ) - } - } - } - IconWithTooltip(EnclyIcons.More, stringResource(R.string.more_options), actions.onMenu, tint = colors.onSurface) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/LinkActions.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/LinkActions.kt deleted file mode 100644 index 88256917..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/LinkActions.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.pasich.encly.presentation.screen.editnote - -import android.content.ActivityNotFoundException -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.os.Build -import android.widget.Toast -import androidx.core.net.toUri -import com.pasich.encly.R -import com.pasich.encly.core.AppLogger -import com.pasich.encly.presentation.components.SensitiveClip -import com.pasich.encly.presentation.editor.blocks.isOpenableLink - -/** - * Opens a note's link in an app the user picks. The link leaves Encly: nothing is fetched by - * Encly itself, the chosen app does it. Only web and mail links are opened (a note restored from - * a crafted backup may hold any address), and a failure to open one never crashes the app. - */ -@Suppress("TooGenericExceptionCaught") // Another app's failure to take the link must never crash Encly. -fun openNoteLink(context: Context, url: String) { - if (!isOpenableLink(url)) { - Toast.makeText(context, R.string.link_blocked, Toast.LENGTH_LONG).show() - return - } - val view = Intent(Intent.ACTION_VIEW, url.toUri()) - try { - context.startActivity(Intent.createChooser(view, null)) - } catch (_: ActivityNotFoundException) { - Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_LONG).show() - } catch (e: RuntimeException) { - // FileUriExposedException, SecurityException: the address cannot leave the app safely. - AppLogger.w(TAG, "Link not opened: ${e.javaClass.simpleName}") - Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_LONG).show() - } -} - -private const val TAG = "LinkActions" - -/** - * Copies a note's link under the app's clipboard policy ([SensitiveClip]: marked sensitive, - * cleared after a minute). Older versions get a toast, since the system shows no confirmation. - */ -fun copyNoteLink(context: Context, url: String) { - val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return - SensitiveClip.copy(clipboard, context.getString(R.string.block_link), url) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - Toast.makeText(context, R.string.link_copied, Toast.LENGTH_SHORT).show() - } -} 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 deleted file mode 100644 index 843f6b15..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.pasich.encly.presentation.screen.editnote - -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.EnclyChip -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.editor.state.NoteTemplate -import com.pasich.encly.presentation.editor.state.blocks -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel -import com.pasich.encly.ui.theme.EnclyTheme - -/** - * "Start from template" under a brand-new, still empty note: Blank (what a new note is anyway, - * shown selected) and the ready outlines. Picking one fills the note with ordinary blocks, one undo - * step; not picking changes nothing, and the row is gone with the first character typed. - */ -@Composable -internal fun TemplateChooser(modifier: Modifier = Modifier, viewModel: EditNoteViewModel = enclyViewModel()) { - val resources = LocalResources.current - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - modifier = modifier.padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - ) { - SectionOverline(stringResource(R.string.template_start)) - Row( - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - modifier = Modifier.horizontalScroll(rememberScrollState()), - ) { - NoteTemplate.entries.forEach { template -> - EnclyChip( - label = stringResource(template.label), - selected = template == NoteTemplate.BLANK, - onClick = { viewModel.applyTemplate(template.blocks { id -> resources.getString(id) }) }, - ) - } - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt deleted file mode 100644 index 634e7fc2..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.pasich.encly.presentation.screen.handoff - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -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.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -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.handoff.HandoffError -import com.pasich.encly.data.handoff.HandoffPreview -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.EnclyTextButton -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.StepHeading -import com.pasich.encly.presentation.screen.LockExits -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 - -/** - * Import from My Notes: the lock screen while the vault is closed, then the hand-off's steps. - * [onClose] ends the activity with the result its current step stands for. - */ -@Composable -fun ImportFromMyNotesScreen( - onClose: () -> Unit, - modifier: Modifier = Modifier, - viewModel: ImportFromMyNotesViewModel = enclyViewModel(), -) { - val locked by viewModel.locked.collectAsState() - val step by viewModel.step.collectAsState() - // A failure shows even while the vault is closed: "nothing can open the vault" comes from - // the lock screen itself, and nothing of the vault is on that page. - if (locked && step !is HandoffStep.Failed) { - // Unlocking publishes itself to the session (LockViewModel); the steps then follow. - val exits = remember(viewModel, onClose) { - LockExits( - onUnlock = {}, - onRecoveryUnlock = {}, - onVaultLost = viewModel::onVaultLost, - onBack = onClose, - ) - } - LockScreen(exits = exits, modifier = modifier) - } else { - HandoffContent(step = step, onConfirm = viewModel::confirm, onClose = onClose, modifier = modifier) - } -} - -@Composable -private fun HandoffContent( - step: HandoffStep, - onConfirm: () -> Unit, - onClose: () -> Unit, - modifier: Modifier = Modifier, -) { - // The import is one transaction; leaving in the middle would only roll it back. - BackHandler(enabled = step == HandoffStep.Importing) {} - when (step) { - HandoffStep.Waiting -> AuthLoading(stringResource(R.string.handoff_title), modifier) - - HandoffStep.Reading -> AuthLoading(stringResource(R.string.handoff_reading), modifier) - - HandoffStep.Importing -> AuthLoading(stringResource(R.string.handoff_importing), modifier) - - is HandoffStep.Preview -> HandoffPage(onClose = onClose, modifier = modifier) { - PreviewBody(step.preview) - EnclyButton(text = stringResource(R.string.handoff_import), onClick = onConfirm) - EnclyTextButton( - text = stringResource(R.string.cancel), - onClick = onClose, - modifier = Modifier.fillMaxWidth(), - ) - } - - is HandoffStep.Done -> HandoffPage(onClose = onClose, modifier = modifier) { - val summary = step.summary - StepHeading( - title = stringResource(R.string.handoff_done_title), - body = stringResource( - R.string.backup_import_done, - summary.notesAdded, - summary.tasksAdded, - summary.tagsAdded, - step.skipped, - ), - ) - EnclyCallout(text = stringResource(R.string.handoff_done_hint)) - EnclyButton(text = stringResource(R.string.done), onClick = onClose) - } - - is HandoffStep.Failed -> HandoffPage(onClose = onClose, modifier = modifier) { - StepHeading(title = stringResource(R.string.handoff_error_title), body = stringResource(errorText(step))) - EnclyButton(text = stringResource(R.string.close), onClick = onClose) - } - } -} - -/** The counts, then what will not come over. */ -@Composable -private fun PreviewBody(preview: HandoffPreview, modifier: Modifier = Modifier) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section)) { - StepHeading( - title = stringResource(R.string.handoff_preview_title), - body = stringResource( - R.string.handoff_preview_body, - pluralStringResource(R.plurals.backup_count_notes, preview.notes, preview.notes), - pluralStringResource(R.plurals.backup_count_tasks, preview.tasks, preview.tasks), - pluralStringResource(R.plurals.backup_count_tags, preview.tags, preview.tags), - ), - ) - if (preview.attachments > 0) { - EnclyCallout( - text = stringResource(R.string.handoff_preview_attachments, preview.attachments), - tone = CalloutTone.WARNING, - ) - } - if (preview.pinned > 0) { - EnclyCallout(text = stringResource(R.string.handoff_preview_pinned, preview.pinned)) - } - } -} - -/** A titled, scrolling page with a close button, for every step that waits for the user. */ -@Composable -private fun HandoffPage( - onClose: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit, -) { - Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { - Column { - EnclyTopBar(title = stringResource(R.string.handoff_title), onClose = onClose) - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .navigationBarsPadding() - .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), - content = content, - ) - } - } -} - -private fun errorText(step: HandoffStep.Failed): Int = when { - step.vaultUnavailable -> R.string.handoff_error_vault - step.error == HandoffError.UNSUPPORTED_SCHEMA -> R.string.handoff_error_unsupported - step.error == HandoffError.TOO_LARGE -> R.string.handoff_error_too_large - step.error == HandoffError.INVALID_PAYLOAD -> R.string.handoff_error_invalid - else -> R.string.handoff_error_failed -} 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 deleted file mode 100644 index c9132529..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.pasich.encly.presentation.screen.importnotes - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.data.importer.ImportLosses -import com.pasich.encly.data.importer.ImportPreview -import com.pasich.encly.data.importer.ImportSource -import com.pasich.encly.presentation.designsystem.EnclyButton -import com.pasich.encly.presentation.designsystem.EnclyCallout -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.EnclyTonalButton -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.designsystem.StepHeading -import com.pasich.encly.presentation.viewmodel.ImportFailure -import com.pasich.encly.presentation.viewmodel.ImportResult -import com.pasich.encly.ui.theme.EnclyTheme - -@Composable -internal fun StartPage(actions: ImportNotesActions) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section)) { - Text( - text = stringResource(R.string.import_intro), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - EnclyButton(text = stringResource(R.string.import_choose_files), onClick = actions.onChooseFiles) - EnclyTonalButton(text = stringResource(R.string.import_choose_folder), onClick = actions.onChooseFolder) - } - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(text = stringResource(R.string.import_section_supported)) - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.cardGap)) { - ImportSource.entries.forEach { SourceCard(it) } - } - } - } -} - -@Composable -internal fun Labelled(label: String, text: String, modifier: Modifier = Modifier) { - Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap)) { - Text( - text = label.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text(text = text, style = MaterialTheme.typography.bodySmall) - } -} - -@Composable -internal fun previewLines(preview: ImportPreview): List = buildList { - add(stringResource(R.string.import_stat_checklists, preview.checklists)) - add(stringResource(R.string.import_stat_tags, preview.tags)) - if (preview.trashed > 0) add(stringResource(R.string.import_stat_trashed, preview.trashed)) - if (preview.duplicates > 0) add(stringResource(R.string.import_stat_duplicates, preview.duplicates)) - if (preview.duplicatesInSelection > 0) { - add(stringResource(R.string.import_stat_repeated, preview.duplicatesInSelection)) - } -} - -@Composable -internal fun lossLines(preview: ImportPreview): List { - val losses: ImportLosses = preview.losses - return buildList { - if (losses.attachments > 0) add(stringResource(R.string.import_loss_attachments, losses.attachments)) - if (losses.pinned > 0) add(stringResource(R.string.import_loss_pinned, losses.pinned)) - if (losses.archived > 0) add(stringResource(R.string.import_loss_archived, losses.archived)) - if (losses.colors > 0) add(stringResource(R.string.import_loss_colors, losses.colors)) - if (losses.extraTags > 0) add(stringResource(R.string.import_loss_tags, losses.extraTags)) - if (losses.todos > 0) add(stringResource(R.string.import_loss_todos, losses.todos)) - if (preview.unsupported > 0) add(stringResource(R.string.import_loss_unsupported, preview.unsupported)) - if (preview.filesSkipped > 0) add(stringResource(R.string.import_loss_files, preview.filesSkipped)) - if (preview.nestedArchives > 0) add(stringResource(R.string.import_loss_nested, preview.nestedArchives)) - } -} - -@Composable -internal fun ChooseSourcePage(actions: ImportNotesActions) { - StepHeading( - title = stringResource(R.string.import_choose_source_title), - body = stringResource(R.string.import_choose_source_body), - ) - ImportSource.entries.forEach { source -> - EnclyTonalButton(text = stringResource(source.title), onClick = { actions.onChooseSource(source) }) - } - EnclyTextButton( - text = stringResource(R.string.cancel), - onClick = actions.onCancel, - modifier = Modifier.fillMaxWidth(), - ) -} - -@Composable -internal fun DonePage(result: ImportResult, actions: ImportNotesActions) { - StepHeading( - title = stringResource(R.string.import_done_title), - body = stringResource(R.string.import_done_body, result.imported, result.duplicates, result.notImported), - ) - EnclyCallout(text = stringResource(R.string.import_done_hint)) - EnclyButton(text = stringResource(R.string.done), onClick = actions.onCancel) -} - -@Composable -internal fun FailedPage(failure: ImportFailure, actions: ImportNotesActions) { - StepHeading(title = stringResource(R.string.import_error_title), body = stringResource(errorText(failure))) - EnclyButton(text = stringResource(R.string.import_choose_again), onClick = actions.onCancel) -} - -internal fun errorText(failure: ImportFailure): Int = when (failure) { - ImportFailure.ENCRYPTED_STANDARD_NOTES -> R.string.import_error_encrypted_sn - ImportFailure.ENCRYPTED_NOTESNOOK -> R.string.import_error_encrypted_nn - ImportFailure.UNSUPPORTED -> R.string.import_error_unsupported - ImportFailure.TOO_LARGE -> R.string.import_error_too_large - ImportFailure.NOTHING_FOUND -> R.string.import_error_nothing - ImportFailure.UNREADABLE -> R.string.import_error_unreadable - ImportFailure.NO_PICKER -> R.string.import_error_no_picker - ImportFailure.VAULT -> R.string.import_error_vault - ImportFailure.IMPORT -> R.string.import_error_failed -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt deleted file mode 100644 index 645e245d..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.pasich.encly.presentation.screen.importnotes - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Checkbox -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.data.importer.ImportPreview -import com.pasich.encly.data.importer.ReviewNote -import com.pasich.encly.presentation.designsystem.CalloutTone -import com.pasich.encly.presentation.designsystem.EnclyButton -import com.pasich.encly.presentation.designsystem.EnclyCallout -import com.pasich.encly.presentation.designsystem.EnclyCard -import com.pasich.encly.presentation.designsystem.EnclyCardStyle -import com.pasich.encly.presentation.designsystem.EnclyChip -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.StepHeading -import com.pasich.encly.presentation.viewmodel.ReviewFilter -import com.pasich.encly.presentation.viewmodel.ReviewState -import com.pasich.encly.ui.theme.EnclyTheme -import com.pasich.encly.utils.formatNoteDate -import java.util.Date - -/** - * The review step before an import: the detected source with its counts and losses, then every - * note with a checkbox. Only ticked notes are imported; a note already in the vault is shown but - * cannot be ticked. Read-only: titles and snippets are shown here and nowhere else, and nothing - * on this page is saved in instance state, copied or shared. - */ -@Composable -fun ImportReviewScreen( - preview: ImportPreview, - review: ReviewState, - actions: ImportNotesActions, - modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.surface, - topBar = { EnclyTopBar(title = stringResource(R.string.import_title), onBack = actions.onCancel) }, - bottomBar = { - Column( - modifier = Modifier - .navigationBarsPadding() - .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - ) { - EnclyButton( - text = stringResource(R.string.import_action_import_count, review.selected.size), - onClick = actions.onImport, - enabled = review.selected.isNotEmpty(), - ) - EnclyTextButton( - text = stringResource(R.string.cancel), - onClick = actions.onCancel, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - ) { padding -> - LazyColumn( - modifier = Modifier.padding(padding), - contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), - ) { - item { Summary(preview) } - item { Controls(review, actions) } - items(review.visible, key = { it.uid }) { note -> - NoteRow( - note = note, - checked = note.uid in review.selected, - expandedText = review.expandedText.takeIf { review.expanded == note.uid }, - actions = actions, - ) - } - } - } -} - -@Composable -private fun Summary(preview: ImportPreview) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section)) { - StepHeading( - title = stringResource(R.string.import_preview_title), - body = stringResource( - R.string.import_detected, - stringResource(preview.source.title), - pluralStringResource(R.plurals.backup_count_notes, preview.notes, preview.notes), - ), - ) - EnclyCard { - previewLines(preview).forEach { Text(text = it, style = MaterialTheme.typography.bodyMedium) } - Text( - text = stringResource(R.string.import_stat_destination), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - val losses = lossLines(preview) - if (losses.isNotEmpty()) { - EnclyCallout( - title = stringResource(R.string.import_label_losses), - text = losses.joinToString("\n"), - tone = CalloutTone.WARNING, - ) - } - Text( - text = stringResource(R.string.import_preview_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -/** "N of M selected", select all / none, and the All / New only / In trash filter. */ -@Composable -private fun Controls(review: ReviewState, actions: ImportNotesActions) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = stringResource(R.string.import_review_selected, review.selected.size, review.newCount), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.weight(1f), - ) - // Nothing new to pick (every note is already here): no select-all to offer. - if (review.newCount > 0) { - val all = review.selected.size == review.newCount - EnclyTextButton( - text = stringResource(if (all) R.string.import_select_none else R.string.import_select_all), - onClick = actions.onToggleAll, - ) - } - } - Row(horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - FilterChip(ReviewFilter.ALL, R.string.import_filter_all, review, actions) - FilterChip(ReviewFilter.NEW, R.string.import_filter_new, review, actions) - FilterChip(ReviewFilter.TRASH, R.string.import_filter_trash, review, actions) - } - } -} - -@Composable -private fun FilterChip(filter: ReviewFilter, label: Int, review: ReviewState, actions: ImportNotesActions) { - EnclyChip( - label = stringResource(label), - selected = review.filter == filter, - onClick = { actions.onFilter(filter) }, - ) -} - -@Composable -private fun NoteRow(note: ReviewNote, checked: Boolean, expandedText: String?, actions: ImportNotesActions) { - EnclyCard(style = EnclyCardStyle.OUTLINED, onClick = { actions.onExpand(note.uid) }) { - Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { - Checkbox( - checked = checked, - onCheckedChange = { actions.onToggle(note.uid) }, - enabled = !note.existing, - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap), - ) { - Text( - text = note.title.ifBlank { stringResource(R.string.import_untitled) }, - style = MaterialTheme.typography.titleSmall, - ) - if (note.snippet.isNotEmpty()) { - Text( - text = note.snippet, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - Text( - text = badges(note), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) - if (expandedText != null) { - Text(text = expandedText, style = MaterialTheme.typography.bodySmall) - } - } - } - } -} - -/** Badges and date on one line: checklist, tag, in trash, already in the vault, then the date. */ -@Composable -private fun badges(note: ReviewNote): String = buildList { - if (note.hasChecklist) add(stringResource(R.string.import_badge_checklist)) - note.tag?.let { add("#$it") } - if (note.trashed) add(stringResource(R.string.import_badge_trash)) - if (note.existing) add(stringResource(R.string.import_badge_in_vault)) - add(formatNoteDate(Date(note.date))) -}.joinToString(" · ") diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingFooter.kt b/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingFooter.kt deleted file mode 100644 index 56ebf7f7..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingFooter.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.pasich.encly.presentation.screen.onboarding - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -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.core.security.PIN_LENGTH -import com.pasich.encly.presentation.designsystem.EnclyButton -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.RecoveryPhraseState -import com.pasich.encly.presentation.viewmodel.OnboardingStep -import com.pasich.encly.presentation.viewmodel.OnboardingViewModel.OnboardingUiState -import com.pasich.encly.ui.theme.EnclyTheme - -internal fun footerSpec( - state: OnboardingUiState, - actions: OnboardingActions, - restorePhrase: RecoveryPhraseState, - finishing: Boolean, -): FooterSpec { - val busy = state.isLoading - return when (state.step) { - OnboardingStep.WELCOME -> FooterSpec( - R.string.onboarding_get_started, - actions.onGetStarted, - secondary = R.string.onboarding_have_backup, - onSecondary = actions.onStartRestore, - ) - - OnboardingStep.PIN -> - FooterSpec(R.string.action_continue, actions.pin.onSubmit, state.pinLength == PIN_LENGTH, busy) - - OnboardingStep.RECOVERY_INFO -> FooterSpec( - R.string.onboarding_recovery_create, - actions.onCreatePhrase, - loading = busy, - secondary = R.string.onboarding_recovery_skip, - onSecondary = actions.onSkipPhrase, - secondaryMuted = true, - ) - - OnboardingStep.PHRASE -> - FooterSpec(R.string.onboarding_phrase_done, actions.onWroteWords, state.words.isNotEmpty()) - - OnboardingStep.VERIFY -> FooterSpec( - R.string.onboarding_verify_confirm, - actions.onConfirmWords, - state.isVerificationComplete, - busy, - secondary = R.string.onboarding_verify_show_again, - onSecondary = actions.onShowWordsAgain, - ) - - OnboardingStep.READY -> - FooterSpec(R.string.onboarding_ready_open, actions.onOpenNotebook, !state.biometricPending, finishing) - - OnboardingStep.RESTORE -> FooterSpec( - R.string.restore, - actions.onRestore, - state.restoreFileReady && restorePhrase.canSubmit, - busy, - ) - } -} - -/** - * The footer: the primary action, and below it a text action or the same space left empty, - * so the primary button never moves between steps. Text actions wait while the primary runs. - */ -@Composable -internal fun OnboardingFooter(spec: FooterSpec, modifier: Modifier = Modifier) { - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), - modifier = modifier - .fillMaxWidth() - .padding( - start = EnclyTheme.spacing.gutter, - end = EnclyTheme.spacing.gutter, - top = EnclyTheme.spacing.s, - bottom = EnclyTheme.spacing.stepGap, - ), - ) { - EnclyButton( - text = stringResource(spec.primary), - onClick = spec.onPrimary, - enabled = spec.enabled, - loading = spec.loading, - ) - val secondary = spec.secondary - if (secondary == null) { - Spacer(modifier = Modifier.height(EnclyTheme.spacing.textButtonHeight)) - } else { - EnclyTextButton( - text = stringResource(secondary), - onClick = spec.onSecondary, - enabled = !spec.loading, - muted = spec.secondaryMuted, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} 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 deleted file mode 100644 index a0a263a0..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt +++ /dev/null @@ -1,282 +0,0 @@ -package com.pasich.encly.presentation.screen.onboarding - -import android.content.ActivityNotFoundException -import android.net.Uri -import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -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.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.stringResource -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.pasich.encly.R -import com.pasich.encly.core.locale.AppLanguage -import com.pasich.encly.core.locale.AppLocales -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog -import com.pasich.encly.presentation.designsystem.EnclyProgressHeader -import com.pasich.encly.presentation.designsystem.LabelHeader -import com.pasich.encly.presentation.designsystem.RecoveryPhraseState -import com.pasich.encly.presentation.designsystem.WelcomeHeader -import com.pasich.encly.presentation.dialogs.LanguageDialog -import com.pasich.encly.presentation.viewmodel.AuthSetupViewModel -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 -private const val FADE_IN_MS = 300 -private const val FADE_IN_DELAY_MS = 200 -private const val FADE_OUT_MS = 300 - -/** - * First run, in one scaffold: a header (wordmark, progress or label), the step, and a footer - * whose primary button sits at the same place on every step. PIN and fingerprint come first, - * then the recovery phrase; the vault is created on the way to Ready and committed by "Open my - * notebook" ([AuthSetupViewModel.finishSetup]), which also imports a restored backup. - */ -@Composable -fun OnboardingScreen( - onComplete: () -> Unit, - modifier: Modifier = Modifier, - viewModel: OnboardingViewModel = enclyViewModel(), - setupViewModel: AuthSetupViewModel = enclyViewModel(), -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val finishing by setupViewModel.busy.collectAsStateWithLifecycle() - val restoreFailed by setupViewModel.restoreFailed.collectAsStateWithLifecycle() - val finishFailed by setupViewModel.finishFailed.collectAsStateWithLifecycle() - val activity = LocalActivity.current as? FragmentActivity - val currentOnComplete by rememberUpdatedState(onComplete) - val pickBackupFile = rememberBackupFilePicker(viewModel::onRestoreFilePicked, viewModel::onRestorePickerUnavailable) - - // The phrase is never put in saved state; it lives only while the screen does. - val restorePhrase = remember { RecoveryPhraseState() } - var languageDialog by rememberSaveable { mutableStateOf(false) } - - val onFinished: (AuthSetupViewModel.FinishResult) -> Unit = { result -> - // Backgrounded: set up but re-locked; MainActivity routes to the lock screen. - if (result.ok) currentOnComplete() - } - val onRestore = { - if (state.restoreFileReady && restorePhrase.canSubmit) viewModel.restoreBackup(restorePhrase.toCharArray()) - } - val actions = onboardingActions( - viewModel = viewModel, - restore = RestoreActions( - onPickFile = pickBackupFile, - onPhraseEdited = viewModel::onRestorePhraseEdited, - onSubmit = onRestore, - ), - onRestore = onRestore, - onLanguage = { languageDialog = true }, - onOpenNotebook = { setupViewModel.finishSetup(onFinished) }, - ) - - LaunchedEffect(state.biometricPending) { - if (state.biometricPending) { - if (activity != null) viewModel.enrollBiometric(activity) else viewModel.skipBiometric() - } - } - BackHandler(enabled = state.step != OnboardingStep.WELCOME) { viewModel.back() } - // Off the restore path (and once it reached Ready) the typed words are dropped. - LaunchedEffect(state.step) { - if (state.step != OnboardingStep.RESTORE && state.step != OnboardingStep.PIN) restorePhrase.clear() - } - - if (languageDialog) OnboardingLanguageDialog(onDismiss = { languageDialog = false }) - // Only over the restore path's Ready step, the one place a staged backup can have failed. - if (restoreFailed && state.step == OnboardingStep.READY && state.path == OnboardingPath.RESTORE) { - RestoreFailedDialog( - onRetry = { setupViewModel.retryRestore(onFinished) }, - onSkip = { setupViewModel.skipRestore(onFinished) }, - ) - } - - OnboardingScaffold( - state = state, - actions = actions, - restorePhrase = restorePhrase, - finishing = finishing, - error = if (finishFailed) stringResource(R.string.error_database_open) else state.error?.asString(), - modifier = modifier, - ) -} - -@Composable -private fun OnboardingScaffold( - state: OnboardingUiState, - actions: OnboardingActions, - restorePhrase: RecoveryPhraseState, - finishing: Boolean, - error: String?, - modifier: Modifier = Modifier, -) { - Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing) - .imePadding(), - ) { - OnboardingHeader(state = state, onBack = actions.onBack, onLanguage = actions.onLanguage) - StepContent( - state = state, - actions = actions, - restorePhrase = restorePhrase, - modifier = Modifier.weight(1f), - ) - if (error != null) { - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter), - ) - } - OnboardingFooter(footerSpec(state, actions, restorePhrase, finishing)) - } - } -} - -@Composable -private fun OnboardingLanguageDialog(onDismiss: () -> Unit) { - LanguageDialog( - current = AppLocales.current(), - onDismiss = onDismiss, - onConfirm = { language -> - onDismiss() - if (language != AppLocales.current()) AppLocales.apply(language) - }, - ) -} - -@Composable -private fun OnboardingHeader(state: OnboardingUiState, onBack: () -> Unit, onLanguage: () -> Unit) { - val progress = state.progress - when { - state.step == OnboardingStep.WELCOME -> WelcomeHeader( - appName = stringResource(R.string.app_name), - language = stringResource(shownLanguage().nativeName), - languageDescription = stringResource(R.string.language_dialog_title), - onLanguage = onLanguage, - ) - - progress == null -> LabelHeader(label = stringResource(R.string.onboarding_restore_label), onBack = onBack) - - else -> EnclyProgressHeader( - step = progress.step, - totalSteps = progress.total, - onBack = if (state.canGoBack) onBack else null, - ) - } -} - -/** The language the UI is shown in: the in-app choice, else the device's if translated, else English. */ -@Composable -private fun shownLanguage(): AppLanguage { - val chosen = AppLocales.current() - if (chosen != AppLanguage.SYSTEM) return chosen - val device = AppLanguage.fromTag(LocalConfiguration.current.locales[0]?.toLanguageTag()) - return if (device == AppLanguage.SYSTEM) AppLanguage.ENGLISH else device -} - -@Composable -private fun StepContent( - state: OnboardingUiState, - actions: OnboardingActions, - restorePhrase: RecoveryPhraseState, - modifier: Modifier = Modifier, -) { - AnimatedContent( - targetState = state.step, - modifier = modifier.fillMaxWidth(), - transitionSpec = { - val forward = targetState.ordinal > initialState.ordinal - slideInHorizontally( - initialOffsetX = { if (forward) it else -it }, - animationSpec = tween(SLIDE_MS, easing = FastOutSlowInEasing), - ) + fadeIn(tween(FADE_IN_MS, FADE_IN_DELAY_MS)) togetherWith slideOutHorizontally( - targetOffsetX = { if (forward) -it else it }, - animationSpec = tween(SLIDE_MS, easing = FastOutSlowInEasing), - ) + fadeOut(tween(FADE_OUT_MS)) - }, - label = "onboarding_step", - ) { step -> - val body = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(bottom = EnclyTheme.spacing.l) - when (step) { - OnboardingStep.WELCOME -> WelcomeStep(body) - OnboardingStep.PIN -> PinStep(state, actions.pin, body) - OnboardingStep.RECOVERY_INFO -> RecoveryInfoStep(body) - OnboardingStep.PHRASE -> PhraseStep(state, onToggleWords = actions.onToggleWords, modifier = body) - OnboardingStep.VERIFY -> VerifyStep(state, onAnswer = actions.onAnswer, modifier = body) - OnboardingStep.READY -> ReadyStep(state, body) - OnboardingStep.RESTORE -> RestoreStep(state, restorePhrase, actions.restore, body) - } - } -} - -/** The staged backup did not import; nothing is committed until the user chooses. */ -@Composable -private fun RestoreFailedDialog(onRetry: () -> Unit, onSkip: () -> Unit) { - EnclyDialog( - title = stringResource(R.string.auth_setup_restore_failed_title), - text = stringResource(R.string.auth_setup_restore_failed), - onDismissRequest = {}, - confirm = DialogAction(stringResource(R.string.auth_setup_restore_retry), onRetry), - dismiss = DialogAction(stringResource(R.string.auth_setup_restore_skip), onSkip), - ) -} - -/** - * The Storage Access Framework "open document" picker for backup files. [onUnavailable] runs - * instead of a crash on a device with no documents app to handle it. - */ -@Composable -private fun rememberBackupFilePicker(onPick: (Uri?) -> Unit, onUnavailable: () -> Unit): () -> Unit { - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument(), onPick) - return { - try { - launcher.launch(arrayOf("*/*")) - } catch (_: ActivityNotFoundException) { - onUnavailable() - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingSteps.kt b/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingSteps.kt deleted file mode 100644 index ec80b9ac..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingSteps.kt +++ /dev/null @@ -1,414 +0,0 @@ -package com.pasich.encly.presentation.screen.onboarding - -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -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.runtime.remember -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.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.CalloutTone -import com.pasich.encly.presentation.designsystem.DoneRow -import com.pasich.encly.presentation.designsystem.EnclyCallout -import com.pasich.encly.presentation.designsystem.EnclyCard -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyPillButton -import com.pasich.encly.presentation.designsystem.EnclySwitchRow -import com.pasich.encly.presentation.designsystem.FactRow -import com.pasich.encly.presentation.designsystem.FilePickerButton -import com.pasich.encly.presentation.designsystem.FormSection -import com.pasich.encly.presentation.designsystem.KeypadSize -import com.pasich.encly.presentation.designsystem.PinDots -import com.pasich.encly.presentation.designsystem.PinKeypad -import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput -import com.pasich.encly.presentation.designsystem.RecoveryPhraseState -import com.pasich.encly.presentation.designsystem.StepHeading -import com.pasich.encly.presentation.designsystem.UseRow -import com.pasich.encly.presentation.designsystem.WordGrid -import com.pasich.encly.presentation.viewmodel.OnboardingPath -import com.pasich.encly.presentation.viewmodel.OnboardingViewModel.OnboardingUiState -import com.pasich.encly.presentation.viewmodel.SecurityType -import com.pasich.encly.ui.theme.EnclyTheme - -/** Step 0: what Encly is, in three facts. */ -@Composable -internal fun WelcomeStep(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .padding(StepPadding) - .padding(top = EnclyTheme.spacing.heroTop), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.heroGap), - ) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.m)) { - Text( - text = stringResource(R.string.onboarding_welcome_headline), - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(R.string.onboarding_welcome_sub), - style = EnclyTheme.typography.heroSub, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.factGap)) { - FactRow( - EnclyIcons.UserOff, - stringResource(R.string.onboarding_fact_account_title), - stringResource(R.string.onboarding_fact_account_desc), - ) - FactRow( - EnclyIcons.CloudOff, - stringResource(R.string.onboarding_fact_offline_title), - stringResource(R.string.onboarding_fact_offline_desc), - ) - FactRow( - EnclyIcons.Shield, - stringResource(R.string.onboarding_fact_encrypted_title), - stringResource(R.string.onboarding_fact_encrypted_desc), - ) - } - } -} - -/** What the PIN step reports back to the ViewModel. */ -internal class PinActions( - val onDigit: (Int) -> Unit, - val onBackspace: () -> Unit, - val onBiometricChange: (Boolean) -> Unit, - val onSubmit: () -> Unit, -) - -/** - * Step 1: choose the PIN, then type it again; the biometric switch below the keypad. Digits - * from a hardware keyboard work too. - */ -@Composable -internal fun PinStep(state: OnboardingUiState, actions: PinActions, modifier: Modifier = Modifier) { - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } - Column( - modifier = modifier - .focusRequester(focus) - .focusable() - .onKeyEvent { event -> event.type == KeyEventType.KeyDown && handleKey(event.key, actions) }, - ) { - Column( - modifier = Modifier - .padding(StepPadding) - .padding(top = EnclyTheme.spacing.m), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.stepGap), - ) { - StepHeading( - title = stringResource( - if (state.isConfirmingPin) R.string.onboarding_pin_again_title else R.string.onboarding_pin_title, - ), - body = stringResource( - if (state.isConfirmingPin) R.string.onboarding_pin_again_body else R.string.onboarding_pin_body, - ), - ) - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - PinDots(entered = state.pinLength, error = state.pinError != null) - val error = state.pinError?.asString() - if (error != null) { - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - } - } - PinKeypad( - onDigit = actions.onDigit, - onBackspace = actions.onBackspace, - size = KeypadSize.SETUP, - enabled = !state.isLoading, - modifier = Modifier.padding(top = EnclyTheme.spacing.stepGap), - ) - if (state.biometricAvailable) { - BiometricCallout( - checked = state.biometricRequested, - onCheckedChange = actions.onBiometricChange, - modifier = Modifier - .padding(StepPadding) - .padding(top = EnclyTheme.spacing.factGap), - ) - } - } -} - -private fun handleKey(key: Key, actions: PinActions): Boolean { - val digit = DIGIT_KEYS.indexOf(key) - return when { - digit >= 0 -> { - actions.onDigit(digit) - true - } - - key == Key.Backspace -> { - actions.onBackspace() - true - } - - key == Key.Enter || key == Key.NumPadEnter -> { - actions.onSubmit() - true - } - - else -> false - } -} - -private val DIGIT_KEYS = listOf( - Key.Zero, Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, -) - -@Composable -private fun BiometricCallout(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier) { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = modifier.fillMaxWidth(), - ) { - EnclySwitchRow( - title = stringResource(R.string.pin_unlock_with_fingerprint), - supporting = stringResource(R.string.onboarding_biometric_desc), - icon = EnclyIcons.Fingerprint, - checked = checked, - onCheckedChange = onCheckedChange, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - } -} - -/** Step 2: why a recovery phrase, and what losing it means. */ -@Composable -internal fun RecoveryInfoStep(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .padding(StepPadding) - .padding(top = EnclyTheme.spacing.m), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.l), - ) { - StepHeading( - title = stringResource(R.string.onboarding_recovery_title), - body = stringResource(R.string.onboarding_recovery_body), - ) - Column { - UseRow( - EnclyIcons.Key, - stringResource(R.string.onboarding_recovery_pin_title), - stringResource(R.string.onboarding_recovery_pin_desc), - ) - UseRow( - EnclyIcons.File, - stringResource(R.string.onboarding_recovery_backups_title), - stringResource(R.string.onboarding_recovery_backups_desc), - ) - UseRow( - EnclyIcons.Phone, - stringResource(R.string.onboarding_recovery_phone_title), - stringResource(R.string.onboarding_recovery_phone_desc), - ) - } - EnclyCallout(text = stringResource(R.string.onboarding_recovery_warning), tone = CalloutTone.WARNING) - } -} - -/** Step 3: the twelve words to write down. */ -@Composable -internal fun PhraseStep(state: OnboardingUiState, onToggleWords: () -> Unit, modifier: Modifier = Modifier) { - Column( - modifier = modifier - .padding(StepPadding) - .padding(top = EnclyTheme.spacing.m), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.l), - ) { - StepHeading( - title = stringResource(R.string.onboarding_phrase_title), - body = stringResource(R.string.onboarding_phrase_body), - ) - if (state.words.isEmpty()) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) { - if (state.error == null) CircularProgressIndicator() - } - } else { - WordGrid(words = state.words, hidden = state.wordsHidden) - EnclyPillButton( - text = stringResource( - if (state.wordsHidden) R.string.onboarding_phrase_show else R.string.onboarding_phrase_hide, - ), - leadingIcon = if (state.wordsHidden) EnclyIcons.Eye else EnclyIcons.EyeOff, - onClick = onToggleWords, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - } -} - -/** Step 3b: type three of the words back. */ -@Composable -internal fun VerifyStep(state: OnboardingUiState, onAnswer: (Int, String) -> Unit, modifier: Modifier = Modifier) { - Column( - modifier = modifier - .padding(StepPadding) - .padding(top = EnclyTheme.spacing.m), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.stepGap), - ) { - StepHeading( - title = stringResource(R.string.onboarding_verify_title), - body = stringResource(R.string.onboarding_verify_body), - ) - PhraseCheckFields( - checks = state.verificationWords, - answers = state.userAnswers, - onAnswer = onAnswer, - enabled = !state.isLoading, - ) - } -} - -/** Step 4: what was set up. */ -@Composable -internal fun ReadyStep(state: OnboardingUiState, modifier: Modifier = Modifier) { - Column( - modifier = modifier - .padding(StepPadding) - .padding(top = EnclyTheme.spacing.readyTop), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xl), - ) { - StepHeading( - title = stringResource(R.string.onboarding_ready_title), - body = stringResource(R.string.onboarding_ready_body), - large = true, - ) - EnclyCard(contentPadding = PaddingValues(EnclyTheme.spacing.factGap)) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.fieldGap)) { - DoneRow( - stringResource(R.string.onboarding_ready_pin_title), - stringResource(R.string.onboarding_ready_pin_desc), - ) - if (state.biometricEnabled) { - DoneRow( - stringResource(R.string.onboarding_ready_biometric_title), - stringResource(R.string.onboarding_ready_biometric_desc), - ) - } - when { - state.path == OnboardingPath.RESTORE -> DoneRow( - stringResource(R.string.onboarding_ready_restored_title), - stringResource(R.string.onboarding_ready_restored_desc), - ) - - state.securityType == SecurityType.AUTO_MANAGED -> DoneRow( - stringResource(R.string.onboarding_ready_no_phrase_title), - stringResource(R.string.onboarding_ready_no_phrase_desc), - done = false, - ) - - else -> DoneRow( - stringResource(R.string.onboarding_ready_phrase_title), - stringResource(R.string.onboarding_ready_phrase_desc), - ) - } - } - } - if (state.securityType != SecurityType.AUTO_MANAGED) { - Row(horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.rowGap)) { - Icon( - EnclyIcons.Download, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(EnclyTheme.spacing.iconMedium), - ) - Text( - text = stringResource(R.string.onboarding_ready_tip), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -/** What the Restore step reports back. */ -internal class RestoreActions(val onPickFile: () -> Unit, val onPhraseEdited: () -> Unit, val onSubmit: () -> Unit) - -/** Alt: restore from a backup file and the 12 words it was made with, one numbered cell each. */ -@Composable -internal fun RestoreStep( - state: OnboardingUiState, - phrase: RecoveryPhraseState, - actions: RestoreActions, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .padding(StepPadding) - .padding(top = EnclyTheme.spacing.m), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.l), - ) { - StepHeading( - title = stringResource(R.string.onboarding_restore_title), - body = stringResource(R.string.onboarding_restore_intro), - ) - FormSection(title = stringResource(R.string.onboarding_restore_file_section)) { - FilePickerButton( - title = state.restoreFileName.takeIf { state.restoreFileReady } - ?: stringResource(R.string.onboarding_restore_pick), - hint = if (state.restoreFileReady) stringResource(R.string.onboarding_restore_tap_other) else null, - enabled = !state.isLoading, - onClick = actions.onPickFile, - ) - state.restoreFileError?.let { ErrorText(it.asString()) } - } - FormSection(title = stringResource(R.string.onboarding_restore_phrase_section)) { - Text( - text = stringResource(R.string.recovery_phrase_entry_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = EnclyTheme.spacing.xxs), - ) - RecoveryPhraseInput( - state = phrase, - enabled = !state.isLoading, - error = state.restorePhraseError?.asString(), - onEdit = actions.onPhraseEdited, - onDone = actions.onSubmit, - ) - Text( - text = stringResource(R.string.onboarding_restore_phrase_helper), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun ErrorText(text: String) { - Text(text = text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt b/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt deleted file mode 100644 index f578beec..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.pasich.encly.presentation.screen.pincode - -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.setValue -import com.pasich.encly.core.security.PIN_LENGTH -import com.pasich.encly.core.security.SensitiveDataCleaner - -/** - * The PIN digits typed on a keypad. They live in a CharArray that is wiped when taken or - * cleared, never in a String (a String cannot be wiped): only their count is Compose state. - */ -@Stable -internal class PinBuffer { - private val digits = CharArray(PIN_LENGTH) - - /** How many digits are typed. */ - var length by mutableIntStateOf(0) - private set - - val isFull: Boolean get() = length == PIN_LENGTH - - /** Adds [digit] (0-9); ignored once the PIN is complete. Returns whether it was added. */ - fun add(digit: Int): Boolean { - if (length >= PIN_LENGTH || digit !in 0..MAX_DIGIT) return false - digits[length] = '0' + digit - length++ - return true - } - - fun deleteLast() { - if (length > 0) { - length-- - digits[length] = '\u0000' - } - } - - /** The typed digits, taken out of the buffer, which is cleared. The caller wipes the copy. */ - fun take(): CharArray = digits.copyOf(length).also { clear() } - - /** Forgets the typed digits. */ - fun clear() { - SensitiveDataCleaner.clear(digits) - length = 0 - } - - private companion object { - const val MAX_DIGIT = 9 - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinLockout.kt b/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinLockout.kt deleted file mode 100644 index 465f53a9..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinLockout.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.pasich.encly.presentation.screen.pincode - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.res.pluralStringResource -import com.pasich.encly.R -import kotlinx.coroutines.delay - -private const val MILLIS_PER_SECOND = 1_000L -private const val LOCKOUT_ROUNDING_MILLIS = MILLIS_PER_SECOND - 1L - -/** Whole seconds left in a PIN lockout, rounded up so "0 s" is never shown while locked. */ -fun lockoutSecondsLeft(remainingMillis: Long): Long = - (remainingMillis.coerceAtLeast(0L) + LOCKOUT_ROUNDING_MILLIS) / MILLIS_PER_SECOND - -/** - * "Too many attempts. Try again in N seconds / minutes / hours", shared by every PIN entry. - * Rounded up, so a running lockout never reads as zero. - */ -@Composable -fun pinLockoutText(seconds: Long): String { - val (plural, count) = lockoutUnit(seconds) - return pluralStringResource(plural, count, count) -} - -/** The plural and count [pinLockoutText] shows for [seconds]. */ -internal fun lockoutUnit(seconds: Long): Pair = when { - seconds < 2 * SECONDS_PER_MINUTE -> R.plurals.lock_lockout_seconds to seconds.toInt() - - seconds < 2 * SECONDS_PER_HOUR -> - R.plurals.lock_lockout_minutes to ((seconds + SECONDS_PER_MINUTE - 1) / SECONDS_PER_MINUTE).toInt() - - else -> R.plurals.lock_lockout_hours to ((seconds + SECONDS_PER_HOUR - 1) / SECONDS_PER_HOUR).toInt() -} - -private const val SECONDS_PER_MINUTE = 60L -private const val SECONDS_PER_HOUR = 3_600L - -/** - * Reports the remaining PIN lockout every second until it ends. Restarts whenever [key] - * changes: pass `seconds > 0`, so a lockout that a failed attempt just started is counted - * down too, not only one already running when the screen opened. - */ -@Composable -fun PinLockoutTicker(key: Any?, remainingMillis: () -> Long, onSecondsChange: (Long) -> Unit) { - // The loop outlives recompositions (it restarts only on [key]); always call the latest lambdas. - val currentRemainingMillis by rememberUpdatedState(remainingMillis) - val currentOnSecondsChange by rememberUpdatedState(onSecondsChange) - LaunchedEffect(key) { - while (true) { - val remaining = currentRemainingMillis() - currentOnSecondsChange(lockoutSecondsLeft(remaining)) - if (remaining <= 0) break - delay(MILLIS_PER_SECOND) - } - } -} 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 deleted file mode 100644 index c51ad3b7..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt +++ /dev/null @@ -1,303 +0,0 @@ -package com.pasich.encly.presentation.screen.settings - -import androidx.annotation.StringRes -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -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.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController -import com.pasich.encly.R -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 com.pasich.encly.presentation.components.FontSizeSlider -import com.pasich.encly.presentation.components.NoteLineSpacingSlider -import com.pasich.encly.presentation.designsystem.EnclyCard -import com.pasich.encly.presentation.designsystem.EnclyCardStyle -import com.pasich.encly.presentation.designsystem.EnclyGroup -import com.pasich.encly.presentation.designsystem.EnclyRadioRow -import com.pasich.encly.presentation.designsystem.EnclySegmentedControl -import com.pasich.encly.presentation.designsystem.EnclySwitchRow -import com.pasich.encly.presentation.designsystem.EnclyTagPill -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.SectionOverline -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 - -/** Swatches render at this opacity while dynamic colour overrides the palette accent. */ -private const val DYNAMIC_SWATCH_ALPHA = 0.38f - -@Composable -fun AppearanceScreen( - navController: NavHostController, - modifier: Modifier = Modifier, - viewModel: AppearanceViewModel = enclyViewModel(), -) { - val settings by viewModel.themeSettings.collectAsStateWithLifecycle() - val fontSize by viewModel.defaultFontSize.collectAsStateWithLifecycle() - val lineSpacing by viewModel.defaultLineSpacing.collectAsStateWithLifecycle() - AppearanceContent( - settings = settings, - textDefaults = TextDefaults( - fontSize, - lineSpacing, - viewModel::setDefaultFontSize, - viewModel::setDefaultLineSpacing, - ), - supportsDynamicColor = viewModel.supportsDynamicColors(), - actions = AppearanceActions( - onBack = { navController.popBackStack() }, - onPalette = viewModel::selectPalette, - onMode = viewModel::selectMode, - onDynamicColor = viewModel::setDynamicColor, - onFontStyle = viewModel::selectFontStyle, - ), - modifier = modifier, - ) -} - -@Composable -private fun AppearanceContent( - settings: ThemeSettings, - textDefaults: TextDefaults, - supportsDynamicColor: Boolean, - actions: AppearanceActions, - modifier: Modifier = Modifier, -) { - val spacing = EnclyTheme.spacing - Scaffold( - modifier = modifier, - containerColor = MaterialTheme.colorScheme.surface, - topBar = { EnclyTopBar(title = stringResource(R.string.settings_appearance), onBack = actions.onBack) }, - ) { padding -> - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(start = spacing.gutter, end = spacing.gutter, top = spacing.s, bottom = spacing.l), - ) { - ThemeSection( - settings = settings, - dynamicActive = settings.dynamic && supportsDynamicColor, - onPalette = actions.onPalette, - ) - ModeSection(settings = settings, onMode = actions.onMode) - if (supportsDynamicColor) { - EnclySwitchRow( - title = stringResource(R.string.dynamic_color), - supporting = stringResource(R.string.dynamic_color_desc), - checked = settings.dynamic, - onCheckedChange = actions.onDynamicColor, - ) - } - FontSection(selected = settings.fontStyle, onFontStyle = actions.onFontStyle) - TextDefaultsSection(textDefaults) - PreviewCard() - } - } -} - -/** The default text size and line spacing of notes, and how to change them. */ -private class TextDefaults( - val fontSize: Int, - val lineSpacing: NoteLineSpacing, - val onFontSize: (Int) -> Unit, - val onLineSpacing: (NoteLineSpacing) -> Unit, -) - -/** The size and spacing every note starts with; a note can set its own from its menu. */ -@Composable -private fun TextDefaultsSection(defaults: TextDefaults, modifier: Modifier = Modifier) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(stringResource(R.string.appearance_text_section)) - FontSizeSlider(currentSize = defaults.fontSize, onSizeChange = defaults.onFontSize) - NoteLineSpacingSlider(selected = defaults.lineSpacing, onSelect = defaults.onLineSpacing) - Text( - text = stringResource(R.string.appearance_text_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -/** What the Appearance screen can ask for; every choice is saved at once. */ -private class AppearanceActions( - val onBack: () -> Unit, - val onPalette: (ThemePalette) -> Unit, - val onMode: (ThemeType) -> Unit, - val onDynamicColor: (Boolean) -> Unit, - val onFontStyle: (FontStyleType) -> Unit, -) - -@StringRes -private fun ThemePalette.labelRes(): Int = when (this) { - ThemePalette.PAPER -> R.string.palette_paper - ThemePalette.FOREST -> R.string.palette_forest - ThemePalette.OCEAN -> R.string.palette_ocean - ThemePalette.GRAPHITE -> R.string.palette_graphite - ThemePalette.MIDNIGHT -> R.string.palette_midnight -} - -@Composable -private fun ThemeSection( - settings: ThemeSettings, - dynamicActive: Boolean, - onPalette: (ThemePalette) -> Unit, - modifier: Modifier = Modifier, -) { - val dark = settings.isDark(isSystemInDarkTheme()) - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(stringResource(R.string.theme)) - Row( - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - modifier = Modifier - .fillMaxWidth() - .selectableGroup(), - ) { - ThemePalette.entries.forEach { palette -> - ThemeSwatch( - // A miniature of the palette in the resolved mode (Midnight is always dark). - preview = colorSchemeFor(palette, dark || palette.isAlwaysDark), - label = stringResource(palette.labelRes()), - selected = palette == settings.palette, - onClick = { onPalette(palette) }, - modifier = Modifier - .weight(1f) - .alpha(if (dynamicActive) DYNAMIC_SWATCH_ALPHA else 1f), - ) - } - } - if (dynamicActive) { - Text( - text = stringResource(R.string.appearance_dynamic_on_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun ModeSection(settings: ThemeSettings, onMode: (ThemeType) -> Unit, modifier: Modifier = Modifier) { - val selectable = AppearanceViewModel.isModeSelectable(settings.palette) - val note = when (AppearanceViewModel.modeNote(settings, isSystemInDarkTheme())) { - ModeNote.SYSTEM_SHOWING_LIGHT -> R.string.appearance_mode_note_system_light - ModeNote.SYSTEM_SHOWING_DARK -> R.string.appearance_mode_note_system_dark - ModeNote.ALWAYS_LIGHT -> R.string.appearance_mode_note_light - ModeNote.ALWAYS_DARK -> R.string.appearance_mode_note_dark - ModeNote.ALWAYS_DARK_PALETTE -> R.string.appearance_mode_note_midnight - } - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(stringResource(R.string.appearance_mode)) - EnclySegmentedControl( - segments = listOf( - Segment(ThemeType.SYSTEM, stringResource(R.string.theme_type_system), enabled = selectable), - Segment(ThemeType.LIGHT, stringResource(R.string.theme_type_light), enabled = selectable), - // Dark stays tappable-looking for Midnight: it is the mode actually shown. - Segment(ThemeType.DARK, stringResource(R.string.theme_type_dark)), - ), - selected = if (selectable) settings.type else ThemeType.DARK, - onSelect = onMode, - ) - Text( - text = stringResource(note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -private data class FontOption( - val style: FontStyleType, - @param:StringRes val title: Int, - @param:StringRes val description: Int, -) - -private val fontOptions = listOf( - FontOption(FontStyleType.COZY_EDITOR, R.string.font_style_cozy, R.string.font_style_cozy_desc), - FontOption(FontStyleType.MODERN_SIMPLE, R.string.font_style_modern, R.string.font_style_modern_desc), - FontOption(FontStyleType.TECH_MINIMAL, R.string.font_style_tech, R.string.font_style_tech_desc), -) - -@Composable -private fun FontSection(selected: FontStyleType, onFontStyle: (FontStyleType) -> Unit, modifier: Modifier = Modifier) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(stringResource(R.string.appearance_font)) - EnclyGroup(modifier = Modifier.selectableGroup()) { - fontOptions.forEach { option -> - FontRow(option = option, selected = option.style == selected, onClick = { onFontStyle(option.style) }) - } - } - } -} - -@Composable -private fun FontRow(option: FontOption, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - EnclyRadioRow( - title = stringResource(option.title), - description = stringResource(option.description), - selected = selected, - onClick = onClick, - modifier = modifier, - leading = { - Text( - text = stringResource(R.string.font_sample), - style = EnclyTheme.typography.fontSample.copy(fontFamily = option.style.fontSet().display), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.widthIn(min = EnclyTheme.spacing.xxl), - ) - }, - ) -} - -@Composable -private fun PreviewCard(modifier: Modifier = Modifier) { - EnclyCard(modifier = modifier, style = EnclyCardStyle.OUTLINED) { - Text(stringResource(R.string.appearance_preview_title), style = MaterialTheme.typography.titleMedium) - Text( - text = stringResource(R.string.appearance_preview_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - ) { - val meta = EnclyTheme.typography.meta.copy(fontWeight = FontWeight.SemiBold) - EnclyTagPill(text = stringResource(R.string.appearance_preview_tag)) - Text( - stringResource(R.string.appearance_preview_label), - style = meta, - color = MaterialTheme.colorScheme.primary, - ) - } - } -} 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 deleted file mode 100644 index 65b94e23..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/AutoLockSetting.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.pasich.encly.presentation.screen.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectableGroup -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.saveable.rememberSaveable -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.AutoLockDelay -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyNavigationRow -import com.pasich.encly.presentation.designsystem.EnclyRadioRow -import com.pasich.encly.ui.theme.EnclyTheme - -/** "Lock after leaving": the current delay; a tap opens the choice. */ -@Composable -internal fun AutoLockRow(current: AutoLockDelay, onSelect: (AutoLockDelay) -> Unit) { - var choosing by rememberSaveable { mutableStateOf(false) } - EnclyNavigationRow( - title = stringResource(R.string.auto_lock_title), - supporting = stringResource(current.label()), - icon = EnclyIcons.Lock, - onClick = { choosing = true }, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - if (choosing) { - AutoLockDialog( - current = current, - onDismiss = { choosing = false }, - onConfirm = { - onSelect(it) - choosing = false - }, - ) - } -} - -@Composable -private fun AutoLockDialog(current: AutoLockDelay, onDismiss: () -> Unit, onConfirm: (AutoLockDelay) -> Unit) { - var selected by rememberSaveable { mutableStateOf(current) } - EnclyDialog( - title = stringResource(R.string.auto_lock_title), - onDismissRequest = onDismiss, - confirm = DialogAction(stringResource(R.string.done), { onConfirm(selected) }), - dismiss = DialogAction(stringResource(R.string.cancel), onDismiss), - ) { - Column(modifier = Modifier.selectableGroup()) { - Text( - text = stringResource(R.string.auto_lock_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = EnclyTheme.spacing.xs), - ) - AutoLockDelay.entries.forEach { delay -> - EnclyRadioRow( - title = stringResource(delay.label()), - selected = delay == selected, - 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), - ) - } - } - } -} - -private fun AutoLockDelay.label(): Int = when (this) { - AutoLockDelay.IMMEDIATELY -> R.string.auto_lock_immediately - AutoLockDelay.SECONDS_15 -> R.string.auto_lock_15s - AutoLockDelay.SECONDS_30 -> R.string.auto_lock_30s - AutoLockDelay.MINUTE_1 -> R.string.auto_lock_1m - AutoLockDelay.MINUTES_2 -> R.string.auto_lock_2m - AutoLockDelay.MINUTES_15 -> R.string.auto_lock_15m - AutoLockDelay.MINUTES_30 -> R.string.auto_lock_30m -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt deleted file mode 100644 index c5151d5b..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.pasich.encly.presentation.screen.settings - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController -import com.pasich.encly.R -import com.pasich.encly.core.security.DuressPinChange -import com.pasich.encly.presentation.designsystem.CalloutTone -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyButton -import com.pasich.encly.presentation.designsystem.EnclyCallout -import com.pasich.encly.presentation.designsystem.EnclyDialog -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel -import com.pasich.encly.ui.theme.EnclyTheme - -private val DuressPinTexts = PinFlowTexts( - title = R.string.duress_pin_title, - currentSubtitle = R.string.duress_pin_current_subtitle, - newTitle = R.string.duress_pin_new_title, - newSubtitle = R.string.duress_pin_new_subtitle, - confirmTitle = R.string.duress_pin_confirm_title, - confirmSubtitle = R.string.duress_pin_confirm_subtitle, -) - -/** - * Settings → Security → Duress PIN. Same flow as the wipe PIN: the real PIN first, then what the - * duress PIN does and whether one is set, then the new PIN typed twice. In a decoy session it - * opens too, and setting or turning it off fails like a storage error (see SecurityManager). - */ -@Composable -fun DuressPinScreen( - navController: NavHostController, - modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = enclyViewModel(), -) { - val state = remember { PinFlowState() } - val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() - val flowActions = remember(state, securityViewModel) { - PinFlowActions( - verify = securityViewModel::verifyCurrentPin, - lockoutMillis = securityViewModel::pinLockoutRemainingMillis, - save = { pin -> securityViewModel.setDuressPin(pin) { result -> state.onDuressPinResult(result) } }, - close = { navController.popBackStack() }, - ) - } - - PinFlowScaffold( - state = state, - texts = DuressPinTexts, - actions = flowActions, - modifier = modifier, - ) { paddingValues -> - DuressPinOptions( - info = DuressPinInfo(turnedOff = securityState.duressPinTurnedOff, isSet = securityState.hasDuressPin), - actions = DuressPinActions( - onSet = state::startNew, - onRemove = { - securityViewModel.removeDuressPin { ok -> - state.message = if (ok) R.string.duress_pin_removed else R.string.duress_pin_failed - } - }, - ), - modifier = Modifier.padding(paddingValues), - ) - } -} - -private fun PinFlowState.onDuressPinResult(result: DuressPinChange) { - when (result) { - DuressPinChange.SET -> saved(R.string.duress_pin_saved_kept) - DuressPinChange.SET_NEW_VAULT -> saved(R.string.duress_pin_saved) - DuressPinChange.SAME_AS_PIN -> restartNew(R.string.duress_pin_same_as_pin) - DuressPinChange.IN_USE -> restartNew(R.string.pin_in_use) - DuressPinChange.FAILED -> restartNew(R.string.duress_pin_failed) - } -} - -/** What the options page shows besides the actions; [isSet]: a decoy vault exists, live or [turnedOff]. */ -private class DuressPinInfo(val turnedOff: Boolean, val isSet: Boolean) - -private class DuressPinActions(val onSet: () -> Unit, val onRemove: () -> Unit) - -@Composable -private fun DuressPinOptions(info: DuressPinInfo, actions: DuressPinActions, modifier: Modifier = Modifier) { - val spacing = EnclyTheme.spacing - var confirmingRemove by rememberSaveable { mutableStateOf(false) } - if (confirmingRemove) { - RemoveDuressPinDialog( - onConfirm = { - confirmingRemove = false - actions.onRemove() - }, - onDismiss = { confirmingRemove = false }, - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(spacing.m), - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = spacing.gutter, vertical = spacing.s), - ) { - if (info.turnedOff) { - EnclyCallout(text = stringResource(R.string.duress_pin_turned_off), tone = CalloutTone.WARNING) - } - Text( - text = stringResource(R.string.duress_pin_intro), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - EnclyCallout(text = stringResource(R.string.duress_pin_real_vault)) - Text( - text = stringResource(R.string.duress_pin_limits), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = stringResource( - if (info.isSet && !info.turnedOff) R.string.duress_pin_status_on else R.string.duress_pin_status_off, - ), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Column( - verticalArrangement = Arrangement.spacedBy(spacing.xxs), - modifier = Modifier.fillMaxWidth().padding(top = spacing.s), - ) { - EnclyButton( - text = stringResource(if (info.isSet) R.string.duress_pin_change else R.string.duress_pin_set), - onClick = actions.onSet, - ) - if (info.isSet) { - EnclyTextButton( - text = stringResource(R.string.duress_pin_remove), - onClick = { confirmingRemove = true }, - destructive = true, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} - -/** Turning it off deletes the decoy vault; the real vault is not affected. */ -@Composable -private fun RemoveDuressPinDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - EnclyDialog( - title = stringResource(R.string.duress_pin_remove_confirm_title), - text = stringResource(R.string.duress_pin_remove_confirm_body), - onDismissRequest = onDismiss, - confirm = DialogAction(stringResource(R.string.duress_pin_remove_confirm), onConfirm, destructive = true), - dismiss = DialogAction(stringResource(R.string.cancel), onDismiss), - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySections.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySections.kt deleted file mode 100644 index 94f717d9..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySections.kt +++ /dev/null @@ -1,236 +0,0 @@ -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 -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.DialogAction -import com.pasich.encly.presentation.designsystem.EnclyDialog -import com.pasich.encly.presentation.designsystem.EnclyGroup -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. */ -private const val ERASE_HOLD_MS = 5_000 - -/** How Encly protects the notes: three facts in a group, not a wall of bullets. */ -@Composable -internal fun ProtectionSection(modifier: Modifier = Modifier) { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), modifier = modifier) { - SectionOverline( - text = stringResource(R.string.security_protection_section), - modifier = Modifier.padding(top = EnclyTheme.spacing.s), - ) - EnclyGroup { - EnclyListRow( - title = stringResource(R.string.security_protection_key_title), - supporting = stringResource(R.string.security_protection_key_desc), - icon = EnclyIcons.Key, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - EnclyGroupDivider() - EnclyListRow( - title = stringResource(R.string.security_protection_pin_title), - supporting = stringResource(R.string.security_protection_pin_desc), - icon = EnclyIcons.Shield, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - EnclyGroupDivider() - EnclyListRow( - title = stringResource(R.string.security_protection_slots_title), - supporting = stringResource(R.string.security_protection_slots_desc), - icon = EnclyIcons.Fingerprint, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - } - } -} - -/** - * The danger zone, last on the page: what erasing does, and a full-width `error` button held - * for five seconds. Then a warning dialog, and only after it the PIN or fingerprint ([onErase] - * starts the re-authenticated erase). - */ -@Composable -internal fun EraseSection(busy: Boolean, onErase: () -> Unit, modifier: Modifier = Modifier) { - val colors = MaterialTheme.colorScheme - var confirming by rememberSaveable { mutableStateOf(false) } - if (confirming) { - EnclyDialog( - title = stringResource(R.string.security_erase_confirm_title), - text = stringResource(R.string.security_erase_confirm_body), - onDismissRequest = { confirming = false }, - confirm = DialogAction( - stringResource(R.string.security_erase_confirm), - { - confirming = false - onErase() - }, - destructive = true, - ), - dismiss = DialogAction(stringResource(R.string.cancel), { confirming = false }), - ) - } - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), modifier = modifier) { - SectionOverline( - text = stringResource(R.string.security_danger_section), - modifier = Modifier.padding(top = EnclyTheme.spacing.s), - ) - Surface( - shape = MaterialTheme.shapes.large, - color = colors.surface, - border = BorderStroke(EnclyTheme.spacing.hairline, colors.error), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), - modifier = Modifier.padding(EnclyTheme.spacing.m), - ) { - Text( - text = stringResource(R.string.security_erase_title), - style = MaterialTheme.typography.titleMedium, - color = colors.error, - ) - Text( - text = stringResource(R.string.security_erase_desc), - style = MaterialTheme.typography.bodyMedium, - color = colors.onSurfaceVariant, - ) - EnclyHoldToConfirmButton( - text = stringResource(R.string.security_erase_hold), - onConfirm = { if (!busy) confirming = true }, - holdMillis = ERASE_HOLD_MS, - ) - } - } - } -} - -/** - * "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 deleted file mode 100644 index b6696dea..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt +++ /dev/null @@ -1,265 +0,0 @@ -package com.pasich.encly.presentation.screen.settings - -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.fillMaxSize -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.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController -import com.pasich.encly.R -import com.pasich.encly.core.common.UiText -import com.pasich.encly.core.security.AuthType -import com.pasich.encly.core.security.AutoLockDelay -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.EnclyNavigationRow -import com.pasich.encly.presentation.designsystem.EnclySnackbarHost -import com.pasich.encly.presentation.designsystem.EnclySwitchRow -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.SectionOverline -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.backup.BackupDialogs -import com.pasich.encly.presentation.screen.backup.rememberBackupDialogActions -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 - -@Composable -fun SecuritySettingsScreen( - navController: NavHostController, - modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = enclyViewModel(), - vaultViewModel: BackupViewModel = enclyViewModel(), -) { - val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() - val strictKeyboard by securityViewModel.strictKeyboard.collectAsStateWithLifecycle() - val autoLockDelay by securityViewModel.autoLockDelay.collectAsStateWithLifecycle() - val vaultState by vaultViewModel.uiState.collectAsStateWithLifecycle() - val activity = LocalActivity.current as? FragmentActivity - - val snackbarHostState = remember { SnackbarHostState() } - val dialogActions = rememberBackupDialogActions(vaultViewModel) - val effectActions = remember(navController, securityViewModel, vaultViewModel) { - SecurityEffectActions( - onVaultMessageShown = { - vaultViewModel.clearMessage() - securityViewModel.refresh() - }, - onVaultErased = { - navController.navigate(NavRoutes.OnboardingRoute.name) { - popUpTo(0) { inclusive = true } - } - }, - onErrorShown = securityViewModel::clearError, - ) - } - SecurityScreenEffects(vaultState.message, vaultState.erased, securityState.error, snackbarHostState, effectActions) - - Box(modifier = modifier.fillMaxSize()) { - Scaffold( - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - EnclyTopBar(title = stringResource(R.string.security_title), onBack = { navController.popBackStack() }) - }, - snackbarHost = { EnclySnackbarHost(snackbarHostState) }, - ) { paddingValues -> - // Nothing until the first load: no PIN text that turns into the phrase text. - if (securityState.loaded) { - SecurityContent( - securityState = securityState, - options = SecurityOptions(strictKeyboard = strictKeyboard, autoLock = autoLockDelay), - vaultBusy = vaultState.busy, - actions = SecurityActions( - onOpen = { route -> navController.navigate(route.name) }, - onBiometric = { enabled -> - if (activity != null) securityViewModel.toggleBiometric(activity, enabled) - }, - onVaultAction = vaultViewModel::start, - onStrictKeyboard = securityViewModel::setStrictKeyboard, - onAutoLock = securityViewModel::setAutoLockDelay, - ), - modifier = Modifier.padding(paddingValues), - ) - } - } - // Re-authentication, the new-phrase steps and the erase confirmation; the phrase steps - // cover the whole screen, so they are drawn last. - BackupDialogs(dialogActions, vaultState.step, vaultState.busy) - } -} - -/** What the security page can start. */ -private class SecurityActions( - val onOpen: (NavRoutes) -> Unit, - val onBiometric: (Boolean) -> Unit, - val onVaultAction: (BackupAction) -> Unit, - val onStrictKeyboard: (Boolean) -> Unit, - val onAutoLock: (AutoLockDelay) -> Unit, -) - -/** The page's own preferences: strict keyboard privacy and the auto-lock delay. */ -private class SecurityOptions(val strictKeyboard: Boolean, val autoLock: AutoLockDelay) - -@Composable -private fun SecurityContent( - securityState: SecuritySettingsViewModel.SecuritySettingsUiState, - options: SecurityOptions, - vaultBusy: Boolean, - actions: SecurityActions, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), - ) { - SecurityRows(securityState, options, vaultBusy, actions) - } -} - -@Composable -private fun SecurityRows( - securityState: SecuritySettingsViewModel.SecuritySettingsUiState, - options: SecurityOptions, - vaultBusy: Boolean, - actions: SecurityActions, -) { - EncryptionStatusCard( - title = stringResource(R.string.security_encryption_active), - text = stringResource( - if (securityState.isUserCreatedSeedKey) { - R.string.security_encryption_seed_plain - } else { - R.string.security_encryption_pin_plain - }, - ), - ) - SectionOverline( - text = stringResource(R.string.auth_title), - modifier = Modifier.padding(top = EnclyTheme.spacing.s), - ) - EnclyGroup { - EnclyNavigationRow( - title = stringResource(R.string.auth_method_pin_title), - supporting = stringResource(R.string.auth_method_pin_desc), - icon = EnclyIcons.Key, - onClick = { actions.onOpen(NavRoutes.PinCodeConfig) }, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - if (securityState.authType != AuthType.NONE) { - EnclyGroupDivider() - BiometricSetting(securityState, actions.onBiometric) - } - if (securityState.hasPinSlot) { - EnclyGroupDivider() - 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) }) -} - -private class SecurityEffectActions( - val onVaultMessageShown: () -> Unit, - val onVaultErased: () -> Unit, - val onErrorShown: () -> Unit, -) - -/** Messages and the end of the vault flows (a new recovery phrase, an erased vault), and errors. */ -@Composable -private fun SecurityScreenEffects( - vaultMessage: BackupMessage?, - vaultErased: Boolean, - securityError: UiText?, - snackbarHostState: SnackbarHostState, - 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, vaultMessageText) { - val message = vaultMessageText ?: return@LaunchedEffect - actions.onVaultMessageShown() - snackbarScope.launch { snackbarHostState.showSnackbar(message) } - } - - LaunchedEffect(vaultErased) { - if (vaultErased) actions.onVaultErased() - } - - LaunchedEffect(securityError) { - val errorMessage = securityError ?: return@LaunchedEffect - actions.onErrorShown() - snackbarScope.launch { snackbarHostState.showSnackbar(errorMessage.asString(context)) } - } -} - -@Composable -private fun BiometricSetting( - securityState: SecuritySettingsViewModel.SecuritySettingsUiState, - onToggle: (Boolean) -> Unit, -) { - EnclySwitchRow( - title = stringResource(R.string.security_biometric_title), - supporting = stringResource( - when (securityState.biometricStatus) { - BiometricStatus.AVAILABLE -> R.string.security_biometric_available - - // The sensor is there: say what is missing, not that the device cannot do it. - BiometricStatus.NOT_ENROLLED -> R.string.security_biometric_not_enrolled - - BiometricStatus.UNAVAILABLE -> R.string.security_biometric_unavailable - }, - ), - icon = EnclyIcons.Fingerprint, - checked = securityState.biometricEnable, - enabled = securityState.isBiometricAvailable, - onCheckedChange = onToggle, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) -} 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 deleted file mode 100644 index 35bf71cc..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.pasich.encly.presentation.screen.settings - -import androidx.activity.compose.LocalActivity -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.remember -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.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.EnclyTextButton -import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel -import com.pasich.encly.ui.theme.EnclyTheme - -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). - */ -@Composable -fun WipePinScreen( - navController: NavHostController, - modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = enclyViewModel(), -) { - val state = remember { PinFlowState() } - val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() - val activity = LocalActivity.current as? FragmentActivity - val flowActions = remember(state, securityViewModel) { - PinFlowActions( - verify = securityViewModel::verifyCurrentPin, - lockoutMillis = securityViewModel::pinLockoutRemainingMillis, - save = { pin -> securityViewModel.setWipePin(pin) { result -> state.onWipePinResult(result) } }, - close = { navController.popBackStack() }, - ) - } - - PinFlowScaffold( - state = state, - texts = WipePinTexts, - actions = flowActions, - modifier = modifier, - ) { 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), - ) - } -} - -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) - } -} - -/** What the options page shows besides the actions. */ -private class WipePinInfo(val turnedOff: Boolean, val biometricOn: Boolean) - -private class WipePinActions(val onSet: () -> Unit, val onRemove: () -> Unit, val onBiometricOff: () -> Unit) - -@Composable -private fun WipePinOptions(info: WipePinInfo, actions: WipePinActions, modifier: Modifier = Modifier) { - val spacing = EnclyTheme.spacing - 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.wipe_pin_turned_off), tone = CalloutTone.WARNING) - } - Text( - text = stringResource(R.string.wipe_pin_intro), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - EnclyCallout(text = stringResource(R.string.wipe_pin_after_erase), tone = CalloutTone.WARNING) - EnclyCallout(text = stringResource(R.string.wipe_pin_backups)) - if (info.biometricOn) { - Column(verticalArrangement = Arrangement.spacedBy(spacing.xxs)) { - EnclyCallout(text = stringResource(R.string.wipe_pin_biometric_warning), tone = CalloutTone.WARNING) - EnclyTextButton( - text = stringResource(R.string.wipe_pin_biometric_off), - onClick = actions.onBiometricOff, - ) - } - } - Text( - text = stringResource(R.string.wipe_pin_limits), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Column( - verticalArrangement = Arrangement.spacedBy(spacing.xxs), - modifier = Modifier.fillMaxWidth().padding(top = spacing.s), - ) { - EnclyButton(text = stringResource(R.string.wipe_pin_set), onClick = actions.onSet) - EnclyTextButton( - text = stringResource(R.string.wipe_pin_remove), - onClick = actions.onRemove, - destructive = true, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} 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 deleted file mode 100644 index 28e31d65..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.pasich.encly.presentation.screen.trash - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -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 com.pasich.encly.R -import com.pasich.encly.core.common.LoadState -import com.pasich.encly.data.model.Note -import com.pasich.encly.presentation.components.tiles.NoteItem -import com.pasich.encly.presentation.designsystem.EnclyEmptyState -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 - -/** - * The trashed notes: a skeleton while they load, "Trash is empty" or the read error, else the - * dimmed cards under a hint that a long-press selects. Tapping a card opens it read-only, or - * toggles it while a selection is open. - */ -@Composable -fun TrashNotesList( - onItemClick: (Int, Note) -> Unit, - modifier: Modifier = Modifier, - trashViewModel: TrashViewModel = enclyViewModel(), -) { - val listScrollState = rememberLazyListState() - val state by trashViewModel.state.collectAsStateWithLifecycle() - val stableNotes = remember(state.notes) { - state.notes.mapIndexed { index, note -> StableTrashNoteItem(note, index) } - } - val spacing = EnclyTheme.spacing - - LazyColumn( - modifier = modifier.fillMaxSize(), - state = listScrollState, - contentPadding = PaddingValues(top = spacing.xs, bottom = spacing.l), - verticalArrangement = Arrangement.spacedBy(spacing.cardGap), - ) { - val load = state.notesLoad - when { - load is LoadState.Loading -> items(SKELETON_CARDS) { - NoteSkeleton(modifier = Modifier.padding(horizontal = spacing.listGutter)) - } - - // A failed read is never shown as an empty trash. - load is LoadState.Failed -> item { - EnclyEmptyState( - icon = EnclyIcons.Trash, - title = load.error.title.asString(), - body = load.error.message.asString(), - error = true, - ) - } - - stableNotes.isEmpty() -> item { - EnclyEmptyState( - icon = EnclyIcons.Trash, - title = stringResource(R.string.empty_trash_title), - body = stringResource(R.string.empty_trash_desc), - ) - } - - else -> trashCards(stableNotes, state.canCheck, onItemClick) { index -> - trashViewModel.onEvent(TrashListEvent.ToggleCheckItem(index)) - } - } - } -} - -/** The hint and the dimmed cards; [onToggle] selects or unselects the card at an index. */ -private fun LazyListScope.trashCards( - notes: List, - selecting: Boolean, - onOpen: (Int, Note) -> Unit, - onToggle: (Int) -> Unit, -) { - item(key = "hint") { - Text( - text = stringResource(R.string.trash_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.listGutter), - ) - } - items(items = notes, key = { it.id }) { stableItem -> - NoteItem( - itemNote = stableItem.note, - dimmed = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = EnclyTheme.spacing.listGutter) - .animateItem(), - onItemClick = { - if (selecting) onToggle(stableItem.index) else onOpen(stableItem.index, stableItem.note) - }, - onItemLongClick = { if (!selecting) onToggle(stableItem.index) }, - ) - } -} - -@Stable -private data class StableTrashNoteItem(val note: Note, val index: Int, val id: Long = note.id) diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt deleted file mode 100644 index 56cf797b..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner - -/** - * At most one biometric prompt at a time, for a ViewModel that outlives its activity. - * - * A second request while a prompt is open is ignored: a double tap must not stack prompts. The - * guard is released by the prompt's answer, or when the activity that showed it is destroyed. - * androidx.biometric drops the answer of a prompt whose activity is gone (BiometricPrompt resets - * its callback on ON_DESTROY), so without that a rotation during a prompt would leave the guard - * closed for good and the ViewModel, which survives the rotation, could never prompt again. - * - * Main thread only, as the prompts themselves. - */ -internal class BiometricPromptGuard { - private var current: Any? = null - - val inFlight: Boolean get() = current != null - - /** - * Runs [prompt] unless one is already open. [prompt] calls the `release` it is given with - * its answer; `release` returns false when that answer is stale (the guard was released by - * the activity's end meanwhile, and a newer prompt may be open), and the caller then drops it. - */ - fun launch(host: LifecycleOwner, prompt: (release: () -> Boolean) -> Unit) { - // A destroyed activity can show no prompt, and would never release the guard. - if (current != null || host.lifecycle.currentState == Lifecycle.State.DESTROYED) return - val token = Any() - current = token - val observer = object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - owner.lifecycle.removeObserver(this) - if (current === token) current = null - } - } - host.lifecycle.addObserver(observer) - prompt { - host.lifecycle.removeObserver(observer) - val mine = current === token - if (mine) current = null - mine - } - } -} 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 deleted file mode 100644 index 6452122d..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ /dev/null @@ -1,609 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.pasich.encly.core.di.ApplicationScope -import com.pasich.encly.core.di.IoDispatcher -import com.pasich.encly.core.security.NeverLocked -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.NoteColor -import com.pasich.encly.domain.model.NoteLineSpacing -import com.pasich.encly.domain.model.NoteTextOverride -import com.pasich.encly.domain.repository.NotesRepository -import com.pasich.encly.domain.repository.SettingsRepository -import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase -import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.dynamicBlocks.TextualBlock -import com.pasich.encly.dynamicBlocks.factory.BlockFactory -import com.pasich.encly.presentation.editor.persistence.LoadNoteState -import com.pasich.encly.presentation.editor.persistence.NoteCopyTitle -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 -import com.pasich.encly.presentation.editor.state.moveBlockOf -import com.pasich.encly.presentation.editor.state.moveInteracted -import com.pasich.encly.presentation.editor.state.pasteLink -import com.pasich.encly.presentation.editor.state.removeInteracted -import com.pasich.encly.presentation.editor.state.toggleChecklistLock -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 -import javax.inject.Inject - -/** - * The note editor. Wires the block content ([BlockEditorState]) to the stored note - * ([NotePersistence]) and decides whether the note may be edited at all (locked, or read-only - * from the trash). Focus is plain state here; the UI owns the FocusRequesters. - */ -@HiltViewModel -class EditNoteViewModel -@Suppress("LongParameterList") // Hilt-injected dependencies; grouping them would only obscure wiring. -@Inject -constructor( - notesRepository: NotesRepository, - private val savedStateHandle: SavedStateHandle, - updateNoteTrashStatusUseCase: UpdateNoteTrashStatusUseCase, - updateNoteArchiveStatusUseCase: UpdateNoteArchiveStatusUseCase, - private val settingsRepository: SettingsRepository, - @ApplicationScope private val appScope: CoroutineScope, - @IoDispatcher ioDispatcher: CoroutineDispatcher, - copyTitle: NoteCopyTitle, - lockEvents: VaultLockEvents = NeverLocked, -) : ViewModel() { - private val noteId: Long = savedStateHandle["idNote"] - ?: -1 // Note identifier; when creating a new note it will be -1 - internal val copySource: Long = - savedStateHandle["copySource"] ?: -1 // Identifier of the note to copy, if specified - val isReadTrashOnly: Boolean = - savedStateHandle["isReadTrashOnly"] as? Boolean == true // Flag for reading a note only from the trash - internal val addTag: Long = savedStateHandle["addTag"] ?: 0 - - /** A note being written from scratch (not opened, not a copy): the editor focuses its title. */ - val isNewNote: Boolean get() = noteId == -1L && copySource == -1L - - private val editor = BlockEditorState() - - private val persistence = NotePersistence( - notesRepository = notesRepository, - updateNoteTrashStatusUseCase = updateNoteTrashStatusUseCase, - updateNoteArchiveStatusUseCase = updateNoteArchiveStatusUseCase, - ioDispatcher = ioDispatcher, - readOnly = isReadTrashOnly, - blocks = { editor.blocks }, - copyTitle = copyTitle, - loading = noteId != -1L || copySource != -1L, - ) - - val state: StateFlow get() = persistence.state - val status: StateFlow get() = persistence.status - - /** - * True when a note had stored content that could not be read: the editor warns instead of - * showing a silent blank screen, and nothing is saved over the original. - */ - val contentLoadFailed: StateFlow get() = persistence.contentLoadFailed - - /** The blocks to display and edit. */ - val blocks: List get() = editor.blocks - - val canUndo: StateFlow get() = editor.canUndo - val canRedo: StateFlow get() = editor.canRedo - - /** The block the user last worked on; the toolbar acts on it. */ - val interactedBlockId: StateFlow get() = editor.selection.interactedBlockId - - /** Position of the block the user last worked on, always inside [blocks]. */ - val interactedIndex: Int get() = editor.selection.interactedIndex - - /** The block whose field has focus; null while none has. */ - val focusedBlockId: StateFlow get() = editor.selection.focusedBlockId - - /** Which block the editor UI should focus next. */ - val focusRequests: Flow get() = editor.selection.requests - - /** Carries out a focus request at once where its field exists; set by the editor screen while composed. */ - var immediateFocus: ((FocusRequest) -> Unit)? - get() = editor.selection.immediateFocus - set(value) { - editor.selection.immediateFocus = value - } - - /** 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 - - // Link blocks whose saved address is open in URL entry. Editor UI state only: the block - // keeps its stored link until a new one is committed. - private val _editingLinkIds = MutableStateFlow>(emptySet()) - - /** Ids of the link blocks the user is editing ([editLink]); they show URL entry. */ - val editingLinkIds: StateFlow> get() = _editingLinkIds - - /** - * What this note sets for its own text size and line spacing; the rest follows the defaults. - * They are columns of the note in the vault, stored with it like the title. - */ - val textOverride: StateFlow = persistence.state.map { state -> - NoteTextOverride( - size = state.note.textSize, - spacing = state.note.lineSpacing?.let { enumValues().getOrNull(it) }, - ) - }.distinctUntilChanged().stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), - NoteTextOverride(), - ) - - /** The text size in effect: the note's own, else the default from Settings → Appearance. */ - val fontSize: StateFlow = combine(settingsRepository.fontSizeFlow, textOverride) { default, own -> - own.size ?: default - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = 16, - ) - - val fontStyle = settingsRepository.fontStyleFlow.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = FontStyleType.DEFAULT, - ) - - val noteLineSpacing = combine(settingsRepository.noteLineSpacingFlow, textOverride) { default, own -> - own.spacing ?: default - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = NoteLineSpacing.DEFAULT, - ) - - val simpleEdit = settingsRepository.simpleEditFlow.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = false, - ) - - init { - when { - copySource != -1L -> loadNote(copySource, isCopy = true) - - noteId != -1L -> loadNote(noteId, isCopy = false) - - // A new note created from a tag's list starts in that tag. - 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 = "") } - } - } - - // A new note (or copy) gets its id on its first save. Kept as the screen's argument, so a - // screen restored after process death opens that note instead of a blank one. - private fun rememberStoredId() { - viewModelScope.launch { - persistence.state.map { it.note.id }.distinctUntilChanged().collect { id -> - if (id > 0L && id != noteId) { - savedStateHandle["idNote"] = id - savedStateHandle["copySource"] = -1L - } - } - } - } - - /** 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) - // A note that could not be read stays read-only, so an empty editor is never - // saved as (or over) it. - _lockEditor.value = !found || isReadTrashOnly - } - } - - override fun onCleared() { - // One last-chance save for lifecycle teardown. It does nothing once the editor was - // closed (explicit exit, trash, delete, restore, discard), so it cannot duplicate an - // insert or undo any of those. - appScope.launch { persistence.save() } - super.onCleared() - } - - /** The user leaves the editor: no queued or later autosave may write the note again. */ - fun markExitHandled() = persistence.close() - - /** - * Flush the current editor state as soon as the Activity leaves the foreground. - * ProcessLifecycleOwner re-locks/closes SQLCipher later in the background transition, - * so this closes the autosave debounce window before the vault is closed. - */ - fun saveForBackground() { - appScope.launch { persistence.save() } - } - - /** Persists the note; returns once the database reports success or failure. */ - suspend fun saveNote(): Boolean = persistence.save() - - /** Puts the stored note back as it was when the editor opened it. */ - suspend fun discardChanges(): Boolean = persistence.discard() - - /** Restores the note from the trash. */ - suspend fun noteRestore(): Boolean = persistence.restoreFromTrash() - - /** Moves the note to the trash; returns once the write completed. */ - suspend fun noteMoveToTrash(): Boolean = persistence.moveToTrash() - - /** Moves the note to the archive, or back to the list; returns once the write completed. */ - suspend fun noteSetArchived(archived: Boolean): Boolean = persistence.setArchived(archived) - - /** Marks the note with [color]. Stored with the next save, like the title. */ - fun updateColor(color: NoteColor) { - if (!isReadTrashOnly) persistence.updateNote { it.copy(color = color.key) } - } - - /** Inserts a copy of the note, titled by [NoteCopyTitle]; returns its id, or -1 on failure. */ - suspend fun noteDuplicate(): Long = persistence.duplicate() - - /** Whether the note is a new one with nothing in it yet: nothing to copy, save or discard. */ - val isBlankDraft: Boolean get() = persistence.isBlankDraft - - /** Whether discarding would undo anything (see [NotePersistence.hasChanges]). */ - suspend fun hasChanges(): Boolean = persistence.hasChanges() - - /** Deletes the note from the database for good. */ - suspend fun noteDelete(): Boolean = persistence.delete() - - fun updateTitle(newTitle: String) { - persistence.updateNote { it.copy(title = newTitle) } - } - - /** - * Moves the note to tag [tagId]. The editor shows it at once; it is stored like a title - * change, by the autosave or the save on leaving, together with the editor's content. - */ - fun updateTagNote(tagId: Long) { - // "No tag" (0) is stored as null, its only representation. - persistence.updateNote { it.copy(tagId = tagId.takeIf { id -> id > 0L }) } - } - - /** Sets this note's own text size; the default in Settings stays as it is. */ - fun updateFontSize(size: Int) { - if (!isReadTrashOnly) persistence.updateNote { it.copy(textSize = size.coerceIn(MIN_TEXT_SIZE, MAX_TEXT_SIZE)) } - } - - /** Sets this note's own line spacing; the default in Settings stays as it is. */ - fun updateNoteLineSpacing(value: NoteLineSpacing) { - if (!isReadTrashOnly) persistence.updateNote { it.copy(lineSpacing = value.ordinal) } - } - - /** "Use default": the note follows the text size and line spacing of Settings again. */ - fun useDefaultText() { - if (!isReadTrashOnly) persistence.updateNote { it.copy(textSize = null, lineSpacing = null) } - } - - /** 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 - _lockEditor.value = !_lockEditor.value - } - - private fun canEdit(): Boolean = !isReadTrashOnly && !_lockEditor.value - - // --- blocks ---------------------------------------------------------------------------- - - /** Position of [block] in the editor, by identity; -1 when it is no longer there. */ - fun indexOfBlock(block: Block): Int = editor.blocks.indexOfFirst { it.id == block.id } - - /** The field of [block] (for a list: one of its items) took focus. */ - 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) - // 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) - editor.slash.dismiss() - } - - /** - * Adds a block of [blockType] after the focused block (or the last one the user touched). - * An empty first text block at that point is replaced instead. - */ - fun addBlock(blockType: BlockType) { - if (canEdit()) editor.addBlock(BlockFactory.createBlock(blockType)) - } - - /** Adds a block of [blockType] after position [afterIndex] and focuses it. */ - fun addBlockAfter(afterIndex: Int, blockType: BlockType) { - if (canEdit()) editor.addBlockAfter(afterIndex, BlockFactory.createBlock(blockType)) - } - - /** Focuses a trailing text block (the tap area below the last block). */ - fun addBlockToEnd() { - if (canEdit()) editor.addBlockToEnd() - } - - /** - * Removes [block] if [blockRemoveAction] allows it; see [BlockEditorState.removeBlock]. - * Does nothing in a locked or read-only editor. - */ - fun removeBlock(block: Block, blockRemoveAction: BlockRemoveAction, isReFocus: Boolean = true) { - if (canEdit()) editor.removeBlock(block, blockRemoveAction, isReFocus) - } - - fun replaceBlock(targetBlockIndex: Int, newBlock: Block): Boolean = - canEdit() && editor.replaceBlock(targetBlockIndex, newBlock) - - /** - * Sets the text of a text-bearing [block] and records it for undo/redo. The previous - * text is read from the block itself, so undo always restores what was really there. - */ - fun onBlockTextChanged(block: Block, newText: String) { - if (canEdit(block) && block is TextualBlock) editor.changeValue(block.id, block.text, newText, mergeable = true) - } - - /** Sets the items of a list [block] (typing, checking, adding or removing items). */ - fun onListItemsChanged(block: Block.ListBlock, newItems: List, mergeable: Boolean) { - if (!canEdit(block)) return - val step = if (mergeable) listTypingStep(block.items.value, newItems) else null - editor.changeValue(block.id, block.items, newItems, mergeable, step) - } - - // A block that left the note (converted, merged, undone) may still report a late edit from - // its field; it must not reach the history. - private fun canEdit(block: Block): Boolean = canEdit() && editor.indexOf(block.id) >= 0 - - /** - * 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) 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) - - /** A mark typed at the start of paragraph [block] ("# ", "- ", ...): it becomes that block. */ - 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) - - /** A line break in item [itemId] of list [block] (see listLineBreak). */ - fun onListLineBreak(block: Block.ListBlock, itemId: String, lineBreak: LineBreak): String? = - if (canEdit(block)) editor.listLineBreak(block.id, itemId, lineBreak) else null - - /** Backspace at the start of item [itemId] of list [block]. */ - fun onListBackspaceAtStart(block: Block.ListBlock, itemId: String): Boolean = - canEdit(block) && editor.listBackspaceAtStart(block.id, itemId) - - /** The cursor in [block]'s field moved to [offset]. */ - fun onCaretMoved(block: Block, offset: Int) = editor.selection.onCaret(block.id, offset) - - /** The cursor in item [itemId] of list [block] moved to [offset]. */ - fun onListItemCaret(block: Block, itemId: String, offset: Int) = - editor.selection.onItemCaret(block.id, itemId, offset) - - /** Sets the link of [block]; a link being edited ([editLink]) is committed with it. */ - fun onLinkChanged(block: Block.LinkBlock, newLink: LinkDataBlock) { - if (!canEdit()) return - _editingLinkIds.update { it - block.id } - editor.changeValue(block.id, block.block, newLink, mergeable = false) - } - - /** - * Puts a saved link back into URL entry, keeping its address to edit. The saved link stays - * in the block until the user commits a new one ([onLinkChanged]), so a save in the - * meantime (autosave, Back, the app going to the background) still stores it. - */ - fun editLink(block: Block.LinkBlock) { - if (!canEdit()) return - _editingLinkIds.update { it + block.id } - editor.selection.onInteraction(block.id) - } - - /** - * Applies toolbar [tool] to the block the user works on (see applyTool); [exact] for a - * specific heading level. - */ - fun applyTool(tool: BlockType, exact: Boolean = false) { - if (canEdit()) editor.applyTool(tool, exact) - } - - /** The title's "Next": the first block takes focus. */ - fun focusFirstBlock() { - if (canEdit()) editor.focusFirstBlock() - } - - /** The toolbar's "Show keyboard": back into the block the user was writing in. */ - fun focusWorkingBlock() { - if (canEdit()) editor.focusWorkingBlock() - } - - fun undo() { - if (canEdit()) editor.undo() - } - - fun redo() { - if (canEdit()) editor.redo() - } - - // --- toolbar: acts on the block the user last worked on -------------------------------- - - fun moveBlock(up: Boolean) { - if (canEdit()) editor.moveInteracted(up) - } - - fun canMoveBlock(up: Boolean): Boolean = canEdit() && editor.canMoveInteracted(up) - - /** Moves [block] one place [up] (or down): TalkBack's actions on a block. */ - fun moveBlockOf(block: Block, up: Boolean) { - if (canEdit(block)) editor.moveBlockOf(block.id, up) - } - - fun canRemoveInteractedBlock(): Boolean = canEdit() && editor.canRemoveInteracted() - - fun removeInteractedBlock() { - if (canEdit()) editor.removeInteracted() - } - - /** Locks or unlocks the checklist [block] (see toggleChecklistLock). */ - fun toggleChecklistLock(block: Block) { - if (canEdit(block)) editor.toggleChecklistLock(block.id) - } - - /** Duplicates the block the user works on, right after it. */ - fun duplicateInteractedBlock() { - if (canEdit()) editor.duplicateInteracted() - } - - /** Duplicates [block], right after it: TalkBack's and the block sheets' action. */ - fun duplicateBlock(block: Block) { - if (canEdit(block)) editor.duplicateBlockOf(block.id) - } - - // --- find in this note: read-only, so it works in a locked or trashed note too --------------- - - fun openFind() = editor.find.open() - - fun closeFind() = editor.find.close() - - fun onFindQueryChanged(query: String) = editor.find.search(query, editor.blocks) - - fun findNext() = editor.find.next() - - fun findPrevious() = editor.find.previous() - - // --- templates ------------------------------------------------------------------------- - - /** - * Whether to offer "Start from template": a brand-new note that is still completely empty, - * and not in Simple Edit (it never shows block tools). Following the text, so it goes away - * with the first character and comes back if undo makes the note blank again, but not once - * the note has been saved (its stored id is set): from then on it is an existing note. - */ - val templatesOffered: StateFlow = combine( - persistence.state.map { it.note.title.isEmpty() && it.note.id <= 0L }.distinctUntilChanged(), - editor.contentChanges.map { editor.isBlankNote() }.onStart { emit(editor.isBlankNote()) }, - simpleEdit, - _lockEditor, - ) { blankUnsaved, blankBody, simple, locked -> isNewNote && blankUnsaved && blankBody && !simple && !locked } - .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), false) - - /** Starts the blank note from [blocks] (a template's, see NoteTemplate.blocks). */ - fun applyTemplate(blocks: List) { - if (canEdit() && !simpleEdit.value) editor.applyTemplate(blocks) - } - - /** Whether the block the user works on is the first, the last or in between. */ - fun getMoveBlockState(): Int = editor.interactedBlockPosition() -} - -/** The find bar refreshes its matches this long after the text stops changing. */ -private const val FIND_REFRESH_MS = 100L - -private const val MIN_TEXT_SIZE = 10 -private const val MAX_TEXT_SIZE = 32 - -/** How long state shared with the UI is kept after the last collector leaves. */ -private const val STOP_TIMEOUT_MS = 5_000L diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ListLoadErrors.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ListLoadErrors.kt deleted file mode 100644 index 68a6625c..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ListLoadErrors.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import com.pasich.encly.R -import com.pasich.encly.core.common.LoadError -import com.pasich.encly.core.common.UiText - -/** The read errors the list screens show. */ -internal object ListLoadErrors { - val NOTES = LoadError( - title = UiText.of(R.string.notes_load_failed_title), - message = UiText.of(R.string.notes_load_failed_desc), - ) - - val TAGS = LoadError( - title = UiText.of(R.string.tags_load_failed_title), - message = UiText.of(R.string.notes_load_failed_desc), - ) -} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockNowViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockNowViewModel.kt deleted file mode 100644 index 5c73b9b3..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockNowViewModel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.lifecycle.ViewModel -import com.pasich.encly.core.security.SessionLockManager -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -/** The "Lock now" action of the notes screen. */ -@HiltViewModel -class LockNowViewModel @Inject constructor(private val sessionLockManager: SessionLockManager) : ViewModel() { - - /** Closes the vault; the app then shows the lock screen. */ - fun lockNow() = sessionLockManager.lockNow() -} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt deleted file mode 100644 index e5f95df9..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.pasich.encly.core.di.ApplicationScope -import com.pasich.encly.core.security.AuthStrategy -import com.pasich.encly.core.security.SecurityManager -import com.pasich.encly.core.security.SensitiveDataCleaner -import com.pasich.encly.core.security.SessionLockManager -import com.pasich.encly.core.security.VaultUnlockResult -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -/** - * BACKGROUNDED: the credential was right, but the app left the foreground before the vault - * finished opening, so SessionLockManager closed it again. The lock screen stays; no error. - * KEY_LOST: the device-bound PIN key is gone; only the recovery phrase (or fingerprint) unlocks. - */ -enum class PinUnlockResult { SUCCESS, WRONG_PIN, LOCKED_OUT, KEY_LOST, DB_ERROR, BACKGROUNDED } -enum class SeedUnlockResult { SUCCESS, WRONG_SEED, DB_ERROR, BACKGROUNDED } - -@HiltViewModel -@Suppress("TooManyFunctions") // One small entry point per unlock factor and capability. -class LockViewModel @Inject constructor( - private val securityManager: SecurityManager, - private val sessionLockManager: SessionLockManager, - /** Where the rest of a wipe-PIN erase runs once the vault is open; outlives this screen. */ - @param:ApplicationScope private val appScope: CoroutineScope, -) : ViewModel() { - - private val _busy = MutableStateFlow(false) - val busy: StateFlow = _busy.asStateFlow() - - /** The open biometric prompt, if any (see [BiometricPromptGuard]). */ - private val biometricPrompt = BiometricPromptGuard() - - /** A biometric prompt is open; a new request is ignored meanwhile. */ - val biometricInFlight: Boolean get() = biometricPrompt.inFlight - - fun strategy(): AuthStrategy = securityManager.authStrategy() - - fun isSeedStrategy(): Boolean = strategy() == AuthStrategy.SEED_PHRASE || - strategy() == AuthStrategy.SEED_PHRASE_BIOMETRIC - - fun biometricEnabled(): Boolean = securityManager.isBiometricEnabled() - fun biometricAvailable(): Boolean = securityManager.biometricAvailable() - fun recoveryAvailable(): Boolean = securityManager.hasRecoverySeed() - fun lockoutRemainingMillis(): Long = securityManager.pinLockoutRemainingMillis() - - /** Whether the session is closed (again); an unlock reveal then must not open Home. */ - fun isSessionLocked(): Boolean = sessionLockManager.locked.value - - /** - * Whether the note remembered at the re-lock under [savedEpoch] may be reopened: only in the - * vault it was in. After a wipe-PIN erase (here or on another lock screen) it does not - * exist, and an empty editor would give the erase away. - */ - fun canReopenNote(savedEpoch: Long?): Boolean = savedEpoch != null && savedEpoch == securityManager.eraseEpoch() - - /** Unlocks with [pin], which is wiped. */ - fun authenticatePin(pin: CharArray, onResult: (PinUnlockResult) -> Unit) { - launchUnlock(onResult) { - val result = withContext(Dispatchers.Default) { - when (securityManager.unlockWithPin(pin)) { - VaultUnlockResult.SUCCESS -> PinUnlockResult.SUCCESS - VaultUnlockResult.INVALID_CREDENTIAL -> PinUnlockResult.WRONG_PIN - VaultUnlockResult.LOCKED_OUT -> PinUnlockResult.LOCKED_OUT - VaultUnlockResult.PIN_KEY_LOST -> PinUnlockResult.KEY_LOST - VaultUnlockResult.DB_ERROR -> PinUnlockResult.DB_ERROR - } - } - // The rest of a wipe-PIN erase (if that was one) runs while the vault is revealed. - if (result == PinUnlockResult.SUCCESS) appScope.launch { securityManager.completePendingWipe() } - publish(result == PinUnlockResult.SUCCESS, result, PinUnlockResult.BACKGROUNDED) - } - } - - /** Unlocks with the recovery words (lower case, single spaces); [chars] is wiped afterwards. */ - fun authenticateSeed(chars: CharArray, onResult: (SeedUnlockResult) -> Unit) { - launchUnlock(onResult) { - val result = withContext(Dispatchers.Default) { - try { - when (securityManager.unlockWithSeed(chars)) { - VaultUnlockResult.SUCCESS -> SeedUnlockResult.SUCCESS - VaultUnlockResult.INVALID_CREDENTIAL -> SeedUnlockResult.WRONG_SEED - else -> SeedUnlockResult.DB_ERROR - } - } finally { - SensitiveDataCleaner.clear(chars) - } - } - publish(result == SeedUnlockResult.SUCCESS, result, SeedUnlockResult.BACKGROUNDED) - } - } - - /** - * Unlocks with the biometric slot. Ignored while a prompt is open; an answer that arrives - * after the prompt's activity was destroyed (the screen it would report to is gone) is dropped - * and its key wiped. - */ - fun authenticateBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { - biometricPrompt.launch(activity) { release -> - securityManager.requestBiometricKey(activity) { dek -> - if (!release()) { - dek?.let(SensitiveDataCleaner::clear) - return@requestBiometricKey - } - if (dek == null) { - onResult(false) - return@requestBiometricKey - } - launchUnlock(onResult) { - val ok = withContext(Dispatchers.IO) { - try { - securityManager.unlockWithRawKey(dek) - } finally { - SensitiveDataCleaner.clear(dek) - } - } - publish(ok, ok, false) - } - } - } - } - - /** - * Runs an unlock to its end even if this ViewModel is cleared meanwhile (the KDF and the - * database open cannot be interrupted anyway). An unlock that completes for a cleared - * ViewModel has no screen left to navigate into the vault, so it is closed again - * ([SessionLockManager.onUnlockAbandoned]) instead of staying open in the background. - */ - private fun launchUnlock(onResult: (T) -> Unit, unlock: suspend UnlockScope.() -> T) { - val job = viewModelScope.launch { - val owner = coroutineContext.job - _busy.value = true - val result = withContext(NonCancellable) { UnlockScope(owner).unlock() } - // A cleared screen gets no result: the unlock was already closed again. - ensureActive() - _busy.value = false - onResult(result) - } - job.invokeOnCompletion { _busy.value = false } - } - - /** What an unlock body can see: whether its ViewModel is still alive. */ - private inner class UnlockScope(private val owner: Job) { - /** - * The unlock [succeeded]: publish it if the app is in front and the screen still - * exists, otherwise close the vault again and report [backgrounded]. - */ - fun publish(succeeded: Boolean, result: T, backgrounded: T): T = when { - !succeeded -> result - - !owner.isActive -> { - sessionLockManager.onUnlockAbandoned() - backgrounded - } - - sessionLockManager.onUnlocked() -> result - - else -> backgrounded - } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LossRecoveryViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LossRecoveryViewModel.kt deleted file mode 100644 index d7d76988..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LossRecoveryViewModel.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.lifecycle.ViewModel -import com.pasich.encly.core.security.SecurityManager -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -/** - * Full-reset path for unrecoverable local vault corruption/loss. - */ -@HiltViewModel -class LossRecoveryViewModel @Inject constructor(private val securityManager: SecurityManager) : ViewModel() { - - fun wipeAllData() { - securityManager.wipeAndReset() - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/MainListStateViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/MainListStateViewModel.kt deleted file mode 100644 index 7e738469..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/MainListStateViewModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.pasich.encly.domain.repository.SettingsRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class MainListStateViewModel @Inject constructor(private val settingsRepository: SettingsRepository) : ViewModel() { - - val isGridNoteList: StateFlow = - settingsRepository.isGridNoteList.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) - - fun updateGridNoteList() { - val value = !isGridNoteList.value - viewModelScope.launch { settingsRepository.setGridNoteList(value) } - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt deleted file mode 100644 index 62b24f6b..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ /dev/null @@ -1,240 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import android.net.Uri -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.pasich.encly.R -import com.pasich.encly.core.AppLogger -import com.pasich.encly.core.backup.BackupError -import com.pasich.encly.core.backup.BackupException -import com.pasich.encly.core.common.AppStrings -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.export.MarkdownDocuments -import com.pasich.encly.data.export.MarkdownExport -import com.pasich.encly.data.export.MarkdownLabels -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -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.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import javax.inject.Inject - -/** What a Markdown export writes. */ -sealed interface MarkdownExportTarget { - /** Every note that is not in the trash, and the tasks, as a `.zip` of `.md` files. */ - data object AllData : MarkdownExportTarget - - /** One note, as one `.md` file. */ - data class SingleNote(val noteId: Long) : MarkdownExportTarget -} - -/** - * The plaintext export of notes as Markdown. The file is **not encrypted**, so the flow is: a - * warning ([BackupStep.ConfirmPlainExport]), then the same re-authentication every export uses - * ([ReauthFlow]: the PIN, or biometrics when enabled), then the system save dialog, then the - * content streams straight into the document the user created there. - * - * - The vault is read only after the save dialog returns, and only through the session that was - * authenticated: a vault that re-locked meanwhile fails instead of exporting. - * - It exports the vault that is open, whichever it is (the decoy vault exports what it holds). - * - Nothing plaintext is held while another app (the save dialog) is in front, and no copy is - * made anywhere else: no temp file, no cache, no share intent. - */ -@HiltViewModel -@Suppress("TooManyFunctions") // One function per step of the flow, and the helpers of the write. -class MarkdownExportViewModel @Inject constructor( - private val securityManager: SecurityManager, - private val sessionLockManager: SessionLockManager, - private val store: VaultDataStore, - private val documents: MarkdownDocuments, - private val strings: AppStrings, -) : ViewModel() { - - private val state = BackupFlowState(viewModelScope, null, ::dropPending) - val uiState: StateFlow = state.ui.asStateFlow() - - val reauthFlow = ReauthFlow(state, securityManager, onAuthenticated = ::proceed) - - private var target: MarkdownExportTarget? = null - - /** The vault session that was authenticated; the export reads this one and no other. */ - private var session: VaultDataStore? = null - - init { - // A re-lock drops what the flow holds at once, even while the app is in the background. - viewModelScope.launch { - sessionLockManager.locked.collect { locked -> - if (locked) { - dropPending() - state.ui.update { it.copy(step = BackupStep.Idle, busy = false) } - } - } - } - } - - /** Starts an export of [target]: first the warning that the file will not be encrypted. */ - fun start(target: MarkdownExportTarget) { - dropPending() - this.target = target - state.go(BackupStep.ConfirmPlainExport) - } - - /** "Continue" on the warning: re-authenticate before anything is read. */ - fun confirmWarning() { - if (state.step != BackupStep.ConfirmPlainExport) return - val biometric = securityManager.canReauthWithBiometric() - state.go(BackupStep.Reauth(BackupAction.EXPORT_MARKDOWN, biometric)) - } - - private fun proceed(action: BackupAction) { - val target = target - // A PIN that verified after the flow was cancelled (or restarted) must not carry on: only the - // re-authentication of the export now waiting for it counts. - val waiting = (state.step as? BackupStep.Reauth)?.action == BackupAction.EXPORT_MARKDOWN - if (action != BackupAction.EXPORT_MARKDOWN || target == null || !waiting) return - try { - // Tied to this opening of the vault: after a re-lock it fails instead of reading. - session = store.bindToCurrentSession() - } catch (_: RuntimeException) { - state.fail(BackupError.VAULT_UNAVAILABLE) - return - } - state.go( - when (target) { - MarkdownExportTarget.AllData -> BackupStep.PickMarkdownTarget( - datedName("encly-notes", "zip"), - zip = true, - ) - - // A date only, never the title: the name shows in the system file dialog and its Recents. - is MarkdownExportTarget.SingleNote -> BackupStep.PickMarkdownTarget( - datedName("encly-note", "md"), - zip = false, - ) - }, - ) - } - - /** - * The save dialog is another app. It keeps the vault open for a short while (like the import - * dialog does), because the notes are read when it returns. - */ - fun onSystemPickerLaunched() { - sessionLockManager.allowSystemPicker() - state.go(BackupStep.Idle) - } - - /** No app on this device handles the save dialog. */ - fun onSystemPickerUnavailable() { - sessionLockManager.endSystemPicker() - state.finish(BackupMessage.Text(R.string.backup_error_no_picker)) - } - - /** The save dialog returned: [uri] is the new, empty document, or null when it was cancelled. */ - fun onTarget(uri: Uri?) { - sessionLockManager.endSystemPicker() - val bound = session - val what = target - dropPending() - if (uri == null) return - if (bound == null || what == null) { - state.launchBusy { discard(uri) } - return - } - state.launchBusy { - try { - export(bound, what, uri) - state.finish(BackupMessage.Text(R.string.markdown_export_done)) - } catch (e: BackupException) { - state.fail(e.error) - } catch (e: CancellationException) { - throw e - } catch (_: RuntimeException) { - // The vault closed under the export (a re-lock): nothing readable was written. - AppLogger.w(TAG, "Markdown export failed") - discard(uri) - state.fail(BackupError.VAULT_UNAVAILABLE) - } - } - } - - private suspend fun export(bound: VaultDataStore, what: MarkdownExportTarget, uri: Uri) { - val snapshot = when (what) { - MarkdownExportTarget.AllData -> bound.snapshot() - - // Only that note and its tag are read, not the whole vault. - is MarkdownExportTarget.SingleNote -> bound.snapshotOfNote(what.noteId) - } - val labels = labels() - // A re-lock that landed after the read must not be followed by a write. - bound.assertCurrentSession() - // Not cancelled by a re-lock clearing this screen: a half-written document would look like - // a complete export. - withContext(NonCancellable + Dispatchers.IO) { - documents.write(uri) { out -> - when (what) { - MarkdownExportTarget.AllData -> MarkdownExport.writeAll(snapshot, labels, out) - - is MarkdownExportTarget.SingleNote -> - if (!MarkdownExport.writeNote(snapshot, what.noteId, labels, out)) { - throw BackupException(BackupError.IO) - } - } - } - } - } - - /** Removes the empty document the save dialog created, off the main thread. */ - private suspend fun discard(uri: Uri) = withContext(NonCancellable + Dispatchers.IO) { documents.discard(uri) } - - fun cancel() { - sessionLockManager.endSystemPicker() - dropPending() - state.go(BackupStep.Idle) - } - - fun clearMessage() { - state.ui.update { it.copy(message = null) } - } - - override fun onCleared() { - dropPending() - super.onCleared() - } - - private fun dropPending() { - target = null - session = null - } - - private fun labels() = MarkdownLabels( - untitled = strings.get(R.string.untitled), - unreadable = strings.get(R.string.note_preview_unreadable), - tasks = strings.get(R.string.main_drawer_tasks), - priorities = mapOf( - HIGH to strings.get(R.string.priority_high), - MEDIUM to strings.get(R.string.priority_medium), - LOW to strings.get(R.string.priority_low), - ), - ) - - /** "encly-notes-2026-10-25.zip": a date only, never a title. */ - private fun datedName(prefix: String, extension: String): String = - "$prefix-${SimpleDateFormat("yyyy-MM-dd", Locale.ROOT).format(Date())}.$extension" - - private companion object { - const val TAG = "MarkdownExport" - const val HIGH = 2 - const val MEDIUM = 1 - const val LOW = 0 - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt deleted file mode 100644 index de7e95f3..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.pasich.encly.core.common.asLoadState -import com.pasich.encly.core.common.valueOrNull -import com.pasich.encly.core.di.IoDispatcher -import com.pasich.encly.domain.enums.NoteSortOption -import com.pasich.encly.domain.model.NoteListItem -import com.pasich.encly.domain.usecase.note.ObserveNotesUseCase -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject - -/** - * Searches every note that is not in the trash and whose tag is not hidden, whatever tag chip - * the note list shows, and returns every match. The notes' text is parsed once per database - * change (in [ObserveNotesUseCase]); a keystroke only filters, after a short pause. - */ -@HiltViewModel -class NoteSearchViewModel @Inject constructor( - observeNotesUseCase: ObserveNotesUseCase, - @IoDispatcher dispatcher: CoroutineDispatcher, -) : ViewModel() { - - private val _query = MutableStateFlow("") - val query: StateFlow = _query.asStateFlow() - - private val notes = observeNotesUseCase(tagId = null, sortOption = NoteSortOption.UPDATED_DESC) - // A read failure is shown by the note list behind the search; here it only means - // no results, never a crash. - .asLoadState(ListLoadErrors.NOTES) - .mapNotNull { it.valueOrNull() } - - @OptIn(FlowPreview::class) - val results: StateFlow = - combine(_query.debounce { if (it.isBlank()) 0L else SEARCH_DEBOUNCE_MS }, notes) { query, all -> - val needle = query.trim() - SearchResults( - query = needle, - notes = if (needle.isEmpty()) emptyList() else all.filter { it.matches(needle) }, - ) - } - .flowOn(dispatcher) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), SearchResults()) - - fun onQueryChange(query: String) { - _query.value = query - } - - fun clear() { - _query.value = "" - } - - /** The notes matching [query]; [query] is the trimmed text they were searched for. */ - data class SearchResults(val query: String = "", val notes: List = emptyList()) - - private companion object { - const val SEARCH_DEBOUNCE_MS = 200L - const val STOP_TIMEOUT_MS = 5_000L - } -} 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 deleted file mode 100644 index e463734b..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/PreloadedViewModelOwner.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.compose.runtime.Composable -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel - -/** A screen-test owner whose ViewModels are already present in its store. */ -interface PreloadedViewModelOwner : ViewModelStoreOwner - -/** - * AndroidX Hilt 1.4 creates its Activity-bound factory before checking the owner store. Screen - * tests use a plain Activity and preloaded ViewModels, so they can read that store directly. - * App owners still use the regular Hilt factory. - */ -@Composable -inline fun enclyViewModel(): VM { - val owner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner provided" } - return if (owner is PreloadedViewModelOwner) { - viewModel(viewModelStoreOwner = owner) - } else { - hiltViewModel(viewModelStoreOwner = owner) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt deleted file mode 100644 index a0750fe6..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import com.pasich.encly.data.model.Tag -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import javax.inject.Inject -import javax.inject.Singleton - -/** The tag chip the note list is filtered by, shared by the tag row and the note list. */ -@Singleton -class SelectedTagHolder @Inject constructor() { - private val _selectedTagFlow = MutableSharedFlow(replay = 1) - val selectedTagFlow: SharedFlow = _selectedTagFlow.asSharedFlow() - - fun selectTag(tag: Tag) { - _selectedTagFlow.tryEmit(tag) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt deleted file mode 100644 index 8ee0678c..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.pasich.encly.domain.model.ThemeSettings -import com.pasich.encly.domain.repository.SettingsRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject - -@HiltViewModel -class ThemeViewModel @Inject constructor(settingsRepository: SettingsRepository) : ViewModel() { - - // MainActivity holds the splash until the theme was read once, so the first frame starts - // from the stored theme instead of flashing the defaults. The main thread never blocks on - // the DataStore: the defaults are only a fallback if that read has not happened. - val themeSettingsFlow: StateFlow = - settingsRepository.themeSettingsFlow.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = settingsRepository.latestThemeSettings ?: ThemeSettings(), - ) -} 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 deleted file mode 100644 index eccfaa47..00000000 --- a/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.pasich.encly.ui.theme - -import android.app.Activity -import android.os.Build -import android.view.WindowManager -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.toArgb -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.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 = enclyViewModel(), content: @Composable () -> Unit) { - val themeSettings by themeViewModel.themeSettingsFlow.collectAsStateWithLifecycle() - val isDarkTheme = themeSettings.isDark(isSystemInDarkTheme()) - - val activity = LocalView.current.context as Activity - WindowCompat.getInsetsController(activity.window, activity.window.decorView).apply { - isAppearanceLightStatusBars = !isDarkTheme - } - // Defense in depth: MainActivity sets this before the first frame, and the theme - // reasserts it for the Compose window. This is intentionally not user-configurable. - activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - - EnclyTheme(settings = themeSettings, dark = isDarkTheme) { - // The window behind Compose (activity recreation, IME and window transitions) follows - // the palette and the app's mode, not the static, system-night-only window_background. - val background = MaterialTheme.colorScheme.background.toArgb() - SideEffect { activity.window.setBackgroundDrawable(background.toDrawable()) } - content() - } -} - -/** - * The Encly theme for already resolved settings: the colour scheme of the palette in the given - * mode (or of the wallpaper when dynamic colour is on, Android 12+), typography from the font - * choice, shapes and spacing. Previews can use it without a ViewModel. - */ -@Composable -fun EnclyTheme(settings: ThemeSettings, dark: Boolean, content: @Composable () -> Unit) { - val colorScheme = if (settings.dynamic && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val context = LocalContext.current - val wallpaper = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - if (settings.palette == ThemePalette.MIDNIGHT) wallpaper.withTrueBlackSurfaces() else wallpaper - } else { - colorSchemeFor(settings.palette, dark) - } - val fonts = settings.fontStyle.fontSet() - val typography = remember(fonts) { appTypography(fonts) } - val enclyTypography = remember(fonts) { enclyTypography(fonts) } - - CompositionLocalProvider( - LocalEnclyColors provides enclyColorsFor(settings.palette, dark), - LocalEnclyTypography provides enclyTypography, - LocalSpacing provides EnclySpacing(), - ) { - MaterialTheme( - colorScheme = colorScheme, - typography = typography, - shapes = EnclyShapes, - content = content, - ) - } -} - -/** Encly tokens that Material's theme does not carry. */ -object EnclyTheme { - val colors: EnclyColors - @Composable - @ReadOnlyComposable - get() = LocalEnclyColors.current - - val typography: EnclyTypography - @Composable - @ReadOnlyComposable - get() = LocalEnclyTypography.current - - val spacing: EnclySpacing - @Composable - @ReadOnlyComposable - get() = LocalSpacing.current -} diff --git a/app/src/main/java/com/pasich/encly/ui/theme/Type.kt b/app/src/main/java/com/pasich/encly/ui/theme/Type.kt deleted file mode 100644 index fe45b7ee..00000000 --- a/app/src/main/java/com/pasich/encly/ui/theme/Type.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.pasich.encly.ui.theme - -import androidx.annotation.FontRes -import androidx.compose.material3.Typography -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.text.ExperimentalTextApi -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontVariation -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import com.pasich.encly.R -import com.pasich.encly.domain.model.FontStyleType - -// Fonts are bundled OFL files in res/font (licenses in /licenses/fonts). Encly must not fetch -// fonts through the Google Play Services font provider: that is a network request to Google, -// breaks the "fully offline" promise and leaves devices without GMS with no fonts. -// Variable fonts cover several weights from one file via FontVariation settings (API 26+). - -val playfair = variableFontFamily(R.font.playfair_display_variable) - -val sourceSans = variableFontFamily(R.font.source_sans3_variable) - -val ibmPlex = variableFontFamily(R.font.ibm_plex_sans_variable) - -// No SemiBold file is bundled; a 600 request resolves to the nearest face (Bold). -val poppins = FontFamily( - Font(R.font.poppins_regular, FontWeight.Normal), - Font(R.font.poppins_medium, FontWeight.Medium), - Font(R.font.poppins_bold, FontWeight.Bold), -) - -@OptIn(ExperimentalTextApi::class) -private fun variableFontFamily(@FontRes resId: Int): FontFamily = FontFamily( - listOf(FontWeight.Normal, FontWeight.Medium, FontWeight.SemiBold, FontWeight.Bold).map { weight -> - Font( - resId = resId, - weight = weight, - variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)), - ) - }, -) - -/** The two families a font choice (Settings → Appearance → Font) is made of. */ -@Immutable -data class FontSet(val display: FontFamily, val body: FontFamily) - -/** Editorial, Modern and Technical (design spec §2.2). */ -fun FontStyleType.fontSet(): FontSet = when (this) { - FontStyleType.COZY_EDITOR -> FontSet(display = playfair, body = sourceSans) - FontStyleType.MODERN_SIMPLE -> FontSet(display = poppins, body = sourceSans) - FontStyleType.TECH_MINIMAL -> FontSet(display = ibmPlex, body = ibmPlex) -} - -private fun style( - family: FontFamily, - weight: FontWeight, - size: Int, - lineHeight: Int, - tracking: TextUnit = 0.em, - fontStyle: FontStyle = FontStyle.Normal, -) = TextStyle( - fontFamily = family, - fontWeight = weight, - fontStyle = fontStyle, - fontSize = size.sp, - lineHeight = lineHeight.sp, - letterSpacing = tracking, -) - -/** - * The Material 3 type scale (design spec §2.3). Headings follow the chosen display family, text - * the chosen body family. The overline ([Typography.labelSmall]) is always IBM Plex Sans. - */ -fun appTypography(fonts: FontSet): Typography { - val d = fonts.display - val b = fonts.body - val semi = FontWeight.SemiBold - return Typography( - displayLarge = style(d, semi, size = 44, lineHeight = 48, tracking = (-0.02).em), - displayMedium = style(d, semi, size = 40, lineHeight = 44, tracking = (-0.02).em), - displaySmall = style(d, semi, size = 34, lineHeight = 40, tracking = (-0.01).em), - headlineLarge = style(d, semi, size = 32, lineHeight = 36, tracking = (-0.01).em), - headlineMedium = style(d, semi, size = 30, lineHeight = 34, tracking = (-0.01).em), - headlineSmall = style(d, semi, size = 28, lineHeight = 32), - titleLarge = style(d, semi, size = 21, lineHeight = 26), - titleMedium = style(d, semi, size = 19, lineHeight = 24), - titleSmall = style(b, semi, size = 17, lineHeight = 22), - bodyLarge = style(b, FontWeight.Normal, size = 17, lineHeight = 26), - bodyMedium = style(b, FontWeight.Normal, size = 15, lineHeight = 21), - bodySmall = style(b, FontWeight.Normal, size = 14, lineHeight = 20), - labelLarge = style(b, semi, size = 16, lineHeight = 22), - labelMedium = style(b, semi, size = 14, lineHeight = 20), - labelSmall = style(ibmPlex, FontWeight.Medium, size = 12, lineHeight = 16, tracking = 0.08.em), - ) -} - -/** Text styles outside the Material scale (design spec §2.3, second table). */ -@Immutable -data class EnclyTypography( - val buttonPrimary: TextStyle, - val chip: TextStyle, - val stepLabel: TextStyle, - val dataLarge: TextStyle, - val dataIndex: TextStyle, - val dataSmall: TextStyle, - val keypadDigit: TextStyle, - val phraseInput: TextStyle, - val quote: TextStyle, - val wordmark: TextStyle, - val meta: TextStyle, - val heroSub: TextStyle, - /** Dialog titles: headlineMedium sized down to 24/30. */ - val dialogTitle: TextStyle, - /** Sub-screen top bars: headlineSmall at 26. */ - val subScreenTitle: TextStyle, - /** Theme swatch labels, 13/18; the selected one in bold. */ - val swatchLabel: TextStyle, - /** The "Aa" sample of a font choice, in that choice's display family. */ - val fontSample: TextStyle, -) - -/** Data styles (IBM Plex Sans) and the wordmark (Playfair Display) ignore the font choice. */ -fun enclyTypography(fonts: FontSet): EnclyTypography { - val b = fonts.body - val plex = FontWeight.Medium - return EnclyTypography( - buttonPrimary = style(b, FontWeight.SemiBold, size = 17, lineHeight = 22, tracking = 0.01.em), - chip = style(b, FontWeight.SemiBold, size = 15, lineHeight = 20), - stepLabel = style(ibmPlex, plex, size = 12, lineHeight = 16, tracking = 0.06.em), - dataLarge = style(ibmPlex, plex, size = 18, lineHeight = 24, tracking = 0.01.em), - dataIndex = style(ibmPlex, plex, size = 13, lineHeight = 16), - dataSmall = style(ibmPlex, plex, size = 12, lineHeight = 16), - keypadDigit = style(ibmPlex, plex, size = 26, lineHeight = 32), - phraseInput = style(ibmPlex, FontWeight.Normal, size = 17, lineHeight = 26), - // No italic Playfair file is bundled; Compose synthesises the slant. - quote = style(fonts.display, plex, size = 19, lineHeight = 27, fontStyle = FontStyle.Italic), - wordmark = style(playfair, FontWeight.Bold, size = 20, lineHeight = 24, tracking = (-0.01).em), - meta = style(b, FontWeight.Normal, size = 13, lineHeight = 18), - heroSub = style(b, FontWeight.Normal, size = 18, lineHeight = 27), - dialogTitle = style(fonts.display, FontWeight.SemiBold, size = 24, lineHeight = 30), - subScreenTitle = style(fonts.display, FontWeight.SemiBold, size = 26, lineHeight = 32), - swatchLabel = style(b, FontWeight.Medium, size = 13, lineHeight = 18), - fontSample = style(fonts.display, FontWeight.SemiBold, size = 24, lineHeight = 28), - ) -} - -val LocalEnclyTypography = staticCompositionLocalOf { enclyTypography(FontStyleType.DEFAULT.fontSet()) } diff --git a/app/src/main/java/com/pasich/encly/utils/DataFormater.kt b/app/src/main/java/com/pasich/encly/utils/DataFormater.kt deleted file mode 100644 index 041b5395..00000000 --- a/app/src/main/java/com/pasich/encly/utils/DataFormater.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.pasich.encly.utils - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * The locale of the current (per-app) configuration. Unlike [Locale.getDefault] it always - * follows the language picked in Settings, on every API level. - */ -@Composable -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 -fun rememberDateTimeFormat(): DateFormat { - val locale = currentLocale() - return remember(locale) { - DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, locale) - } -} - -/** "Today 15:04", "Yesterday 15:04", or a localized medium date with time. */ -@Composable -fun formatNoteDate(date: Date = Date()): String { - val locale = currentLocale() - val timeFormat = remember(locale) { DateFormat.getTimeInstance(DateFormat.SHORT, locale) } - val dateTimeFormat = remember(locale) { - DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale) - } - return when (relativeDay(date)) { - RelativeDay.TODAY -> stringResource(R.string.date_today_at, timeFormat.format(date)) - RelativeDay.YESTERDAY -> stringResource(R.string.date_yesterday_at, timeFormat.format(date)) - RelativeDay.OTHER -> dateTimeFormat.format(date) - } -} - -/** - * The date on a note card: "Today", "Yesterday", then day and month ("18 Sep"), with the year - * only when it is not this year (design spec §4.3). - */ -@Composable -fun formatCardDate(date: Date): String { - val locale = currentLocale() - val dayMonth = remember(locale) { SimpleDateFormat(bestPattern(locale, "dMMM"), locale) } - val dayMonthYear = remember(locale) { SimpleDateFormat(bestPattern(locale, "dMMMy"), locale) } - return when (relativeDay(date)) { - RelativeDay.TODAY -> stringResource(R.string.date_today) - RelativeDay.YESTERDAY -> stringResource(R.string.date_yesterday) - RelativeDay.OTHER -> if (isSameYear(date)) dayMonth.format(date) else dayMonthYear.format(date) - } -} - -/** The locale's own order for a date [skeleton] ("18 Sep" in English, "Sep 18" in some locales). */ -private fun bestPattern(locale: Locale, skeleton: String): String = - android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton) diff --git a/app/src/main/java/com/pasich/encly/utils/DeviceCapabilities.kt b/app/src/main/java/com/pasich/encly/utils/DeviceCapabilities.kt deleted file mode 100644 index 343fb823..00000000 --- a/app/src/main/java/com/pasich/encly/utils/DeviceCapabilities.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.pasich.encly.utils - -import android.content.Context -import android.os.Build -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class DeviceCapabilities @Inject constructor(@param:ApplicationContext private val context: Context) { - - fun supportsDynamicColors(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -} diff --git a/app/src/main/java/com/pasich/encly/utils/NotesTextFormatter.kt b/app/src/main/java/com/pasich/encly/utils/NotesTextFormatter.kt deleted file mode 100644 index da4efebe..00000000 --- a/app/src/main/java/com/pasich/encly/utils/NotesTextFormatter.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.pasich.encly.utils - -import com.pasich.encly.core.serialization.BlockConverter -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.dynamicBlocks.hasContent - -/** - * Utility class for formatting note text. - */ -object NotesTextFormatter { - - /** - * Plain text of a note's stored blocks, or null when they cannot be read (damaged, or - * written by a newer version). Never throws: list previews and search call it for every - * note, and one bad note must not take the whole list down. - */ - fun jsonToPlainText(json: String): String? { - if (json.isEmpty()) return "" - - // Use the existing converter to get blocks from JSON - return BlockConverter.jsonToBlocksOrNull(json)?.let(::blocksToPlainText) - } - - /** - * The text the user typed into [blocks], one block or list item per line, for search: no - * checkbox or number markers, no separator lines and no quotation marks, so a query such - * as "x" or "-" does not match every checklist or separator. - */ - fun blocksToSearchText(blocks: List): String = blocks.flatMap { block -> - when (block) { - is Block.TextBlock -> listOf(block.text.value) - is Block.HBlock -> listOf(block.text.value) - is Block.QuoteBlock -> listOf(block.text.value) - is Block.LinkBlock -> listOf(block.block.value.title, block.block.value.url) - is Block.ListBlock -> block.items.value.map { it.value } - is Block.SeparatorBlock -> emptyList() - } - }.filter { it.isNotBlank() }.joinToString("\n") - - /** - * Converts a list of blocks into plain text without Markdown formatting, one block or list item - * per line. Blank paragraphs (the blank lines a note keeps between its sections) are skipped and - * no empty line separates blocks, so a card's short excerpt never spends a line on nothing. - */ - fun blocksToPlainText(blocks: List): String { - val text = StringBuilder() - - blocks.filter { it.hasContent() }.forEach { block -> - when (block) { - is Block.TextBlock -> { - text.append(block.text.value) - text.append("\n") - } - - is Block.HBlock -> { - text.append(block.text.value) - text.append("\n") - } - - is Block.QuoteBlock -> { - // For quotes, add quotation marks instead of a Markdown marker - text.append("«${block.text.value}»") - text.append("\n") - } - - is Block.LinkBlock -> { - val linkData = block.block.value - // If there is a title, use it and append the URL in parentheses - if (linkData.title.isNotEmpty()) { - text.append("${linkData.title} (${linkData.url})") - } else { - text.append(linkData.url) - } - text.append("\n") - } - - is Block.SeparatorBlock -> { - text.append("---------------------") - text.append("\n") - } - - is Block.ListBlock -> { - val items = block.items.value - - if (items.isNotEmpty()) { - when (block.blockType) { - BlockType.LIST_CHECK -> { - // For checkboxes, use [x] or [ ] - items.forEach { item -> - val status = if (item.isCheck) "[x]" else "[ ]" - text.append("$status ${item.value}\n") - } - } - - BlockType.LIST_NUMBER -> { - // Leave numbered lists as they are - items.forEachIndexed { index, item -> - text.append("${index + 1}. ${item.value}\n") - } - } - - else -> { - // For bulleted lists, use a hyphen - items.forEach { item -> - text.append("- ${item.value}\n") - } - } - } - } - } - - else -> { - // Can do nothing here or log it - } - } - } - - return text.toString().trim() - } -} diff --git a/app/src/main/java/com/pasich/encly/utils/RelativeDay.kt b/app/src/main/java/com/pasich/encly/utils/RelativeDay.kt deleted file mode 100644 index 4c4d0c8e..00000000 --- a/app/src/main/java/com/pasich/encly/utils/RelativeDay.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.pasich.encly.utils - -import java.util.Calendar -import java.util.Date - -/** Where a timestamp falls relative to the current day, in the device time zone. */ -enum class RelativeDay { TODAY, YESTERDAY, OTHER } - -fun relativeDay(date: Date, now: Date = Date()): RelativeDay { - val target = Calendar.getInstance().apply { time = date } - val day = Calendar.getInstance().apply { time = now } - if (target.isSameDay(day)) return RelativeDay.TODAY - day.add(Calendar.DAY_OF_YEAR, -1) - return if (target.isSameDay(day)) RelativeDay.YESTERDAY else RelativeDay.OTHER -} - -private fun Calendar.isSameDay(other: Calendar): Boolean = get(Calendar.YEAR) == other.get(Calendar.YEAR) && - get(Calendar.DAY_OF_YEAR) == other.get(Calendar.DAY_OF_YEAR) - -/** Whether [date] falls in the same calendar year as [now]; a card date then leaves the year out. */ -fun isSameYear(date: Date, now: Date = Date()): Boolean { - val target = Calendar.getInstance().apply { time = date } - val current = Calendar.getInstance().apply { time = now } - return target.get(Calendar.YEAR) == current.get(Calendar.YEAR) -} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml deleted file mode 100644 index 9ff57d85..00000000 --- a/app/src/main/res/values-de/strings.xml +++ /dev/null @@ -1,656 +0,0 @@ - - - Über die App - Ein lokaler, verschlüsselter Ort für Notizen, Ideen und Aufgaben – ohne Cloud-Synchronisierung. - Datenschutz - Die Tresordaten werden lokal in einer verschlüsselten SQLCipher-Datenbank gespeichert - Häufige Fragen - Feedback - Einen Fehler melden oder eine Verbesserung auf GitHub vorschlagen - Warum es wichtig ist und wie du über Ko-fi hilfst - Kein Browser gefunden. Öffne den Link manuell: %1$s - Keine E-Mail-App gefunden - Datenschutzerklärung - Wie wir deine Daten speichern und schützen - App bewerten - Gib eine Bewertung im Google Play Store ab - Support und Kontakt - Entwickler kontaktieren - Wenn du Fragen oder Vorschläge hast, kannst du mir eine E-Mail schreiben. - - Weiter - - Hinzufügen - - Alle - - Erforderlicher Zugangsschlüssel. Tippe, um deine PIN zu ändern. - 6-stellige PIN - Anmeldung - - Zurück - - Die Änderung des biometrischen Schutzes wurde nicht bestätigt - Der biometrische Schlüssel konnte nicht erstellt werden - Bestätige deine Identität - Authentifizierung - Bestätige deine Biometrie - Schlüsselzugriff - Bestätige deine Biometrie, um den Schutz zu ändern - Sicherheitsbestätigung - Bestätige deine Biometrie, um auf den verschlüsselten Schlüssel zuzugreifen - Encly entsperren - PIN verwenden - Biometrische Authentifizierung ist nicht verfügbar - - Weitere Block-Werkzeuge - Aufzählung - Checkliste - Überschrift - Überschrift %1$d - Link - Liste - Checkliste sperren - Checkliste entsperren - Block nach unten verschieben - Block nach oben verschieben - Nummerierte Liste - Trennlinie - - Abbrechen - - - Möchtest du diese Notizen wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden. - Möchtest du den Papierkorb wirklich endgültig leeren? Das kann nicht rückgängig gemacht werden. - Papierkorb leeren? - - Leeren - - Schließen - - Einklappen - - Neues Label erstellen - - Heute %1$s - Gestern %1$s - - Löschen - Block löschen - Löschen - Label löschen - Dieses Label wird aus allen deinen Notizen entfernt. Die Notizen selbst werden nicht gelöscht. - - Löschen - Abbrechen - Möchtest du diese Notiz wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden. - Notiz löschen? - - Encly ist kostenlos und Open Source, ohne Werbung, Tracking und Konten. Spenden sind die einzige Einnahmequelle. - Entwicklung unterstützen - - Fertig - - Duplikat - - Dynamische Farben - - Bearbeiten - - Bearbeitung sperren - - Noch keine Notizen - Tippe auf „Neue Notiz“, um die erste zu schreiben. - Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. - - Link eingeben - - Die Datenbank konnte nicht geöffnet werden - - Ausklappen - - Inhalt konnte nicht geladen werden - - Encly speichert deine Änderungen und schließt nach der unter Einstellungen → Sicherheit gewählten Verzögerung (standardmäßig 15 Sekunden) die verschlüsselte Datenbank und löscht den Sitzungsschlüssel. Bildschirm aus oder Telefon sperren tut das sofort. Danach entsperrst du erneut. - Was passiert, wenn ich die App verlasse? - Nutze den Biometrie-Schalter unter Einstellungen → Sicherheit. Das Anlegen oder Entfernen des biometrischen Slots erfordert eine starke biometrische Authentifizierung. - Wie aktiviere oder deaktiviere ich die Biometrie? - Öffne Einstellungen → Sicherheit, bestätige deine aktuelle PIN und gib dann zweimal die neue ein. - Wie ändere ich meine PIN? - Encly erzeugt einen zufälligen 256-Bit-Datenbankschlüssel. Deine PIN schützt einen separaten AES-GCM-Entsperrslot, und die Biometrie nutzt einen Android-Keystore-Schlüssel, der bei jeder Verwendung eine Authentifizierung erfordert. - Wie ist der Datenbankschlüssel geschützt? - Tippe auf dem Sperrbildschirm auf „Zugang wiederherstellen“, gib deine 12 Wörter ein und lege eine neue PIN fest. Ohne Wiederherstellungsphrase gibt es keinen anderen Weg hinein: Niemand, auch nicht der Entwickler, kann sie zurücksetzen. - Was passiert, wenn ich meine PIN vergesse? - Öffne Einstellungen → Sprache und wähle eine der verfügbaren Sprachen oder „Systemstandard“, um der Sprache deines Geräts zu folgen. Die Änderung gilt sofort. - Wie ändere ich die Sprache? - Tippe auf dem Startbildschirm auf „Neue Notiz“. Die Leiste über der Tastatur fügt Überschriften, Checklisten, Aufzählungen, nummerierte Listen, Zitate, Links und Trennlinien ein. - Wie erstelle ich eine neue Notiz? - Sie ist ein Ersatzschlüssel aus 12 Wörtern. Sie entsperrt den Tresor, wenn du deine PIN vergisst, und ist der einzige Schlüssel zu deinen Backup-Dateien. Du erstellst sie bei der Einrichtung oder später unter Einstellungen → Sicherheit, falls du sie übersprungen hast. Bewahre sie auf Papier auf, offline. - Wofür ist die Wiederherstellungsphrase? - Öffne „Über die App“ und nutze die Feedback-Option oder schreibe dem Entwickler. Füge einem Fehlerbericht niemals deine Wiederherstellungsphrase, deine PIN oder private Notiztexte hinzu. - Wie melde ich einen Fehler? - Automatisch während du schreibst, und beim Verlassen des Editors wird die letzte Speicherung abgeschlossen. Schlägt eine Speicherung einmal fehl, zeigt der Editor „Nicht gespeichert“ in Rot. - Wie werden Änderungen gespeichert? - Notizen, Aufgaben, lokale Verschlüsselung und Zugangswiederherstellung – ohne Cloud-Synchronisierung. - Antworten auf die häufigsten Fragen - Nein. Der Bildschirmschutz ist immer aktiv: Encly blockiert Screenshots, Bildschirmaufnahmen und ungeschützte Vorschauen in der Liste der zuletzt verwendeten Apps. - Kann ich Screenshots von Encly machen? - Grundlagen - Sicherheit und Wiederherstellung - Einstellungen - Fehlerbehebung - Notizen, Labels und Aufgaben werden ausschließlich in der lokalen SQLCipher-Datenbank von Encly gespeichert. Es gibt keine Cloud-Synchronisierung und keine automatischen Backups: Ein verschlüsseltes Backup kannst du selbst unter Einstellungen → Backup exportieren, außerdem eine lesbare Markdown-Kopie, die nicht verschlüsselt ist. - Wo werden meine Daten gespeichert? - Labels gruppieren deine Notizen. Du kannst sie erstellen, umbenennen, ausblenden und löschen, per Ziehen neu ordnen und den Startbildschirm danach filtern. - Wie funktionieren Labels? - Aufgaben haben eine Beschreibung und eine Priorität. Du kannst sie nach Priorität filtern und als erledigt markieren. Encly zeigt Aufgabentexte nie in Systembenachrichtigungen an. - Was können Aufgaben? - Unter Einstellungen → Darstellung wählst du eines von fünf Designs, hellen, dunklen oder System-Modus, einen von drei Schriftstilen und die Textgröße. Ab Android 12 kann die Akzentfarbe auch vom Hintergrundbild kommen. - Wie ändere ich das Design? - Encly legt absichtlich nie eine leere Datenbank über einen verlorenen oder unlesbaren Tresor an. Bestätige das vollständige Löschen nicht, wenn du deine lokalen Daten noch brauchst. - Encly meldet einen beschädigten Tresor - - Textgröße - Zeilenabstand - Standard - Kompakt - Großzügig - Redaktionell - Modern - Technisch - - - Sprache wählen - Systemstandard - - Link hinzufügen - - Listeneintrag - - - Zurück zur PIN - Zugang wiederherstellen - Gib deine 12-Wörter-Wiederherstellungsphrase ein - Wiederherstellung - Gib deine 6-stellige PIN ein, um fortzufahren - Notizen entsperren - Wird entsperrt… - Zugang mit der Wiederherstellungsphrase wiederherstellen - Falsche PIN - Falsche Wiederherstellungsphrase - - Gedrückt halten, um alles zu löschen - Die verschlüsselten Daten oder die Verschlüsselungsschlüssel wurden offenbar verändert oder beschädigt, daher ist keine Wiederherstellung möglich.\n\nUm die App weiter zu nutzen, musst du sie zurücksetzen und die beschädigten Daten löschen. Dadurch werden alle gespeicherten Notizen und Einstellungen endgültig gelöscht. - Deine Daten sind leider beschädigt - Diese Daten stammen aus einer älteren Version - Encly hat Notizen gefunden, die mit einer Version vor 2.0 gespeichert wurden. Encly 2.0 nutzt ein neues verschlüsseltes Format und kann sie nicht öffnen; eine Umwandlung ist nicht möglich.\n\nUm die App zu nutzen, fang neu an. Dabei werden die alten Notizen und Einstellungen auf diesem Handy endgültig gelöscht. - Gedrückt halten, um neu anzufangen - - FAQ - Einstellungen - Entwickler unterstützen - Labels - Aufgaben - Papierkorb - - Weitere Optionen - - - Kein Label - - Neue Notiz - Die Notiz konnte nicht gelöscht werden - Beschreibung… - Alle Änderungen verwerfen - Die Änderungen konnten nicht verworfen werden - Die Kopie konnte nicht erstellt werden - Der Inhalt der Notiz konnte nicht gelesen werden. Die Bearbeitung ist deaktiviert, damit die Daten nicht beschädigt werden. - - %1$s (Kopie) - Die Notiz konnte nicht wiederhergestellt werden - Die Änderungen konnten nicht gespeichert werden - Die Notiz konnte nicht in den Papierkorb verschoben werden - - Die App konnte nicht initialisiert werden - Die Wiederherstellungsphrase konnte nicht erstellt werden - Der verschlüsselte Tresor konnte nicht erstellt werden - - Menü öffnen - - %1$d %% - - Gib die PIN zur Bestätigung erneut ein - 6-stellige PIN bestätigen - Bestätige deine aktuelle PIN, bevor du deinen Zugangsschlüssel änderst - Aktuelle PIN eingeben - Gib eine neue PIN zum Schutz deiner Notizen ein - PIN geändert - 6-stellige PIN erstellen - Die aktuelle PIN ist falsch - Ziffer löschen - Die PINs stimmen nicht überein. Bitte versuche es erneut. - Die PIN konnte nicht gespeichert werden - Die PIN konnte nicht aktualisiert werden. Bitte versuche es erneut. - - - Tippe, um mit dem Bearbeiten zu beginnen… - - Hoch - Niedrig - Mittel - Priorität wählen - - Zitat - Wiederholen - - Wiederherstellen - - Speichern - - - - - Suche löschen - - Fingerabdruck - Biometrisches Entsperren - Auf diesem Gerät nicht verfügbar - Kein Fingerabdruck eingerichtet. Füge einen in den Systemeinstellungen hinzu. - Verschlüsselung ist aktiv - Nicht eingerichtet. Ohne sie bedeutet eine vergessene PIN, dass alles verloren ist. Tippe, um eine zu erstellen. - Alle Daten löschen - Alle Notizen, Aufgaben und Labels sowie die Schlüssel auf diesem Gerät löschen - Alle Notizen, Aufgaben und Labels sowie die Schlüssel werden von diesem Gerät gelöscht und Encly beginnt von vorn. Das lässt sich nicht rückgängig machen: Exportiere zuerst ein Backup, wenn du etwas behalten möchtest. - Verschlüsselung und Datenschutz - Sicherheit - - - Darstellung - Allgemein - Sprache - Privatsphäre - Aufgaben anzeigen - Die Aufgabenkarte auf dem Startbildschirm anzeigen - Einfaches Bearbeiten - Dynamische Blöcke deaktivieren und Notizen als reinen Text bearbeiten - - Sortieren nach: - Nach Erstellungsdatum (älteste zuerst) - Erstellt ↑ - Nach Erstellungsdatum (neueste zuerst) - Erstellt ↓ - Nach letzter Bearbeitung (älteste zuerst) - Bearbeitet ↑ - Nach letzter Bearbeitung (neueste zuerst) - Bearbeitet ↓ - Entwickler unterstützen - - Das Label konnte nicht gespeichert werden. Bitte versuche es erneut. - Das Label konnte nicht gelöscht werden. Bitte versuche es erneut. - Label bearbeiten - Ausgeblendetes Label - Ein Label mit diesem Namen gibt es bereits - Die Reihenfolge der Labels konnte nicht gespeichert werden. Bitte versuche es erneut. - Das Label konnte nicht aktualisiert werden. Bitte versuche es erneut. - - Zum Löschen erneut tippen - - Neue Aufgabe - Erledigte Aufgaben konnten nicht gelöscht werden. Bitte versuche es erneut. - Möchtest du wirklich alle erledigten Aufgaben löschen? Das kann nicht rückgängig gemacht werden. - Erledigte Aufgaben löschen - Erledigt: %1$s - Die Aufgabe konnte nicht gespeichert werden. Bitte versuche es erneut. - Beschreibung - Aufgabe bearbeiten - Keine offenen Aufgaben. - Keine Aufgaben passen zu den gewählten Filtern. - Alle Aufgaben - Erledigt - Neue Aufgabe - Fortschritt - Der Status der Aufgabe konnte nicht geändert werden. Bitte versuche es erneut. - Die Aufgabe konnte nicht aktualisiert werden. Bitte versuche es erneut. - - Text - - Design - Dunkel - Hell - System - - - Rückgängig - - Ohne Titel - - Zur Rasteransicht wechseln - Zur Listenansicht wechseln - - - %d Notiz löschen? - %d Notizen löschen? - - - Zu viele Versuche. Versuche es in %d Sekunde erneut - Zu viele Versuche. Versuche es in %d Sekunden erneut - - - %1$d von %2$d Aufgabe erledigt - %1$d von %2$d Aufgaben erledigt - - Backup - Verschlüsselte Sicherung, Import und Markdown - Backup - Mit deiner Wiederherstellungsphrase verschlüsselt - Ein Backup enthält alle deine Notizen, Aufgaben und Labels, verschlüsselt mit deiner 12-Wörter-Wiederherstellungsphrase. Encly lädt es nirgendwo hoch: Du entscheidest, wo die Datei gespeichert wird. Auf einem neuen Handy stellen dieselben 12 Wörter es wieder her. - Letzter Export: %1$s - Noch kein Backup exportiert - Backup exportieren - Backup importieren - Notizen, Aufgaben und Labels aus einer Backup-Datei wiederherstellen - Backup gespeichert - Bestätige, dass du es bist - Gib deine PIN ein, um fortzufahren - Wiederherstellungsphrase - Backups werden mit einer Wiederherstellungsphrase verschlüsselt, nur ihre 12 Wörter können sie öffnen. Dieser Tresor hat noch keine. Erstelle jetzt eine: Mit ihr kannst du den Tresor auch entsperren, wenn du deine PIN vergisst. - Wiederherstellungsphrase erstellen - Schreib diese 12 Wörter der Reihe nach auf Papier und bewahre sie offline auf. Wer sie hat, kann deine Backups öffnen. - Ich habe sie notiert - Die Wörter stimmen nicht. Prüfe deine Notiz. - Wiederherstellungsphrase gespeichert - Wiederherstellungsphrase bestätigen - Gib die 12-Wörter-Wiederherstellungsphrase dieses Tresors einmal ein, damit Encly Backups damit verschlüsseln kann. - Wiederherstellungsphrase eingeben - Gib die 12 Wörter der Wiederherstellungsphrase ein, mit der dieses Backup erstellt wurde. - Entschlüsseln - Backup importieren - Zusammenführen - Ersetzen - Alle Daten ersetzen? - Alle Notizen, Aufgaben und Labels in diesem Tresor werden gelöscht und durch das Backup ersetzt. Das kann nicht rückgängig gemacht werden. - Importiert: Notizen %1$d, Aufgaben %2$d, Labels %3$d. Übersprungen (schon vorhanden): %4$d. - Diese Datei ist kein Encly-Backup - Dieses Backup stammt von einer neueren Encly-Version. Aktualisiere die App und versuche es erneut. - Die Backup-Datei ist beschädigt oder unvollständig - Die Datei ist zu groß für ein Encly-Backup - Dein Tresor ist zu groß für eine Backup-Datei (64 MB). Es wurde nichts gespeichert. - Diese Datei existiert bereits. Wähle einen neuen Namen: Ein älteres Backup wird nie überschrieben. - Auf diesem Gerät gibt es keine App zum Öffnen oder Speichern von Dateien. - Diese Wörter öffnen dieses Backup nicht, oder die Datei wurde verändert - Gib eine gültige 12-Wörter-Wiederherstellungsphrase ein - Der Tresor wurde gesperrt. Entsperre ihn und versuche es erneut. - Das Backup konnte nicht gelesen oder geschrieben werden. Es wurde nichts geändert. - Diese Wörter sind nicht die Wiederherstellungsphrase dieses Tresors - Die Wiederherstellungsphrase konnte nicht gespeichert werden - Aus einem Backup wiederherstellen - Backup-Datei wählen - Das Backup konnte nicht wiederhergestellt werden, daher ist die Einrichtung noch nicht abgeschlossen. Versuche es erneut oder fahre mit einem leeren Tresor fort und importiere die Datei später unter Einstellungen → Backup. - Backup nicht wiederhergestellt - Erneut versuchen - Ohne Backup fortfahren - Diese Notiz kann von dieser Encly-Version nicht gelesen werden. - Link öffnen - Link kopieren - Link bearbeiten - Link kopiert - Keine App auf diesem Gerät kann diesen Link öffnen. - Nur Web-Links (http, https) und E-Mail-Links können gespeichert oder geöffnet werden. - Notizen konnten nicht gelesen werden - Labels konnten nicht gelesen werden - Deine Notizen sind weiterhin verschlüsselt auf diesem Gerät gespeichert. Schließe Encly und öffne es erneut, um es noch einmal zu versuchen. - Du hast Encly mit deiner Wiederherstellungsphrase entsperrt. Lege eine neue PIN fest, um fortzufahren. - Aufgabe löschen - Aufgabe gelöscht - Die Aufgabe konnte nicht gelöscht werden. Bitte versuche es erneut. - Modus - Schrift - Papier - Wald - Ozean - Graphit - Mitternacht - Folgt der Systemeinstellung. Gerade hell. - Folgt der Systemeinstellung. Gerade dunkel. - Immer hell. - Immer dunkel. - Mitternacht ist immer dunkel. - Akzentfarbe aus deinem Hintergrundbild. Ab Android 12. - Dynamische Farben sind an: Die Akzentfarbe kommt aus deinem Hintergrundbild. - Playfair Display und Source Sans 3 - Poppins und Source Sans 3 - Durchgehend IBM Plex Sans - Checkliste Wohnungsbesichtigung - Vor dem Termin: Wasserdruck prüfen, fragen, was die Heizung letzten Winter gekostet hat. - Privat - Vorschau - Design, Modus und Schrift - Richtig - Eingegebene Ziffern: %1$d von %2$d - Mit Fingerabdruck entsperren - Schritt %1$d von %2$d - Ein Notizbuch, das nur du öffnen kannst. - Notizen und Aufgaben, verschlüsselt auf diesem Handy. Die Einrichtung dauert etwa eine Minute. - Kein Konto - Keine Anmeldung, keine E-Mail, kein Passwort-Reset. - Funktioniert offline - Encly verbindet sich nie mit dem Internet. - Auf dem Gerät verschlüsselt - Dein Tresor ist mit einem Schlüssel verschlossen, den nur du hast. - Los geht\'s - Ich habe ein Backup - Wähle eine 6-stellige PIN - Sie entsperrt Encly und schützt den Schlüssel zu deinem Tresor. Es gibt keine E-Mail, um sie zurückzusetzen. - Gib die PIN noch einmal ein - Damit wir wissen, dass es die richtige ist. - Die PIN funktioniert weiterhin als Ersatz - Zwölf Wörter bringen alles zurück - Encly gibt dir eine Wiederherstellungsphrase. Schreib sie auf Papier; sie ist der einzige Ersatzschlüssel. - PIN vergessen? - Die Phrase entsperrt deinen Tresor und lässt dich eine neue PIN festlegen. - Öffnet deine Backups - Verschlüsselte Backup-Dateien lassen sich nur mit diesen Wörtern lesen. - Neues Handy - Backup-Datei plus Phrase stellt jede Notiz, Aufgabe und jedes Label wieder her. - Niemand kann sie für dich wiederherstellen, auch wir nicht. Verlierst du PIN und Phrase, sind die Notizen weg. - Meine Phrase erstellen - Überspringen: nur PIN, keine Backups - Schreib diese Wörter auf - Auf Papier, in dieser Reihenfolge. Nicht als Screenshot, nicht in der Cloud. - Wörter verbergen - Wörter zeigen - Ich habe sie aufgeschrieben - Drei Wörter prüfen - Tippe sie von deiner Papierkopie ab, nicht aus dem Gedächtnis. - Wort Nr. %1$d - Wort eingeben - Das ist nicht Wort Nr. %1$d - Bestätigen - Wörter noch einmal zeigen - Dein Tresor ist bereit - Alles, was du ab jetzt schreibst, wird vor dem Speichern verschlüsselt. - PIN festgelegt - Wird bei jedem Öffnen von Encly abgefragt - Entsperren per Fingerabdruck an - Änderbar unter Einstellungen, Sicherheit - Wiederherstellungsphrase geprüft - Bewahre das Papier sicher auf - Keine Wiederherstellungsphrase - Backups sind aus. Du kannst später unter Einstellungen, Sicherheit eine Phrase erstellen. - Backup wiederhergestellt - Deine Notizen, Aufgaben und Labels sind zurück - Tipp: Exportiere ab und zu ein verschlüsseltes Backup unter Einstellungen, Backup. Deine Phrase öffnet es. - Mein Notizbuch öffnen - Wiederherstellen, dann PIN festlegen - Wähle deine Encly-Backup-Datei und gib dann die 12 Wörter ein, mit denen sie erstellt wurde. - 1 · Backup-Datei - Tippen, um eine andere Datei zu wählen - 2 · Wiederherstellungsphrase - Diese Wörter werden auch die Wiederherstellungsphrase dieses Handys. - Notizen - Jetzt sperren - Titel und Text durchsuchen - Ansichtsoptionen - Keine Treffer für „%1$s“ - Heute - Gestern - - Aufgaben · %d offen - Aufgaben · %d offen - - Aufgaben · keine offen - Noch keine Aufgaben - Tippe auf „Neue Aufgabe“, um die erste anzulegen. - Alles erledigt - Erledigt · %1$d - Keine Notizen mit dem Tag „%1$s“ - Notizen mit diesem Tag erscheinen hier. - Alle Notizen zeigen - Der Papierkorb ist leer - Tippe auf eine Notiz, um sie zu lesen. Lange drücken zum Auswählen. - Endgültig löschen - Papierkorb leeren - Notizen durchsuchen - Notizen und Aufgaben - Version, Datenschutz und Kontakt - Antworten auf häufige Fragen - Open-Source-Lizenzen - Bibliotheken und Schriften, die Encly nutzt - Auf Open Source gebaut - Encly nutzt diese Bibliotheken und Schriften. Tippe darauf, um die Lizenz zu lesen. - Bibliotheken - Schriften - Noch keine Tags - Tags ordnen deine Notizen. Lege oben einen an und wähle ihn dann per langem Druck auf eine Notiz aus. - Tippe auf einen Tag, um ihn umzubenennen, auszublenden oder zu löschen. Zum Sortieren am Griff ziehen. - Tag hinzufügen - Name - In der Notizliste zeigen - Ein ausgeblendeter Tag behält seine Notizen, aber keinen Filter-Chip - Ziehen zum Sortieren - Nach oben - Nach unten - - %d ausgewählt - %d ausgewählt - - Labels bearbeiten - Titel - Nicht gespeichert - Labels verwalten - Änderungen verwerfen? - Alles, was du seit dem Öffnen dieser Notiz geändert hast, geht verloren. Das kann nicht rückgängig gemacht werden. - Alles gehört schon dir - Jede Funktion steht allen zur Verfügung. Nichts ist hinter einer Zahlung versteckt. - Keine Werbung, kein Tracking - Encly sieht deine Notizen nie und verdient nichts an Daten. Nur Spenden finanzieren die App. - Wohin das Geld fließt - Testgeräte (als Nächstes: ein Mac mini zum Bauen und Testen) und Zeit für Releases und Fehlerbehebungen. - Karte oder PayPal, einmalig oder monatlich - Öffnet ko-fi.com im Browser. Encly selbst wickelt nie Zahlungen ab. - Was bewirkt langes Drücken auf eine Notiz? - Es öffnet die Aktionen der Notiz: Label ändern, eine kurze Beschreibung für die Karte hinzufügen, die Notiz duplizieren oder in den Papierkorb verschieben. - Wie bekomme ich eine gelöschte Notiz zurück? - Gelöschte Notizen landen im Papierkorb im Menü. Dort kannst du eine Notiz wiederherstellen oder endgültig löschen. Encly leert den Papierkorb nie von selbst. - Was macht „Bearbeitung sperren“? - Die Notiz wird schreibgeschützt, damit sie nicht versehentlich geändert wird. Text lässt sich weiterhin markieren und kopieren. Ein- und ausschalten kannst du es im Menü des Editors. - Wie funktionieren Backups? - Unter Einstellungen → Backup exportierst du eine verschlüsselte Datei, die nur deine Wiederherstellungsphrase öffnen kann. Zum Wiederherstellen wählst du die Datei, tippst oder fügst die 12 Wörter ein und führst sie dann mit diesem Tresor zusammen oder ersetzt alles. - Tastatur einblenden - Tastatur ausblenden - Alle Daten löschen? - Die Daten konnten nicht gelöscht werden. Nichts wurde geändert. - Löschen - Unteraufgaben - Unteraufgabe - Unteraufgabe hinzufügen - Unteraufgabe löschen - Alle Unteraufgaben sind erledigt - Aufgabe erledigen - Unteraufgaben anzeigen - Unteraufgaben ausblenden - Ausgeklappt - Eingeklappt - Unteraufgabe gelöscht - Nächster Schritt: %1$s - Erster Schritt - - %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 - Anheften - Anheften aufheben - Archivieren - Aus dem Archiv holen - Angeheftet - Weitere - Archiv - Das Archiv ist leer - Archivierte Notizen verschwinden aus Liste und Suche, bleiben aber im Tresor. Archiviere eine Notiz über ihr Menü. - Die Notiz konnte nicht archiviert werden - Die Notiz konnte nicht aus dem Archiv geholt werden - Farbe - Keine Farbe - Rot - Orange - Gelb - Grün - Petrol - Blau - Violett - Rosa - Als Markdown exportieren - Als Markdown kopieren - Als Markdown kopiert - Lesbarer Export - Alle Notizen und Aufgaben als lesbare .md-Dateien in einer .zip speichern. Die Datei ist nicht verschlüsselt. - Ohne Verschlüsselung exportieren? - Die exportierte Datei ist nicht verschlüsselt. Jeder, der sie bekommt, kann deine Notizen lesen. Überall, wo du sie speicherst, auch in einem Cloud-Ordner, liegt eine lesbare Kopie, die Encly weder schützen noch zurückholen kann. Als Nächstes bestätigst du mit deiner PIN. - Markdown-Export gespeichert - Kann ich meine Notizen als Markdown exportieren? - Ja. Wähle im Menü einer Notiz „Als Markdown exportieren“ für eine .md-Datei, oder nutze Einstellungen → Backup → „Als Markdown exportieren“ für eine .zip mit allen Notizen (archivierte im Ordner archive, ohne Papierkorb) und einer Aufgabendatei. Diese Dateien sind nicht verschlüsselt: Encly warnt dich vorher, fragt nach deiner PIN und speichert nur dort, wo du es festlegst. - Wischaktionen - Wischen auf Notizen - Wische eine Notizkarte, um eine Aktion auszuführen - Nach rechts wischen - Nach links wischen - Anheften oder lösen - Nächste Farbe - In den Papierkorb - Nichts - In den Papierkorb legen? - Du kannst die Notiz aus dem Papierkorb wiederherstellen. - Was bewirken Wischgesten auf einer Notiz? - Standardmäßig heftet ein Wisch nach rechts eine Notiz an oder löst sie, ein Wisch nach links archiviert sie. Unter Einstellungen → Wischaktionen kannst du für jede Richtung Anheften, Archivieren, nächste Farbe, In den Papierkorb (fragt immer nach) oder nichts wählen oder das Wischen ausschalten. TalkBack bietet dieselben Aktionen im Aktionsmenü der Notiz an. - Text in Notizen - Der Standard für jede Notiz. Eine Notiz kann in ihrem Menü eigene Werte festlegen. - Standard-Texteinstellungen verwenden - Quellcode - Encly ist Open Source: Lies den Code auf GitHub - diff --git a/app/src/main/res/values-de/strings_import.xml b/app/src/main/res/values-de/strings_import.xml deleted file mode 100644 index aa62e595..00000000 --- a/app/src/main/res/values-de/strings_import.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - Import aus anderen Apps - Notizen importieren - Aus Google Keep, Standard Notes, Joplin, Notesnook oder Markdown-Dateien - Hole Notizen aus einer anderen App. Wähle deren Export, Encly erkennt das Format selbst. Alles bleibt auf diesem Telefon, nichts wird hochgeladen. - Dateien wählen - Ordner wählen - Unterstützte Apps - So exportierst du - Nicht importiert - Google Keep - Google-Takeout-.zip (unverändert wählen, ohne zu entpacken) oder einzelne .json-Notizen - Wähle in Google Takeout nur Keep, erstelle den Export und lade die .zip herunter. Wähle dann diese .zip hier aus. - Angeheftet- und Archiv-Status, Farben und Anhänge. Nur das erste Label wird zum Tag. - Standard Notes - Entschlüsseltes Backup (.txt oder .json) oder die .zip eines entschlüsselten Downloads - Lade in Standard Notes ein entschlüsseltes Backup herunter, kein verschlüsseltes. Wähle dann die Datei oder die .zip hier aus. - Verschlüsselte Backups werden abgelehnt. Angeheftet- und Archiv-Status, Dateien und andere Elementtypen. Nur das erste Tag bleibt. - Joplin - JEX-Archiv (.jex), RAW-Ordner oder Markdown-Export - Wähle in Joplin Datei → Alles exportieren und dann JEX oder RAW. Wähle dann die Datei oder den Ordner hier aus. - Anhänge und der Aufgabenstatus (Aufgaben kommen als normale Notizen an). Eine Notiz erhält ihr erstes Tag, sonst den Namen ihres Notizbuchs. - Notesnook - Markdown-, Markdown-mit-Front-Matter- oder TXT-Export (.zip) - Exportiere deine Notizen in Notesnook als Markdown mit Front Matter, um Daten und Tags zu behalten. Wähle dann die .zip hier aus. Verschlüsselte Backups lassen sich nicht importieren. - Anhänge, Angeheftet- und Archiv-Status, Farben. Das erste Tag, sonst das Notizbuch, wird zum Tag. - Markdown und Text - .md- und .txt-Dateien oder ein ganzer Ordner, zum Beispiel aus Obsidian oder Markor - Wähle die Dateien oder den Ordner, der sie enthält. Der Dateiname wird zum Titel; einfaches Front Matter (Titel, Tags, Daten) wird gelesen. - Bilder, Anhänge, Plugins und Formatierung über Überschriften, Listen, Checklisten und Zitate hinaus. Der erste Ordner wird zum Tag. - Deine Dateien werden gelesen und erkannt … - Bereit zum Import - Erkannt: %1$s · %2$s - Die ausgewählten Notizen werden diesem Tresor hinzugefügt. Nichts wird ersetzt, und was schon da ist, wird übersprungen. - Notizen mit Checklisten: %1$d - Tags: %1$d - Notizen, die in den Papierkorb kommen: %1$d - Schon in diesem Tresor, übersprungen: %1$d - In deiner Auswahl wiederholt, übersprungen: %1$d - Ziel: dieser Tresor - Zurückbleibende Anhänge: %1$d - Angeheftete Notizen, die nicht angeheftet bleiben: %1$d - Archivierte Notizen, die als normale Notizen ankommen: %1$d - Nicht übernommene Notizfarben: %1$d - Verworfene zusätzliche Tags (eine Notiz hat ein Tag): %1$d - Aufgaben-Notizen, die als normale Notizen importiert werden: %1$d - Übersprungene verschlüsselte oder nicht unterstützte Einträge: %1$d - Nicht verwendbare Dateien: %1$d - Archive in Archiven, nicht geöffnet: %1$d - Wird importiert … - Importiert - %1$d hinzugefügt, %2$d als Duplikate übersprungen, %3$d nicht importiert. - Deine Originaldateien wurden nicht verändert. Lösche sie selbst, sobald du deine Notizen hier geprüft hast. - Aus welcher App stammen diese? - Die Dateien scheinen aus mehr als einer App zu stammen. Wähle die zu importierende; der Rest wird übersprungen. - Nichts wurde importiert - Dieses Standard-Notes-Backup ist verschlüsselt. Lade in Standard Notes ein entschlüsseltes Backup herunter und wähle dieses. - Das ist ein verschlüsseltes Notesnook-Backup. Exportiere deine Notizen in Notesnook stattdessen als Markdown und wähle die .zip. - Diese Dateien sind kein unterstützter Export. Sieh dir die unterstützten Apps an oder wähle Markdown- oder Textdateien. - Diese Auswahl ist größer, als Encly auf einmal importieren kann (64 MB Notizen, 2 GB insgesamt oder 20.000 Notizen). Importiere sie in kleineren Teilen. - In dieser Auswahl wurden keine Notizen gefunden. - Die gewählten Dateien konnten nicht gelesen werden. - Auf diesem Telefon gibt es keine Datei-Auswahl-App, die Encly nutzen kann. - Der Tresor wurde während des Imports geschlossen. Entsperre Encly und versuche es erneut. - Etwas ist schiefgelaufen, es wurde nichts importiert. Dein Tresor ist unverändert. - %1$d von %2$d ausgewählt - Alle auswählen - Keine auswählen - Alle - Nur neue - Im Papierkorb - Ohne Titel - Checkliste - Im Papierkorb - Schon im Tresor - %1$d importieren - Erneut wählen - diff --git a/app/src/main/res/values-de/strings_security.xml b/app/src/main/res/values-de/strings_security.xml deleted file mode 100644 index 95063cb2..00000000 --- a/app/src/main/res/values-de/strings_security.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - Gib deine 12 Wörter ein oder füge sie ein - Wort %1$d von %2$d - Wort %1$d ist kein Wort einer Wiederherstellungsphrase. Prüfe die Schreibweise. - Diese 12 Wörter ergeben keine gültige Wiederherstellungsphrase. Prüfe jedes Wort und die Reihenfolge. - Dieser Tresor hat bereits eine Wiederherstellungsphrase - Zu deinen Notizen hinzufügen oder sie ersetzen? - Dieses Backup enthält %1$s, %2$s und %3$s. - Alles hier bleibt, Fehlendes kommt dazu. Was schon da ist, wird übersprungen. - Alles ersetzen - Alle Notizen, Aufgaben und Tags auf diesem Handy löschen, dann das Backup wiederherstellen. - Sichere alle Notizen, Aufgaben und Tags in einer verschlüsselten Datei und stelle sie auf jedem Handy wieder her. - Exportieren - Importieren - Deine Notizen sind verschlüsselt. Deine PIN (und dein Fingerabdruck, falls aktiviert) entsperrt sie. - Deine Notizen sind verschlüsselt. Deine PIN (und dein Fingerabdruck, falls aktiviert) entsperrt sie, und wenn du die PIN vergisst, deine Wiederherstellungsphrase. - Bei der Einrichtung erstellt. Encly speichert die Wörter nie und kann sie nicht erneut zeigen. Hast du sie nicht? Ersetze die Phrase unten. - Eingerichtet - Nach dem Zurücksetzen kannst du eine Backup-Datei mit ihren 12 Wörtern wiederherstellen. - PIN ändern - - %d Notiz - %d Notizen - - - %d Aufgabe - %d Aufgaben - - - %d Tag - %d Tags - - - Deine PIN kann Encly auf diesem Telefon nicht mehr entsperren: Ihr Hardware-Schlüssel wurde zurückgesetzt. Nutze deine Wiederherstellungsphrase (oder deinen Fingerabdruck) und lege dann eine neue PIN fest. - - Zu viele Versuche. Versuche es in %d Minute erneut - Zu viele Versuche. Versuche es in %d Minuten erneut - - - Zu viele Versuche. Versuche es in %d Stunde erneut - Zu viele Versuche. Versuche es in %d Stunden erneut - - Wiederherstellungsphrase ersetzen - Erhalte 12 neue Wörter. Frühere Backups brauchen weiterhin die alten. - Wiederherstellungsphrase ersetzen? - Du erhältst 12 neue Wörter. Die alte Phrase entsperrt deine Notizen auf diesem Telefon nicht mehr. Bereits erstellte Backups öffnen sich weiterhin nur mit der alten Phrase – bewahre sie also auf, solange du diese Backups behältst, oder erstelle danach ein neues Backup. - Neue Wörter erhalten - Deine alte Phrase funktioniert hier nicht mehr - Sobald du bestätigst, entsperren nur noch diese Wörter deine Notizen, wenn du die PIN vergisst. Frühere Backups brauchen weiterhin die alten Wörter. - Wiederherstellungsphrase ersetzt. Erstelle ein neues Backup, um sie auch für Backups zu nutzen. - Datenschutz - Strenger Tastatur-Datenschutz - Bittet die Tastatur um keine Vorschläge, keine Cloud-Vorhersage und kein Mitlesen rund um den Cursor. Schaltet die Autokorrektur aus, und manche Tastaturen blenden Emojis oder Spracheingabe aus. - Sperren nach dem Verlassen - Wie lange Encly offen bleibt, wenn du zu einer anderen App wechselst. Bildschirm aus oder Telefon sperren sperrt es immer sofort. - Sofort - Nach 15 Sekunden - 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. - Eine PIN, an dieses Telefon gebunden - Deine PIN wird mit einem Schlüssel im Sicherheitschip des Telefons verknüpft und kann nirgendwo sonst erraten werden. - Ein eigenes Schloss für jeden Zugang - PIN, Wiederherstellungsphrase und Fingerabdruck öffnen je einen eigenen verschlüsselten Slot. Der Fingerabdruck wird jedes Mal verlangt. - Gefahrenbereich - 5 Sekunden halten zum Löschen - - Lösch-PIN - Eine zweite PIN, die deine Notizen löscht, wenn du sie auf dem Sperrbildschirm eingibst - Ausgeschaltet, als dein PIN-Schlüssel zurückgesetzt wurde. Lege sie neu fest, wenn du eine hattest. - Wenn dich jemand zwingt, Encly zu öffnen, gib statt deiner PIN die Lösch-PIN ein. Encly löscht deine Notizen, Aufgaben und Labels ohne sichtbares Zeichen und öffnet sich als leerer Tresor. Das funktioniert nur auf dem Sperrbildschirm. - Nach dem Löschen öffnet sich Encly mit der Lösch-PIN: Sie wird zur PIN des neuen, leeren Tresors. Deine alte PIN funktioniert nicht mehr. Wer gesehen hat, wie du die Lösch-PIN eingibst, kann Encly damit wieder öffnen und bemerkt nichts Ungewöhnliches. - Bereits exportierte Backup-Dateien bleiben unberührt. Sie lassen sich weiterhin mit deiner Wiederherstellungsphrase öffnen und sind der einzige Weg zurück. - Biometrisches Entsperren bleibt an: Jemand kann deinen Finger auf den Sensor zwingen. Schalte es aus, wenn das für dich ein Risiko ist. - Biometrisches Entsperren ausschalten - Das schützt dich, wenn dich jemand zum Entsperren zwingt, nicht vor einer forensischen Untersuchung des Telefons. Während einer Sperre nach Fehlversuchen funktioniert auch die Lösch-PIN nicht. Encly zeigt nie an, ob eine Lösch-PIN festgelegt ist: Eine neue ersetzt die alte, und wenn du deine PIN auf die Lösch-PIN änderst, wird sie ausgeschaltet. - Lösch-PIN festlegen - Lösch-PIN ausschalten - Bestätige deine PIN, um die Lösch-PIN einzurichten - Lösch-PIN erstellen - 6 Ziffern, anders als deine PIN - Lösch-PIN bestätigen - Gib die Lösch-PIN noch einmal ein - Die Lösch-PIN muss sich von deiner PIN unterscheiden - Die Lösch-PIN konnte nicht gespeichert werden. Bitte versuche es erneut. - Lösch-PIN festgelegt - Lösch-PIN ausgeschaltet - Dein PIN-Schlüssel wurde zurückgesetzt, daher ist eine eventuelle Lösch-PIN ausgeschaltet. Lege sie neu fest, wenn du eine möchtest. - Diese PIN wird bereits verwendet. Wähle eine andere. - Zwangs-PIN - Eine zweite PIN, die einen getrennten Tresor mit eigenen Notizen öffnet - Ausgeschaltet, als dein PIN-Schlüssel zurückgesetzt wurde. Lege sie neu fest, wenn du eine möchtest. - Wenn dich jemand zwingt, Encly zu öffnen, gib statt deiner PIN die Zwangs-PIN ein. Encly öffnet einen getrennten, leeren Tresor, den du wie gewohnt nutzen kannst: Notizen, die du dort anlegst, bleiben darin erhalten. - Dein echter Tresor bleibt gesperrt und unberührt. Anders als die Lösch-PIN löscht die Zwangs-PIN nichts. Dein Fingerabdruck und deine Wiederherstellungsphrase öffnen immer den echten Tresor. - Sie schützt dich, wenn dich jemand auffordert, die App zu öffnen und ihm alles zu zeigen, nicht vor einer forensischen Untersuchung des Telefons. Während einer Sperre nach Fehlversuchen funktioniert auch die Zwangs-PIN nicht. - Eine Zwangs-PIN ist festgelegt. - Es ist keine Zwangs-PIN festgelegt. - Zwangs-PIN festlegen - Zwangs-PIN ändern - Zwangs-PIN ausschalten - Bestätige deine PIN, um die Zwangs-PIN einzurichten - Zwangs-PIN erstellen - 6 Ziffern, anders als deine PIN - Zwangs-PIN bestätigen - Gib die Zwangs-PIN noch einmal ein - Die Zwangs-PIN muss sich von deiner PIN unterscheiden - Die Zwangs-PIN konnte nicht gespeichert werden. Bitte versuche es erneut. - Zwangs-PIN festgelegt. - Zwangs-PIN geändert. Der Tarn-Tresor behält seine Notizen. - Zwangs-PIN ausgeschaltet - Dein PIN-Schlüssel wurde zurückgesetzt, daher funktioniert die Zwangs-PIN nicht mehr. Lege sie neu fest; der Tarn-Tresor behält seine Notizen. - Zwangs-PIN ausschalten? - Der Tarn-Tresor und die Notizen darin werden gelöscht. Dein echter Tresor bleibt unverändert. - Ausschalten - diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml deleted file mode 100644 index 2f58f05c..00000000 --- a/app/src/main/res/values-es/strings.xml +++ /dev/null @@ -1,662 +0,0 @@ - - - Acerca de - Un espacio local y cifrado para notas, ideas y tareas, sin sincronización en la nube. - Protección de datos - Los datos de la caja fuerte se guardan localmente en una base de datos SQLCipher cifrada - Preguntas frecuentes - Comentarios - Informa de un error o sugiere una mejora en GitHub - Por qué importa y cómo ayudar en Ko-fi - No se encontró ningún navegador. Abre el enlace manualmente: %1$s - No se encontró ninguna aplicación de correo - Política de privacidad - Cómo guardamos y protegemos tus datos - Valorar la aplicación - Deja una valoración en Google Play Store - Soporte y contacto - Contactar con el desarrollador - Si tienes preguntas o sugerencias, puedes escribirme por correo. - - Continuar - - Añadir - - Todas - - Clave de acceso obligatoria. Toca para cambiar el PIN. - PIN de 6 dígitos - Autenticación - - Atrás - - No se confirmó el cambio de la protección biométrica - No se pudo crear la clave biométrica - Confirma tu identidad - Autenticación - Confirma tu biometría - Acceso a la clave - Confirma tu biometría para cambiar la protección - Confirmación de seguridad - Confirma tu biometría para acceder a la clave cifrada - Desbloquear Encly - Usar PIN - La autenticación biométrica no está disponible - - Más herramientas de bloque - Lista con viñetas - Lista de verificación - Encabezado - Encabezado %1$d - Enlace - Lista - Bloquear lista de tareas - Desbloquear lista de tareas - Mover bloque hacia abajo - Mover bloque hacia arriba - Lista numerada - Separador - - Cancelar - - - ¿Seguro que quieres eliminar estas notas para siempre? Esta acción no se puede deshacer. - ¿Seguro que quieres vaciar la papelera para siempre? Esta acción no se puede deshacer. - ¿Vaciar la papelera? - - Borrar - - Cerrar - - Contraer - - Crear una etiqueta - - Hoy %1$s - Ayer %1$s - - Eliminar - Eliminar bloque - Eliminar - Eliminar etiqueta - Esta etiqueta se quitará de todas tus notas. Las notas en sí no se eliminarán. - - Eliminar - Cancelar - ¿Seguro que quieres eliminar esta nota para siempre? Esta acción no se puede deshacer. - ¿Eliminar la nota? - - Encly es gratuito y de código abierto, sin anuncios, sin rastreo y sin cuentas. Las donaciones son su único ingreso. - Apoyar el desarrollo - - Listo - - Duplicar - - Colores dinámicos - - Editar - - Bloquear la edición - - Aún no hay notas - Toca Nueva nota para escribir la primera. - Las notas eliminadas esperan aquí hasta que las restaures o las borres para siempre. - - Introduce un enlace - - No se pudo abrir la base de datos - - Expandir - - No se pudo cargar el contenido - - Encly guarda tus cambios y, tras el retraso elegido en Ajustes → Seguridad (15 segundos por defecto), cierra la base de datos cifrada y borra la clave de sesión. Apagar la pantalla o bloquear el teléfono lo hace al instante. Después, desbloqueas de nuevo. - ¿Qué pasa cuando salgo de la aplicación? - Usa el interruptor de biometría en Ajustes → Seguridad. Crear o eliminar la ranura biométrica requiere autenticación biométrica fuerte. - ¿Cómo activo o desactivo la biometría? - Abre Ajustes → Seguridad, confirma tu PIN actual y luego introduce el nuevo dos veces. - ¿Cómo cambio mi PIN? - Encly genera una clave de base de datos aleatoria de 256 bits. Tu PIN protege una ranura de desbloqueo AES-GCM independiente, y la biometría usa una clave de Android Keystore que exige autenticación en cada uso. - ¿Cómo se protege la clave de la base de datos? - En la pantalla de bloqueo, toca «Recuperar el acceso», introduce tus 12 palabras y elige un PIN nuevo. Sin frase de recuperación no hay otra forma de entrar: nadie, ni siquiera el desarrollador, puede restablecerlo. - ¿Y si olvido mi PIN? - Abre Ajustes → Idioma y elige uno de los idiomas disponibles, o «Predeterminado del sistema» para usar el idioma del dispositivo. El cambio se aplica al instante. - ¿Cómo cambio el idioma? - Toca «Nota nueva» en la pantalla principal. La barra sobre el teclado añade encabezados, listas de verificación, listas con viñetas y numeradas, citas, enlaces y separadores. - ¿Cómo creo una nota nueva? - Es una llave de repuesto de 12 palabras. Desbloquea la bóveda si olvidas tu PIN y es la única llave de tus copias de seguridad. Se crea durante la configuración, o más tarde en Ajustes → Seguridad si la omitiste. Guárdala en papel, sin conexión. - ¿Para qué sirve la frase de recuperación? - Abre «Acerca de» y usa la opción de comentarios, o escribe al desarrollador. Nunca incluyas tu frase de recuperación, tu PIN ni el texto privado de tus notas en un informe de error. - ¿Cómo informo de un error? - Automáticamente mientras escribes, y al salir del editor se completa el último guardado. Si alguna vez falla un guardado, el editor muestra «Sin guardar» en rojo. - ¿Cómo se guardan los cambios? - Notas, tareas, cifrado local y recuperación del acceso, sin sincronización en la nube. - Respuestas a las preguntas más frecuentes - No. La protección de pantalla está siempre activa: Encly bloquea las capturas de pantalla, la grabación de pantalla y las vistas previas sin proteger en la lista de aplicaciones recientes. - ¿Puedo hacer capturas de pantalla de Encly? - Lo básico - Seguridad y recuperación - Ajustes - Solución de problemas - Las notas, etiquetas y tareas se guardan únicamente en la base de datos SQLCipher local de Encly. No hay sincronización en la nube ni copias automáticas: puedes exportar tú mismo una copia cifrada en Ajustes → Copia de seguridad, o una copia legible en Markdown, que no está cifrada. - ¿Dónde se guardan mis datos? - Las etiquetas agrupan tus notas. Puedes crearlas, renombrarlas, ocultarlas y eliminarlas, arrastrarlas en el orden que quieras y filtrar la pantalla principal por etiqueta. - ¿Cómo funcionan las etiquetas? - Las tareas tienen una descripción y una prioridad. Puedes filtrarlas por prioridad y marcarlas como completadas. Encly nunca muestra el texto de las tareas en las notificaciones del sistema. - ¿Qué puedo hacer con las tareas? - En Ajustes → Apariencia elige uno de cinco temas, modo claro, oscuro o del sistema, uno de tres estilos de fuente y el tamaño del texto. En Android 12 y posteriores también puedes tomar el color de acento del fondo de pantalla. - ¿Cómo cambio el tema? - Encly nunca crea a propósito una base de datos vacía sobre una caja fuerte perdida o ilegible. No confirmes el borrado completo si aún necesitas tus datos locales. - Encly indica que la caja fuerte está dañada - - Tamaño del texto - Interlineado - Predeterminado - Compacto - Amplio - Editorial - Moderno - Técnico - - - Elegir idioma - Predeterminado del sistema - - Añadir enlace - - Elemento de la lista - - - Volver al PIN - Recuperar el acceso - Introduce tu frase de recuperación de 12 palabras - Recuperación - Introduce tu PIN de 6 dígitos para continuar - Desbloquea tus notas - Desbloqueando… - Recuperar el acceso con tu frase de recuperación - PIN incorrecto - Frase de recuperación incorrecta - - Mantén pulsado para borrarlo todo - Parece que los datos cifrados o las claves de cifrado se han modificado o dañado, así que no es posible recuperarlos.\n\nPara seguir usando la aplicación, tienes que restablecerla y borrar los datos dañados. Esto elimina para siempre todas las notas y ajustes guardados. - Lo sentimos, tus datos están dañados - Estos datos son de una versión anterior - Encly encontró notas guardadas por una versión anterior a la 2.0. Encly 2.0 usa un nuevo formato cifrado y no puede abrirlas, y no hay forma de convertirlas.\n\nPara usar la app, empieza de cero. Esto borra para siempre las notas y los ajustes antiguos de este teléfono. - Mantén pulsado para empezar de cero - - Preguntas frecuentes - Ajustes - Apoyar al desarrollador - Etiquetas - Tareas - Papelera - - Más opciones - - - Sin etiqueta - - Nota nueva - No se pudo eliminar la nota - Descripción… - Descartar todos los cambios - No se pudieron descartar los cambios - No se pudo crear una copia - No se pudo leer el contenido de la nota. La edición está desactivada para no dañar los datos. - - %1$s (copia) - No se pudo restaurar la nota - No se pudieron guardar los cambios - No se pudo mover la nota a la papelera - - No se pudo inicializar la aplicación - No se pudo crear la frase de recuperación - No se pudo crear la caja fuerte cifrada - - Abrir menú - - %1$d %% - - Vuelve a introducir el PIN para confirmarlo - Confirma tu PIN de 6 dígitos - Confirma tu PIN actual antes de cambiar tu clave de acceso - Introduce tu PIN actual - Introduce un PIN nuevo para proteger tus notas - PIN cambiado - Crea un PIN de 6 dígitos - El PIN actual no es correcto - Borrar dígito - Los PIN no coinciden. Inténtalo de nuevo. - No se pudo guardar el PIN - No se pudo actualizar el PIN. Inténtalo de nuevo. - - - Toca para empezar a editar… - - Alta - Baja - Media - Elige una prioridad - - Cita - Rehacer - - Restaurar - - Guardar - - - - - Borrar búsqueda - - Huella digital - Desbloqueo biométrico - No disponible en este dispositivo - No hay ninguna huella registrada. Añade una en los ajustes del sistema. - El cifrado está activo - Sin configurar. Sin ella, olvidar el PIN significa perderlo todo. Toca para crear una. - Borrar todos los datos - Eliminar todas las notas, tareas y etiquetas y las claves de cifrado de este dispositivo - Todas las notas, tareas y etiquetas y las claves de cifrado se eliminarán de este dispositivo, y Encly empezará de cero. No se puede deshacer: exporta antes una copia si quieres conservar algo. - Cifrado y protección de datos - Seguridad - - - Apariencia - General - Idioma - Privacidad - Mostrar tareas - Mostrar la tarjeta de tareas en la pantalla de inicio - Edición sencilla - Desactivar los bloques dinámicos y editar las notas como texto sin formato - - Ordenar por: - Por fecha de creación (más antiguas primero) - Creada ↑ - Por fecha de creación (más recientes primero) - Creada ↓ - Por última edición (más antiguas primero) - Editada ↑ - Por última edición (más recientes primero) - Editada ↓ - Apoyar al desarrollador - - No se pudo guardar la etiqueta. Inténtalo de nuevo. - No se pudo eliminar la etiqueta. Inténtalo de nuevo. - Editar etiqueta - Etiqueta oculta - Ya existe una etiqueta con este nombre - No se pudo guardar el orden de las etiquetas. Inténtalo de nuevo. - No se pudo actualizar la etiqueta. Inténtalo de nuevo. - - Toca de nuevo para eliminar - - Nueva tarea - No se pudieron borrar las tareas completadas. Inténtalo de nuevo. - ¿Seguro que quieres eliminar todas las tareas completadas? Esta acción no se puede deshacer. - Borrar tareas completadas - Completada: %1$s - No se pudo guardar la tarea. Inténtalo de nuevo. - Descripción - Editar tarea - No hay tareas activas. - Ninguna tarea coincide con los filtros seleccionados. - Todas las tareas - Completadas - Tarea nueva - Progreso - No se pudo cambiar el estado de la tarea. Inténtalo de nuevo. - No se pudo actualizar la tarea. Inténtalo de nuevo. - - Texto - - Tema - Oscuro - Claro - Sistema - - - Deshacer - - Sin título - - Cambiar a vista de cuadrícula - Cambiar a vista de lista - - - ¿Eliminar %d nota? - ¿Eliminar %d notas? - ¿Eliminar %d notas? - - - Demasiados intentos. Vuelve a intentarlo en %d segundo - Demasiados intentos. Vuelve a intentarlo en %d segundos - Demasiados intentos. Vuelve a intentarlo en %d segundos - - - %1$d de %2$d tarea completada - %1$d de %2$d tareas completadas - %1$d de %2$d tareas completadas - - Copia de seguridad - Copia cifrada, importación y Markdown - Copia de seguridad - Cifrada con tu frase de recuperación - Una copia de seguridad contiene todas tus notas, tareas y etiquetas, cifradas con tu frase de recuperación de 12 palabras. Encly nunca la sube a ningún sitio: tú eliges dónde se guarda el archivo. En un teléfono nuevo, las mismas 12 palabras la restauran. - Última exportación: %1$s - Aún no se ha exportado ninguna copia - Exportar copia - Importar copia - Restaurar notas, tareas y etiquetas desde un archivo de copia - Copia de seguridad guardada - Confirma que eres tú - Introduce tu PIN para continuar - Frase de recuperación - Las copias se cifran con una frase de recuperación, así que solo sus 12 palabras pueden abrirlas. Esta caja fuerte aún no tiene ninguna. Créala ahora: también te permite desbloquear esta caja fuerte si olvidas tu PIN. - Crear frase de recuperación - Anota estas 12 palabras en papel, en orden, y guárdalas sin conexión. Quien las tenga puede abrir tus copias. - Ya las he anotado - Las palabras no coinciden. Revisa lo que anotaste. - Frase de recuperación guardada - Confirma tu frase de recuperación - Introduce una vez la frase de recuperación de 12 palabras de esta caja fuerte para que Encly pueda cifrar las copias con ella. - Introduce la frase de recuperación - Introduce las 12 palabras de la frase de recuperación con la que se hizo esta copia. - Descifrar - Importar copia - Combinar - Reemplazar - ¿Reemplazar todos los datos? - Todas las notas, tareas y etiquetas de esta caja fuerte se eliminarán y se sustituirán por la copia. No se puede deshacer. - Importado: notas %1$d, tareas %2$d, etiquetas %3$d. Omitido (ya existía): %4$d. - Este archivo no es una copia de seguridad de Encly - Esta copia se creó con una versión más reciente de Encly. Actualiza la app y vuelve a intentarlo. - El archivo de copia está dañado o incompleto - El archivo es demasiado grande para ser una copia de Encly - Tu caja fuerte es demasiado grande para un solo archivo de copia (64 MB). No se ha guardado nada. - Ese archivo ya existe. Elige otro nombre: nunca se sobrescribe una copia anterior. - Ninguna app de este dispositivo puede abrir ni guardar archivos. - Estas palabras no abren esta copia, o el archivo ha sido modificado - Introduce una frase de recuperación válida de 12 palabras - La caja fuerte se ha bloqueado. Desbloquéala y vuelve a intentarlo. - No se pudo leer ni escribir la copia. No se ha cambiado nada. - Estas palabras no son la frase de recuperación de esta caja fuerte - No se pudo guardar la frase de recuperación - Restaurar desde una copia - Elegir un archivo de copia - No se pudo restaurar la copia, así que la configuración aún no ha terminado. Inténtalo de nuevo o continúa con una caja fuerte vacía e importa el archivo más tarde en Ajustes → Copia de seguridad. - Copia no restaurada - Reintentar - Continuar sin ella - Esta versión de Encly no puede leer esta nota. - Abrir enlace - Copiar enlace - Editar enlace - Enlace copiado - Ninguna app de este dispositivo puede abrir este enlace. - Solo se pueden guardar o abrir enlaces web (http, https) y de correo. - No se pudieron leer tus notas - No se pudieron leer tus etiquetas - Tus notas siguen guardadas cifradas en este dispositivo. Cierra Encly y vuelve a abrirlo para intentarlo de nuevo. - Has desbloqueado Encly con tu frase de recuperación. Crea un PIN nuevo para continuar. - Eliminar tarea - Tarea eliminada - No se pudo eliminar la tarea. Inténtalo de nuevo. - Modo - Fuente - Papel - Bosque - Océano - Grafito - Medianoche - Sigue el ajuste del sistema. Ahora claro. - Sigue el ajuste del sistema. Ahora oscuro. - Siempre claro. - Siempre oscuro. - Medianoche siempre es oscuro. - Toma el color de acento de tu fondo de pantalla. Android 12 y posteriores. - Los colores dinámicos están activados: el acento viene de tu fondo de pantalla. - Playfair Display y Source Sans 3 - Poppins y Source Sans 3 - IBM Plex Sans en todo - Lista para ver el piso - Antes de la visita: comprobar la presión del agua, preguntar cuánto costó la calefacción el invierno pasado. - Personal - Vista previa - Tema, modo y fuente - Correcto - Dígitos introducidos: %1$d de %2$d - Desbloquear con huella - Paso %1$d de %2$d - Una libreta que solo tú puedes abrir. - Notas y tareas, cifradas en este teléfono. La configuración lleva un minuto. - Sin cuenta - Sin registro, sin correo, sin restablecer contraseñas. - Funciona sin conexión - Encly nunca se conecta a internet. - Cifrado en el dispositivo - Tu caja fuerte está cerrada con una llave que solo tienes tú. - Empezar - Tengo una copia de seguridad - Elige un PIN de 6 dígitos - Desbloquea Encly y protege la llave de tu caja fuerte. No hay correo para restablecerlo. - Vuelve a introducir el PIN - Para saber que es el que querías. - El PIN sigue funcionando como alternativa - Doce palabras lo recuperan todo - Encly te dará una frase de recuperación. Escríbela en papel; es la única llave de repuesto. - ¿Olvidaste tu PIN? - La frase desbloquea tu caja fuerte y te permite poner un PIN nuevo. - Abre tus copias - Los archivos de copia cifrados solo se pueden leer con estas palabras. - Teléfono nuevo - El archivo de copia y la frase restauran cada nota, tarea y etiqueta. - Nadie puede recuperarla por ti, ni siquiera nosotros. Si pierdes el PIN y la frase, las notas se pierden. - Crear mi frase - Omitir: solo PIN, sin copias - Escribe estas palabras - En papel, en este orden. Ni en una captura ni en la nube. - Ocultar palabras - Mostrar palabras - Ya las escribí - Comprueba tres palabras - Escríbelas desde tu papel, no de memoria. - Palabra n.º %1$d - Introduce la palabra - Esa no es la palabra n.º %1$d - Confirmar - Ver las palabras otra vez - Tu caja fuerte está lista - Todo lo que escribas a partir de ahora se cifra antes de guardarse. - PIN configurado - Se pide cada vez que abres Encly - Desbloqueo con huella activado - Cámbialo en Ajustes, Seguridad - Frase de recuperación comprobada - Guarda el papel en un lugar seguro - Sin frase de recuperación - Las copias están desactivadas. Puedes crear una frase más tarde en Ajustes, Seguridad. - Copia restaurada - Tus notas, tareas y etiquetas han vuelto - Consejo: exporta de vez en cuando una copia cifrada desde Ajustes, Copia de seguridad. Tu frase es lo que la abre. - Abrir mi libreta - Restaurar y después crear un PIN - Elige tu archivo de copia de Encly y escribe las 12 palabras con las que se creó. - 1 · Archivo de copia - Toca para elegir otro archivo - 2 · Frase de recuperación - Estas palabras también serán la frase de recuperación de este teléfono. - Notas - Bloquear ahora - Buscar en títulos y texto - Opciones de vista - Nada coincide con «%1$s» - Hoy - Ayer - - Tareas · %d pendiente - Tareas · %d pendientes - Tareas · %d pendientes - - Tareas · ninguna pendiente - Aún no hay tareas - Toca Nueva tarea para añadir la primera. - Todo hecho - Hechas · %1$d - No hay notas con la etiqueta «%1$s» - Aquí aparecerán las notas con esta etiqueta. - Mostrar todas las notas - La papelera está vacía - Toca una nota para leerla. Mantén pulsado para seleccionar. - Eliminar para siempre - Vaciar papelera - Buscar notas - Notas y tareas - Versión, privacidad y contacto - Respuestas a preguntas frecuentes - Licencias de código abierto - Las bibliotecas y fuentes que usa Encly - Hecho con código abierto - Encly se hace con estas bibliotecas y fuentes. Toca una para leer su licencia. - Bibliotecas - Fuentes - Aún no hay etiquetas - Las etiquetas agrupan tus notas. Crea una arriba y elígela para una nota con una pulsación larga. - Toca una etiqueta para renombrarla, ocultarla o borrarla. Arrastra el asa para reordenar. - Añadir etiqueta - Nombre - Mostrar en la lista de notas - Una etiqueta oculta conserva sus notas pero no tiene filtro - Arrastra para reordenar - Subir - Bajar - - %d seleccionada - %d seleccionadas - %d seleccionadas - - Editar etiquetas - Título - Sin guardar - Gestionar etiquetas - ¿Descartar los cambios? - Se perderá todo lo que has cambiado desde que abriste esta nota. Esta acción no se puede deshacer. - Todo ya es tuyo - Todas las funciones están disponibles para todos. Nada está bloqueado tras un pago. - Sin anuncios ni rastreo - Encly nunca ve tus notas y no gana nada con tus datos. Solo las donaciones lo financian. - En qué se usa el dinero - Dispositivos de prueba (el próximo objetivo: un Mac mini para compilar y probar) y tiempo para versiones y correcciones. - Tarjeta o PayPal, una vez o cada mes - Abre ko-fi.com en tu navegador. La propia app Encly nunca gestiona pagos. - ¿Qué hace una pulsación larga sobre una nota? - Abre las acciones de la nota: cambiar su etiqueta, añadir una breve descripción que se ve en la tarjeta, duplicarla o moverla a la papelera. - ¿Cómo recupero una nota eliminada? - Las notas eliminadas van a la Papelera del menú. Allí puedes restaurar una nota o eliminarla para siempre. Encly nunca vacía la papelera por sí solo. - ¿Qué hace «Bloquear la edición»? - Deja la nota en solo lectura para que no se cambie por accidente. Aún puedes seleccionar y copiar el texto. Actívalo o desactívalo desde el menú del editor. - ¿Cómo funcionan las copias de seguridad? - En Ajustes → Copia de seguridad exportas un archivo cifrado que solo tu frase de recuperación puede abrir. Para restaurar, elige el archivo, escribe o pega las 12 palabras y luego combínalo con esta bóveda o reemplázalo todo. - Mostrar teclado - Ocultar teclado - ¿Borrar todos los datos? - No se pudieron borrar los datos. No se ha cambiado nada. - Borrar - Subtareas - Subtarea - Añadir subtarea - Eliminar subtarea - Todas las subtareas están hechas - Completar tarea - Mostrar subtareas - Ocultar subtareas - Expandido - Contraído - Subtarea eliminada - Siguiente paso: %1$s - Primer paso - - %1$d de %2$d subtarea completada - %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 - Fijar - Desfijar - Archivar - Desarchivar - Fijadas - Otras - Archivo - El archivo está vacío - Las notas archivadas salen de la lista y de la búsqueda, pero siguen en la bóveda. Archiva una nota desde su menú. - No se pudo archivar la nota - No se pudo sacar la nota del archivo - Color - Sin color - Rojo - Naranja - Amarillo - Verde - Turquesa - Azul - Morado - Rosa - Exportar como Markdown - Copiar como Markdown - Copiado como Markdown - Exportación legible - Guarda todas las notas y tareas como archivos .md legibles en un .zip. El archivo no está cifrado. - ¿Exportar sin cifrado? - El archivo exportado no está cifrado. Cualquiera que lo obtenga puede leer tus notas. Dondequiera que lo guardes, incluida una carpeta en la nube, habrá una copia legible que Encly no puede proteger ni recuperar. A continuación confirmarás con tu PIN. - Exportación Markdown guardada - ¿Puedo exportar mis notas como Markdown? - Sí. En el menú de una nota elige «Exportar como Markdown» para obtener un archivo .md, o usa Ajustes → Copia de seguridad → «Exportar como Markdown» para un .zip con todas las notas (las archivadas en una carpeta archive, sin la papelera) y un archivo de tareas. Estos archivos no están cifrados: Encly te avisa antes, te pide el PIN y solo guarda donde tú elijas. - Acciones al deslizar - Deslizar notas - Desliza una nota para actuar sobre ella - Deslizar a la derecha - Deslizar a la izquierda - Fijar o desfijar - Siguiente color - Mover a la papelera - Nada - ¿Mover a la papelera? - Podrás restaurar la nota desde la papelera. - ¿Qué hacen los deslizamientos en una nota? - Por defecto, deslizar una nota a la derecha la fija o la desfija, y a la izquierda la archiva. En Ajustes → Acciones al deslizar puedes elegir para cada dirección: fijar, archivar, siguiente color, mover a la papelera (siempre pregunta) o nada, o desactivar los deslizamientos. TalkBack ofrece las mismas acciones en el menú de acciones de la nota. - Texto de las notas - El valor predeterminado de cada nota. Una nota puede fijar el suyo desde su menú. - Usar el texto predeterminado - Código fuente - Encly es de código abierto: lee el código en GitHub - diff --git a/app/src/main/res/values-es/strings_import.xml b/app/src/main/res/values-es/strings_import.xml deleted file mode 100644 index 3313d0eb..00000000 --- a/app/src/main/res/values-es/strings_import.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - Importar de otras apps - Importar notas - Desde Google Keep, Standard Notes, Joplin, Notesnook o archivos Markdown - Trae tus notas de otra app. Elige su exportación y Encly reconoce el formato solo. Todo se queda en este teléfono; no se sube nada. - Elegir archivos - Elegir carpeta - Apps compatibles - Cómo exportar - No se importa - Google Keep - .zip de Google Takeout (elígelo tal cual, sin descomprimir) o notas .json sueltas - En Google Takeout selecciona solo Keep, crea la exportación y descarga el .zip. Luego elige ese .zip aquí. - El estado de fijada y archivada, los colores y los adjuntos. Solo la primera etiqueta pasa a ser la etiqueta. - Standard Notes - Copia descifrada (.txt o .json) o el .zip de una descarga descifrada - En Standard Notes descarga una copia descifrada, no una cifrada. Luego elige el archivo o el .zip aquí. - Las copias cifradas se rechazan. El estado de fijada y archivada, los archivos y otros tipos de elementos. Solo se conserva la primera etiqueta. - Joplin - Archivo JEX (.jex), carpeta RAW o exportación Markdown - En Joplin elige Archivo → Exportar todo y JEX o RAW. Luego elige el archivo o la carpeta aquí. - Los adjuntos y el estado de las tareas (las tareas llegan como notas normales). Cada nota recibe su primera etiqueta o, si no tiene, el nombre de su libreta. - Notesnook - Exportación Markdown, Markdown con front matter o TXT (.zip) - En Notesnook exporta tus notas como Markdown con front matter para conservar fechas y etiquetas. Luego elige el .zip aquí. Las copias cifradas no se pueden importar. - Los adjuntos, el estado de fijada y archivada y los colores. La primera etiqueta, o si no hay, la libreta, pasa a ser la etiqueta. - Markdown y texto - Archivos .md y .txt o una carpeta entera, por ejemplo de Obsidian o Markor - Elige los archivos o la carpeta que los contiene. El nombre del archivo es el título; se lee el front matter sencillo (título, etiquetas, fechas). - Imágenes, adjuntos, plugins y formato más allá de títulos, listas, listas de tareas y citas. La primera carpeta pasa a ser la etiqueta. - Leyendo y reconociendo tus archivos… - Listo para importar - Detectado: %1$s · %2$s - Las notas marcadas se añaden a esta caja fuerte. No se reemplaza nada y lo que ya está aquí se omite. - Notas con listas de tareas: %1$d - Etiquetas: %1$d - Notas que van a la papelera: %1$d - Ya están en esta caja fuerte, omitidas: %1$d - Repetidas en tu selección, omitidas: %1$d - Destino: esta caja fuerte - Adjuntos que se quedan atrás: %1$d - Notas fijadas que no seguirán fijadas: %1$d - Notas archivadas que llegan como notas normales: %1$d - Colores de nota que no se conservan: %1$d - Etiquetas extra descartadas (una nota tiene una etiqueta): %1$d - Notas de tareas importadas como notas normales: %1$d - Elementos cifrados o no compatibles omitidos: %1$d - Archivos que no se pudieron usar: %1$d - Archivos dentro de archivos, sin abrir: %1$d - Importando… - Importado - %1$d añadidas, %2$d omitidas por duplicadas, %3$d sin importar. - Tus archivos originales no se han modificado. Bórralos tú cuando hayas revisado las notas aquí. - ¿De qué app son? - Los archivos parecen venir de más de una app. Elige la que quieres importar; el resto se omite. - No se importó nada - Esta copia de Standard Notes está cifrada. Descarga una copia descifrada en Standard Notes y elígela. - Es una copia cifrada de Notesnook. En Notesnook exporta las notas como Markdown y elige el .zip. - Estos archivos no son una exportación compatible. Mira las apps compatibles o elige archivos Markdown o de texto. - Esta selección es mayor de lo que Encly puede importar de una vez (64 MB de notas, 2 GB en total o 20 000 notas). Impórtala por partes. - No se encontraron notas en esta selección. - No se pudieron leer los archivos elegidos. - Este teléfono no tiene ninguna app selectora de archivos que Encly pueda usar. - La caja fuerte se cerró durante la importación. Desbloquea Encly e inténtalo de nuevo. - Algo salió mal y no se importó nada. Tu caja fuerte no ha cambiado. - %1$d de %2$d seleccionadas - Seleccionar todo - Quitar selección - Todas - Solo nuevas - En la papelera - Sin título - Lista de tareas - En la papelera - Ya en la caja fuerte - Importar %1$d - Elegir de nuevo - diff --git a/app/src/main/res/values-es/strings_security.xml b/app/src/main/res/values-es/strings_security.xml deleted file mode 100644 index fb3a18b8..00000000 --- a/app/src/main/res/values-es/strings_security.xml +++ /dev/null @@ -1,127 +0,0 @@ - - - Escribe o pega tus 12 palabras - Palabra %1$d de %2$d - La palabra %1$d no es de una frase de recuperación. Revisa cómo está escrita. - Estas 12 palabras no forman una frase de recuperación válida. Revisa cada palabra y su orden. - Esta caja fuerte ya tiene una frase de recuperación - ¿Añadir a tus notas o reemplazarlas? - Esta copia contiene %1$s, %2$s y %3$s. - Todo lo de aquí se queda y se añade lo que falta. Lo que ya está se omite. - Reemplazar todo - Borra todas las notas, tareas y etiquetas de este teléfono y luego restaura la copia. - Guarda todas tus notas, tareas y etiquetas en un archivo cifrado y restáuralas en cualquier teléfono. - Exportar - Importar - Tus notas están cifradas. Tu PIN (y tu huella, si está activada) las desbloquea. - Tus notas están cifradas. Tu PIN (y tu huella, si está activada) las desbloquea, y tu frase de recuperación si olvidas el PIN. - Creada durante la configuración. Encly nunca guarda las palabras y no puede volver a mostrarlas. ¿No las tienes? Sustituye la frase abajo. - Configurada - Después del restablecimiento puedes restaurar un archivo de copia con sus 12 palabras. - Cambiar PIN - - %d nota - %d de notas - %d notas - - - %d tarea - %d de tareas - %d tareas - - - %d etiqueta - %d de etiquetas - %d etiquetas - - - Tu PIN ya no puede desbloquear Encly en este teléfono: su clave de hardware se ha restablecido. Usa tu frase de recuperación (o tu huella) y luego establece un PIN nuevo. - - Demasiados intentos. Vuelve a intentarlo en %d minuto - Demasiados intentos. Vuelve a intentarlo en %d minutos - Demasiados intentos. Vuelve a intentarlo en %d minutos - - - Demasiados intentos. Vuelve a intentarlo en %d hora - Demasiados intentos. Vuelve a intentarlo en %d horas - Demasiados intentos. Vuelve a intentarlo en %d horas - - Reemplazar la frase de recuperación - Obtén 12 palabras nuevas. Las copias hechas antes siguen necesitando las antiguas. - ¿Reemplazar la frase de recuperación? - Obtendrás 12 palabras nuevas. La frase antigua deja de desbloquear tus notas en este teléfono. Las copias de seguridad que ya hiciste solo se abren con la frase antigua, así que guárdala mientras conserves esas copias, o haz una copia nueva después. - Obtener palabras nuevas - Tu frase antigua deja de funcionar aquí - Cuando confirmes, solo estas palabras desbloquearán tus notas si olvidas el PIN. Las copias hechas antes siguen necesitando las palabras antiguas. - Frase de recuperación reemplazada. Haz una copia de seguridad nueva para usarla también en las copias. - Privacidad - Privacidad estricta del teclado - Pide al teclado que no muestre sugerencias, no use predicción en la nube y no lea el texto junto al cursor. Desactiva la autocorrección, y algunos teclados ocultan los emojis o la entrada por voz. - Bloquear al salir - Cuánto tiempo sigue abierto Encly cuando cambias a otra app. Apagar la pantalla o bloquear el teléfono siempre lo bloquea al momento. - Inmediatamente - Tras 15 segundos - 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. - Un PIN ligado a este teléfono - Tu PIN se combina con una clave del chip seguro del teléfono, así que no se puede adivinar en ningún otro sitio. - Una cerradura para cada acceso - El PIN, la frase de recuperación y la huella abren cada uno su propio espacio cifrado. La huella se pide siempre. - Zona de peligro - Mantén 5 segundos para borrar - - PIN de borrado - Un segundo PIN que borra tus notas si lo escribes en la pantalla de bloqueo - Se desactivó al restablecerse la clave de tu PIN. Vuelve a configurarlo si lo usabas. - Si alguien te obliga a abrir Encly, escribe el PIN de borrado en lugar de tu PIN. Encly borra tus notas, tareas y etiquetas sin dejar señal y se abre como una bóveda vacía. Solo funciona en la pantalla de bloqueo. - Tras el borrado, Encly se abre con el PIN de borrado: pasa a ser el PIN de la nueva bóveda vacía. Tu PIN anterior deja de funcionar, así que quien te vio escribir el PIN de borrado puede volver a abrir Encly con él sin notar nada raro. - Los archivos de copia que ya exportaste no se tocan. Se siguen abriendo con tu frase de recuperación y son la única forma de volver atrás. - El desbloqueo biométrico sigue activado: alguien puede obligarte a poner el dedo en el sensor. Desactívalo si eso es un riesgo para ti. - Desactivar el desbloqueo biométrico - Te protege cuando alguien te obliga a desbloquear, no frente a un análisis forense del teléfono. Durante un bloqueo por intentos fallidos el PIN de borrado tampoco funciona. Encly nunca muestra si hay un PIN de borrado: uno nuevo sustituye al anterior, y si cambias tu PIN al PIN de borrado, este se desactiva. - Configurar PIN de borrado - Desactivar PIN de borrado - Confirma tu PIN para configurar el PIN de borrado - Crea un PIN de borrado - 6 dígitos, distintos de tu PIN - Confirma el PIN de borrado - Vuelve a escribir el PIN de borrado - El PIN de borrado debe ser distinto de tu PIN - No se pudo guardar el PIN de borrado. Inténtalo de nuevo. - PIN de borrado configurado - PIN de borrado desactivado - Se restableció la clave de tu PIN, así que el PIN de borrado, si tenías uno, se desactivó. Vuelve a configurarlo si lo quieres. - Este PIN ya está en uso. Elige otro distinto. - PIN de coacción - Un segundo PIN que abre una bóveda aparte con sus propias notas - Desactivado cuando se restableció la clave de tu PIN. Vuelve a configurarlo si quieres uno. - Si alguien te obliga a abrir Encly, escribe el PIN de coacción en lugar de tu PIN. Encly abre una bóveda aparte y vacía que puedes usar con normalidad: las notas que añadas allí se conservan. - Tu bóveda real sigue bloqueada e intacta. A diferencia del PIN de borrado, el PIN de coacción no borra nada. Tu huella y tu frase de recuperación siempre abren la bóveda real. - Te protege cuando alguien te pide que abras la app y se lo enseñes, no frente a un análisis forense del teléfono. Durante un bloqueo tras intentos fallidos, el PIN de coacción tampoco funciona. - Hay un PIN de coacción configurado. - No hay ningún PIN de coacción configurado. - Configurar PIN de coacción - Cambiar PIN de coacción - Desactivar PIN de coacción - Confirma tu PIN para configurar el PIN de coacción - Crea un PIN de coacción - 6 dígitos, distintos de tu PIN - Confirma el PIN de coacción - Introduce el PIN de coacción otra vez - El PIN de coacción debe ser distinto de tu PIN - No se pudo guardar el PIN de coacción. Inténtalo de nuevo. - PIN de coacción configurado. - PIN de coacción cambiado. La bóveda señuelo conserva sus notas. - PIN de coacción desactivado - La clave de tu PIN se restableció, así que el PIN de coacción dejó de funcionar. Configúralo de nuevo; la bóveda señuelo conserva sus notas. - ¿Desactivar el PIN de coacción? - La bóveda señuelo y las notas que contiene se eliminan. Tu bóveda real no se ve afectada. - Desactivar - diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml deleted file mode 100644 index f0605a7d..00000000 --- a/app/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,662 +0,0 @@ - - - À propos - Un espace local et chiffré pour vos notes, idées et tâches, sans synchronisation cloud. - Protection des données - Les données du coffre-fort sont stockées localement dans une base de données SQLCipher chiffrée - Questions fréquentes - Commentaires - Signaler un bug ou proposer une amélioration sur GitHub - Pourquoi c\'est important et comment aider sur Ko-fi - Aucun navigateur trouvé. Ouvrez le lien manuellement : %1$s - Aucune application de messagerie trouvée - Politique de confidentialité - Comment nous stockons et protégeons vos données - Noter l\'application - Laissez une note sur le Google Play Store - Assistance et contact - Contacter le développeur - Si vous avez des questions ou des suggestions, vous pouvez m\'écrire par e-mail. - - Continuer - - Ajouter - - Toutes - - Clé d\'accès obligatoire. Appuyez pour modifier votre code PIN. - Code PIN à 6 chiffres - Authentification - - Retour - - La modification de la protection biométrique n\'a pas été confirmée - Impossible de créer la clé biométrique - Confirmez votre identité - Authentification - Confirmez votre biométrie - Accès à la clé - Confirmez votre biométrie pour modifier la protection - Confirmation de sécurité - Confirmez votre biométrie pour accéder à la clé chiffrée - Déverrouiller Encly - Utiliser le code PIN - L\'authentification biométrique n\'est pas disponible - - Plus d\'outils de bloc - Liste à puces - Liste de contrôle - Titre - Titre %1$d - Lien - Liste - Verrouiller la liste de tâches - Déverrouiller la liste de tâches - Déplacer le bloc vers le bas - Déplacer le bloc vers le haut - Liste numérotée - Séparateur - - Annuler - - - Voulez-vous vraiment supprimer définitivement ces notes ? Cette action est irréversible. - Voulez-vous vraiment vider définitivement la corbeille ? Cette action est irréversible. - Vider la corbeille ? - - Effacer - - Fermer - - Réduire - - Créer un libellé - - Aujourd\'hui %1$s - Hier %1$s - - Supprimer - Supprimer le bloc - Supprimer - Supprimer le libellé - Ce libellé sera retiré de toutes vos notes. Les notes elles-mêmes ne seront pas supprimées. - - Supprimer - Annuler - Voulez-vous vraiment supprimer définitivement cette note ? Cette action est irréversible. - Supprimer la note ? - - Encly est gratuit et open source, sans publicité, sans pistage et sans compte. Les dons sont son seul revenu. - Soutenir le développement - - Terminé - - Dupliquer - - Couleurs dynamiques - - Modifier - - Verrouiller la modification - - Pas encore de notes - Touchez Nouvelle note pour écrire la première. - Les notes supprimées restent ici jusqu’à ce que vous les restauriez ou les supprimiez définitivement. - - Saisissez un lien - - Impossible d\'ouvrir la base de données - - Développer - - Impossible de charger le contenu - - Encly enregistre vos modifications puis, après le délai choisi dans Paramètres → Sécurité (15 secondes par défaut), ferme la base chiffrée et efface la clé de session. Éteindre l’écran ou verrouiller le téléphone le fait immédiatement. Vous déverrouillez ensuite à nouveau. - Que se passe-t-il quand je quitte l\'application ? - Utilisez l\'interrupteur de biométrie dans Paramètres → Sécurité. La création ou la suppression de l\'emplacement biométrique nécessite une authentification biométrique forte. - Comment activer ou désactiver la biométrie ? - Ouvrez Paramètres → Sécurité, confirmez votre code PIN actuel, puis saisissez deux fois le nouveau. - Comment modifier mon code PIN ? - Encly génère une clé de base de données aléatoire de 256 bits. Votre code PIN protège un emplacement de déverrouillage AES-GCM distinct, et la biométrie utilise une clé Android Keystore qui exige une authentification à chaque utilisation. - Comment la clé de la base de données est-elle protégée ? - Sur l’écran de verrouillage, touchez « Récupérer l’accès », saisissez vos 12 mots et définissez un nouveau code PIN. Sans phrase de récupération, il n’y a pas d’autre moyen d’entrer : personne, pas même le développeur, ne peut le réinitialiser. - Et si j\'oublie mon code PIN ? - Ouvrez Paramètres → Langue et choisissez l\'une des langues disponibles, ou « Langue du système » pour suivre votre appareil. Le changement s\'applique immédiatement. - Comment changer la langue ? - Touchez « Nouvelle note » sur l’écran d’accueil. La barre au-dessus du clavier ajoute des titres, des listes de contrôle, des listes à puces et numérotées, des citations, des liens et des séparateurs. - Comment créer une nouvelle note ? - C’est une clé de secours de 12 mots. Elle déverrouille le coffre si vous oubliez votre code PIN et c’est la seule clé de vos fichiers de sauvegarde. Vous la créez lors de la configuration, ou plus tard dans Paramètres → Sécurité si vous l’avez ignorée. Gardez-la sur papier, hors ligne. - À quoi sert la phrase de récupération ? - Ouvrez « À propos » et utilisez l\'option de commentaires, ou écrivez au développeur. N\'incluez jamais votre phrase de récupération, votre code PIN ni le texte privé de vos notes dans un rapport de bug. - Comment signaler un bug ? - Automatiquement pendant la saisie, et quitter l’éditeur termine le dernier enregistrement. Si un enregistrement échoue, l’éditeur affiche « Non enregistré » en rouge. - Comment les modifications sont-elles enregistrées ? - Notes, tâches, chiffrement local et récupération de l\'accès, sans synchronisation cloud. - Réponses aux questions les plus courantes - Non. La protection de l\'écran est toujours active : Encly bloque les captures d\'écran, l\'enregistrement de l\'écran et les aperçus non protégés dans la liste des applications récentes. - Puis-je faire des captures d\'écran d\'Encly ? - Les bases - Sécurité et récupération - Paramètres - Dépannage - Les notes, libellés et tâches sont stockés uniquement dans la base de données SQLCipher locale d\'Encly. Il n\'y a ni synchronisation cloud ni sauvegarde automatique : vous pouvez exporter vous-même une sauvegarde chiffrée dans Paramètres → Sauvegarde, ou une copie Markdown lisible, qui n\'est pas chiffrée. - Où sont stockées mes données ? - Les libellés regroupent vos notes. Vous pouvez les créer, les renommer, les masquer et les supprimer, les réordonner par glisser-déposer et filtrer l’écran d’accueil par libellé. - Comment fonctionnent les libellés ? - Les tâches ont une description et une priorité. Vous pouvez les filtrer par priorité et les marquer comme terminées. Encly n\'affiche jamais le texte des tâches dans les notifications système. - Que permettent les tâches ? - Dans Paramètres → Apparence, choisissez l’un des cinq thèmes, le mode clair, sombre ou système, l’un des trois styles de police et la taille du texte. Sur Android 12 et plus, la couleur d’accent peut aussi venir du fond d’écran. - Comment changer de thème ? - Encly ne crée volontairement jamais une base de données vide à la place d\'un coffre-fort perdu ou illisible. Ne confirmez pas l\'effacement complet si vous avez encore besoin de vos données locales. - Encly indique que le coffre-fort est endommagé - - Taille du texte - Espacement des lignes - Par défaut - Compact - Aéré - Éditorial - Moderne - Technique - - - Choisir la langue - Langue du système - - Ajouter un lien - - Élément de liste - - - Retour au code PIN - Récupérer l\'accès - Saisissez votre phrase de récupération de 12 mots - Récupération - Saisissez votre code PIN à 6 chiffres pour continuer - Déverrouillez vos notes - Déverrouillage… - Récupérer l\'accès avec votre phrase de récupération - Code PIN incorrect - Phrase de récupération incorrecte - - Maintenez pour tout effacer - Les données chiffrées ou les clés de chiffrement semblent avoir été modifiées ou endommagées ; la récupération est donc impossible.\n\nPour continuer à utiliser l\'application, vous devez la réinitialiser et effacer les données endommagées. Toutes les notes et tous les paramètres enregistrés seront définitivement supprimés. - Désolé, vos données sont endommagées - Ces données viennent d\'une version antérieure - Encly a trouvé des notes enregistrées par une version antérieure à la 2.0. Encly 2.0 utilise un nouveau format chiffré et ne peut pas les ouvrir, et il est impossible de les convertir.\n\nPour utiliser l\'application, repartez de zéro. Les anciennes notes et les anciens réglages de ce téléphone seront définitivement supprimés. - Maintenez pour repartir de zéro - - FAQ - Paramètres - Soutenir le développeur - Libellés - Tâches - Corbeille - - Plus d\'options - - - Sans libellé - - Nouvelle note - Impossible de supprimer la note - Description… - Annuler toutes les modifications - Impossible d\'annuler les modifications - Impossible de créer une copie - Impossible de lire le contenu de la note. La modification est désactivée pour ne pas endommager les données. - - %1$s (copie) - Impossible de restaurer la note - Impossible d\'enregistrer les modifications - Impossible de déplacer la note dans la corbeille - - Impossible d\'initialiser l\'application - Impossible de créer la phrase de récupération - Impossible de créer le coffre-fort chiffré - - Ouvrir le menu - - %1$d %% - - Saisissez à nouveau le code PIN pour confirmer - Confirmez votre code PIN à 6 chiffres - Confirmez votre code PIN actuel avant de modifier votre clé d\'accès - Saisissez votre code PIN actuel - Saisissez un nouveau code PIN pour protéger vos notes - Code PIN modifié - Créez un code PIN à 6 chiffres - Le code PIN actuel est incorrect - Effacer le chiffre - Les codes PIN ne correspondent pas. Veuillez réessayer. - Impossible d\'enregistrer le code PIN - Impossible de mettre à jour le code PIN. Veuillez réessayer. - - - Appuyez pour commencer à écrire… - - Haute - Basse - Moyenne - Choisissez une priorité - - Citation - Rétablir - - Restaurer - - Enregistrer - - - - - Effacer la recherche - - Empreinte digitale - Déverrouillage biométrique - Non disponible sur cet appareil - Aucune empreinte enregistrée. Ajoutez-en une dans les paramètres du système. - Le chiffrement est actif - Non configurée. Sans elle, un PIN oublié signifie tout perdre. Touchez pour en créer une. - Effacer toutes les données - Supprimer toutes les notes, tâches et libellés ainsi que les clés de chiffrement de cet appareil - Toutes les notes, tâches et libellés ainsi que les clés de chiffrement seront supprimés de cet appareil, et Encly repartira de zéro. Action irréversible : exportez d\'abord une sauvegarde si vous voulez conserver quelque chose. - Chiffrement et protection des données - Sécurité - - - Apparence - Général - Langue - Confidentialité - Afficher les tâches - Afficher la carte des tâches sur l\'écran d\'accueil - Édition simple - Désactiver les blocs dynamiques et modifier les notes en texte brut - - Trier par : - Par date de création (plus anciennes d\'abord) - Créée ↑ - Par date de création (plus récentes d\'abord) - Créée ↓ - Par dernière modification (plus anciennes d\'abord) - Modifiée ↑ - Par dernière modification (plus récentes d\'abord) - Modifiée ↓ - Soutenir le développeur - - Impossible d\'enregistrer le libellé. Veuillez réessayer. - Impossible de supprimer le libellé. Veuillez réessayer. - Modifier le libellé - Libellé masqué - Un libellé portant ce nom existe déjà - Impossible d\'enregistrer l\'ordre des libellés. Veuillez réessayer. - Impossible de mettre à jour le libellé. Veuillez réessayer. - - Appuyez à nouveau pour supprimer - - Nouvelle tâche - Impossible d\'effacer les tâches terminées. Veuillez réessayer. - Voulez-vous vraiment supprimer toutes les tâches terminées ? Cette action est irréversible. - Effacer les tâches terminées - Terminée : %1$s - Impossible d\'enregistrer la tâche. Veuillez réessayer. - Description - Modifier la tâche - Aucune tâche active. - Aucune tâche ne correspond aux filtres sélectionnés. - Toutes les tâches - Terminées - Nouvelle tâche - Progression - Impossible de modifier l\'état de la tâche. Veuillez réessayer. - Impossible de mettre à jour la tâche. Veuillez réessayer. - - Texte - - Thème - Sombre - Clair - Système - - - Annuler - - Sans titre - - Passer à l\'affichage en grille - Passer à l\'affichage en liste - - - Supprimer %d note ? - Supprimer %d notes ? - Supprimer %d notes ? - - - Trop de tentatives. Réessayez dans %d seconde - Trop de tentatives. Réessayez dans %d secondes - Trop de tentatives. Réessayez dans %d secondes - - - %1$d sur %2$d tâche terminée - %1$d sur %2$d tâches terminées - %1$d sur %2$d tâches terminées - - Sauvegarde - Sauvegarde chiffrée, import et Markdown - Sauvegarde - Chiffrée avec votre phrase de récupération - Une sauvegarde contient toutes vos notes, tâches et libellés, chiffrés avec votre phrase de récupération de 12 mots. Encly ne l\'envoie nulle part : vous choisissez où le fichier est enregistré. Sur un nouveau téléphone, les mêmes 12 mots la restaurent. - Dernier export : %1$s - Aucune sauvegarde exportée pour l\'instant - Exporter une sauvegarde - Importer une sauvegarde - Restaurer notes, tâches et libellés depuis un fichier de sauvegarde - Sauvegarde enregistrée - Confirmez que c\'est vous - Saisissez votre PIN pour continuer - Phrase de récupération - Les sauvegardes sont chiffrées avec une phrase de récupération : seuls ses 12 mots peuvent les ouvrir. Ce coffre n\'en a pas encore. Créez-la maintenant : elle permet aussi de déverrouiller ce coffre-fort si vous oubliez votre PIN. - Créer une phrase de récupération - Notez ces 12 mots sur papier, dans l\'ordre, et gardez-les hors ligne. Quiconque les possède peut ouvrir vos sauvegardes. - Je les ai notés - Les mots ne correspondent pas. Vérifiez ce que vous avez noté. - Phrase de récupération enregistrée - Confirmez votre phrase de récupération - Saisissez une fois la phrase de récupération de 12 mots de ce coffre-fort pour qu\'Encly puisse chiffrer les sauvegardes avec elle. - Saisir la phrase de récupération - Saisissez les 12 mots de la phrase de récupération avec laquelle cette sauvegarde a été faite. - Déchiffrer - Importer une sauvegarde - Fusionner - Remplacer - Remplacer toutes les données ? - Toutes les notes, tâches et libellés de ce coffre-fort seront supprimés et remplacés par la sauvegarde. Cette action est irréversible. - Importé : notes %1$d, tâches %2$d, libellés %3$d. Ignoré (déjà présent) : %4$d. - Ce fichier n\'est pas une sauvegarde Encly - Cette sauvegarde a été créée par une version plus récente d\'Encly. Mettez l\'app à jour et réessayez. - Le fichier de sauvegarde est endommagé ou incomplet - Le fichier est trop volumineux pour être une sauvegarde Encly - Votre coffre-fort est trop volumineux pour un seul fichier de sauvegarde (64 Mo). Rien n\'a été enregistré. - Ce fichier existe déjà. Choisissez un autre nom : une ancienne sauvegarde n\'est jamais écrasée. - Aucune application de cet appareil ne peut ouvrir ou enregistrer des fichiers. - Ces mots n\'ouvrent pas cette sauvegarde, ou le fichier a été modifié - Saisissez une phrase de récupération valide de 12 mots - Le coffre-fort a été verrouillé. Déverrouillez-le et réessayez. - Impossible de lire ou d\'écrire la sauvegarde. Rien n\'a été modifié. - Ces mots ne sont pas la phrase de récupération de ce coffre-fort - Impossible d\'enregistrer la phrase de récupération - Restaurer une sauvegarde - Choisir un fichier de sauvegarde - La sauvegarde n\'a pas pu être restaurée, la configuration n\'est donc pas terminée. Réessayez, ou continuez avec un coffre-fort vide et importez le fichier plus tard dans Paramètres → Sauvegarde. - Sauvegarde non restaurée - Réessayer - Continuer sans - Cette version d\'Encly ne peut pas lire cette note. - Ouvrir le lien - Copier le lien - Modifier le lien - Lien copié - Aucune application de cet appareil ne peut ouvrir ce lien. - Seuls les liens web (http, https) et e-mail peuvent être enregistrés ou ouverts. - Impossible de lire vos notes - Impossible de lire vos libellés - Vos notes sont toujours stockées chiffrées sur cet appareil. Fermez Encly puis rouvrez-le pour réessayer. - Vous avez déverrouillé Encly avec votre phrase de récupération. Définissez un nouveau code PIN pour continuer. - Supprimer la tâche - Tâche supprimée - Impossible de supprimer la tâche. Veuillez réessayer. - Mode - Police - Papier - Forêt - Océan - Graphite - Minuit - Suit le réglage du système. Affichage clair. - Suit le réglage du système. Affichage sombre. - Toujours clair. - Toujours sombre. - Minuit est toujours sombre. - Prend la couleur d\'accent de votre fond d\'écran. Android 12 et versions ultérieures. - Les couleurs dynamiques sont activées : l\'accent vient de votre fond d\'écran. - Playfair Display et Source Sans 3 - Poppins et Source Sans 3 - IBM Plex Sans partout - Liste pour la visite de l\'appartement - Avant la visite : vérifier la pression de l\'eau, demander le coût du chauffage l\'hiver dernier. - Personnel - Aperçu - Thème, mode et police - Correct - Chiffres saisis : %1$d sur %2$d - Déverrouiller avec l\'empreinte - Étape %1$d sur %2$d - Un carnet que vous seul pouvez ouvrir. - Notes et tâches, chiffrées sur ce téléphone. La configuration prend environ une minute. - Aucun compte - Pas d\'inscription, pas d\'e-mail, pas de réinitialisation de mot de passe. - Fonctionne hors ligne - Encly ne se connecte jamais à Internet. - Chiffré sur l\'appareil - Votre coffre est verrouillé par une clé que vous seul détenez. - Commencer - J\'ai une sauvegarde - Choisissez un code PIN à 6 chiffres - Il déverrouille Encly et protège la clé de votre coffre. Aucun e-mail ne permet de le réinitialiser. - Saisissez à nouveau le code PIN - Pour être sûr que c\'est bien celui que vous vouliez. - Le code PIN reste disponible en secours - Douze mots pour tout récupérer - Encly va vous donner une phrase de récupération. Notez-la sur papier : c\'est la seule clé de secours. - Code PIN oublié ? - La phrase déverrouille votre coffre et permet de définir un nouveau code PIN. - Ouvre vos sauvegardes - Les fichiers de sauvegarde chiffrés ne peuvent être lus qu\'avec ces mots. - Nouveau téléphone - Le fichier de sauvegarde et la phrase restaurent chaque note, tâche et libellé. - Personne ne peut la récupérer à votre place, pas même nous. Si vous perdez le code PIN et la phrase, les notes sont perdues. - Créer ma phrase - Passer : code PIN seul, sans sauvegardes - Notez ces mots - Sur papier, dans cet ordre. Pas en capture d\'écran, pas dans le cloud. - Masquer les mots - Afficher les mots - Je les ai notés - Vérifiez trois mots - Recopiez-les depuis votre papier, pas de mémoire. - Mot n° %1$d - Saisissez le mot - Ce n\'est pas le mot n° %1$d - Confirmer - Revoir les mots - Votre coffre est prêt - Tout ce que vous écrivez désormais est chiffré avant d\'être enregistré. - Code PIN défini - Demandé à chaque ouverture d\'Encly - Déverrouillage par empreinte activé - Modifiable dans Paramètres, Sécurité - Phrase de récupération vérifiée - Gardez le papier en lieu sûr - Aucune phrase de récupération - Les sauvegardes sont désactivées. Vous pourrez créer une phrase plus tard dans Paramètres, Sécurité. - Sauvegarde restaurée - Vos notes, tâches et libellés sont de retour - Astuce : exportez de temps en temps une sauvegarde chiffrée depuis Paramètres, Sauvegarde. C\'est votre phrase qui l\'ouvre. - Ouvrir mon carnet - Restaurer, puis définir un PIN - Choisissez votre fichier de sauvegarde Encly, puis saisissez les 12 mots avec lesquels il a été créé. - 1 · Fichier de sauvegarde - Touchez pour choisir un autre fichier - 2 · Phrase de récupération - Ces mots deviennent aussi la phrase de récupération de ce téléphone. - Notes - Verrouiller maintenant - Rechercher dans les titres et le texte - Options d\'affichage - Aucun résultat pour « %1$s » - Aujourd\'hui - Hier - - Tâches · %d en cours - Tâches · %d en cours - Tâches · %d en cours - - Tâches · aucune en cours - Pas encore de tâches - Touchez Nouvelle tâche pour ajouter la première. - Tout est fait - Terminées · %1$d - Aucune note avec l’étiquette « %1$s » - Les notes portant cette étiquette apparaîtront ici. - Afficher toutes les notes - La corbeille est vide - Touchez une note pour la lire. Appui long pour sélectionner. - Supprimer définitivement - Vider la corbeille - Rechercher des notes - Notes et tâches - Version, confidentialité et contact - Réponses aux questions fréquentes - Licences open source - Les bibliothèques et polices utilisées par Encly - Construit sur l’open source - Encly repose sur ces bibliothèques et polices. Touchez-en une pour lire sa licence. - Bibliothèques - Polices - Pas encore d’étiquettes - Les étiquettes regroupent vos notes. Créez-en une ci-dessus, puis choisissez-la pour une note par un appui long. - Touchez une étiquette pour la renommer, la masquer ou la supprimer. Faites glisser la poignée pour réordonner. - Ajouter l’étiquette - Nom - Afficher dans la liste des notes - Une étiquette masquée garde ses notes mais n’a pas de filtre - Faire glisser pour réordonner - Monter - Descendre - - %d sélectionnée - %d sélectionnées - %d sélectionnées - - Modifier les libellés - Titre - Non enregistré - Gérer les libellés - Annuler les modifications ? - Tout ce que vous avez modifié depuis l\'ouverture de cette note sera perdu. Cette action est irréversible. - Tout est déjà à vous - Chaque fonctionnalité est accessible à tous. Rien n’est bloqué derrière un paiement. - Sans pub ni pistage - Encly ne voit jamais vos notes et ne gagne rien avec vos données. Seuls les dons le financent. - À quoi sert l\'argent - Des appareils de test (prochain objectif : un Mac mini pour compiler et tester) et du temps pour les versions et les corrections. - Carte ou PayPal, ponctuel ou mensuel - Ouvre ko-fi.com dans votre navigateur. Encly lui-même ne gère jamais de paiement. - Que fait un appui long sur une note ? - Il ouvre les actions de la note : changer son libellé, ajouter une courte description affichée sur la carte, dupliquer la note ou la mettre à la corbeille. - Comment récupérer une note supprimée ? - Les notes supprimées vont dans la Corbeille du menu. Vous pouvez y restaurer une note ou la supprimer définitivement. Encly ne vide jamais la corbeille de lui-même. - Que fait « Verrouiller la modification » ? - La note passe en lecture seule pour éviter toute modification accidentelle. Vous pouvez toujours sélectionner et copier le texte. Activez-le ou désactivez-le depuis le menu de l’éditeur. - Comment fonctionnent les sauvegardes ? - Dans Paramètres → Sauvegarde, vous exportez un fichier chiffré que seule votre phrase de récupération peut ouvrir. Pour restaurer, choisissez le fichier, saisissez ou collez les 12 mots, puis fusionnez-le avec ce coffre ou remplacez tout. - 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 - Ajouter une sous-tâche - Supprimer la sous-tâche - Toutes les sous-tâches sont terminées - Terminer la tâche - Afficher les sous-tâches - Masquer les sous-tâches - Développé - Réduit - Sous-tâche supprimée - Prochaine étape : %1$s - Première étape - - %1$d sur %2$d sous-tâche terminée - %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 - Épingler - Désépingler - Archiver - Désarchiver - Épinglées - Autres - Archives - Les archives sont vides - Les notes archivées quittent la liste et la recherche mais restent dans le coffre-fort. Archivez une note depuis son menu. - Impossible d\'archiver la note - Impossible de désarchiver la note - Couleur - Aucune couleur - Rouge - Orange - Jaune - Vert - Sarcelle - Bleu - Violet - Rose - Exporter en Markdown - Copier en Markdown - Copié en Markdown - Export lisible - Enregistrer toutes les notes et tâches en fichiers .md lisibles dans un .zip. Le fichier n\'est pas chiffré. - Exporter sans chiffrement ? - Le fichier exporté n\'est pas chiffré. Quiconque l\'obtient peut lire vos notes. Là où vous l\'enregistrez, y compris dans un dossier cloud, se trouvera une copie lisible qu\'Encly ne peut ni protéger ni reprendre. Vous confirmerez ensuite avec votre PIN. - Export Markdown enregistré - Puis-je exporter mes notes en Markdown ? - Oui. Dans le menu d\'une note, choisissez « Exporter en Markdown » pour un fichier .md, ou allez dans Paramètres → Sauvegarde → « Exporter en Markdown » pour un .zip de toutes les notes (celles archivées dans un dossier archive, sans la corbeille) et un fichier de tâches. Ces fichiers ne sont pas chiffrés : Encly vous prévient d\'abord, demande votre PIN et n\'enregistre qu\'à l\'endroit que vous choisissez. - Actions par balayage - Balayer les notes - Balayez une carte de note pour agir dessus - Balayer vers la droite - Balayer vers la gauche - Épingler ou désépingler - Couleur suivante - Mettre à la corbeille - Rien - Mettre à la corbeille ? - Vous pourrez restaurer la note depuis la corbeille. - À quoi servent les balayages sur une note ? - Par défaut, balayer une note vers la droite l\'épingle ou la désépingle, et vers la gauche l\'archive. Dans Paramètres → Actions par balayage, vous choisissez pour chaque direction : épingler, archiver, couleur suivante, mettre à la corbeille (avec confirmation) ou rien, ou vous désactivez les balayages. TalkBack propose les mêmes actions dans le menu d\'actions de la note. - Texte des notes - La valeur par défaut de chaque note. Une note peut définir la sienne depuis son menu. - Utiliser les réglages de texte par défaut - Code source - Encly est open source : lisez le code sur GitHub - diff --git a/app/src/main/res/values-fr/strings_import.xml b/app/src/main/res/values-fr/strings_import.xml deleted file mode 100644 index da70ab91..00000000 --- a/app/src/main/res/values-fr/strings_import.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - Importer depuis d\'autres applications - Importer des notes - Depuis Google Keep, Standard Notes, Joplin, Notesnook ou des fichiers Markdown - Récupérez vos notes d\'une autre application. Choisissez son export et Encly reconnaît le format tout seul. Tout reste sur ce téléphone ; rien n\'est envoyé. - Choisir des fichiers - Choisir un dossier - Applications prises en charge - Comment exporter - Non importé - Google Keep - .zip Google Takeout (à choisir tel quel, sans le décompresser) ou notes .json isolées - Dans Google Takeout, sélectionnez seulement Keep, créez l\'export et téléchargez le .zip. Choisissez ensuite ce .zip ici. - L\'état épinglé et archivé, les couleurs et les pièces jointes. Seule la première étiquette devient le tag. - Standard Notes - Sauvegarde déchiffrée (.txt ou .json) ou .zip d\'un téléchargement déchiffré - Dans Standard Notes, téléchargez une sauvegarde déchiffrée, pas une chiffrée. Choisissez ensuite le fichier ou le .zip ici. - Les sauvegardes chiffrées sont refusées. L\'état épinglé et archivé, les fichiers et les autres types d\'éléments. Seul le premier tag est conservé. - Joplin - Archive JEX (.jex), dossier RAW ou export Markdown - Dans Joplin, choisissez Fichier → Tout exporter, puis JEX ou RAW. Choisissez ensuite le fichier ou le dossier ici. - Les pièces jointes et l\'état des tâches (les tâches arrivent comme des notes ordinaires). Une note reçoit son premier tag, sinon le nom de son carnet. - Notesnook - Export Markdown, Markdown avec front matter ou TXT (.zip) - Dans Notesnook, exportez vos notes en Markdown avec front matter pour garder dates et tags. Choisissez ensuite le .zip ici. Les sauvegardes chiffrées ne sont pas importables. - Les pièces jointes, l\'état épinglé et archivé, les couleurs. Le premier tag, sinon le carnet, devient le tag. - Markdown et texte - Fichiers .md et .txt ou un dossier entier, par exemple d\'Obsidian ou de Markor - Choisissez les fichiers ou le dossier qui les contient. Le nom du fichier devient le titre ; le front matter simple (titre, tags, dates) est lu. - Les images, pièces jointes, plugins et la mise en forme au-delà des titres, listes, listes de tâches et citations. Le premier dossier devient le tag. - Lecture et reconnaissance de vos fichiers… - Prêt à importer - Détecté : %1$s · %2$s - Les notes cochées sont ajoutées à ce coffre-fort. Rien n\'est remplacé et ce qui s\'y trouve déjà est ignoré. - Notes avec listes de tâches : %1$d - Tags : %1$d - Notes qui vont à la corbeille : %1$d - Déjà dans ce coffre-fort, ignorées : %1$d - Répétées dans votre sélection, ignorées : %1$d - Destination : ce coffre-fort - Pièces jointes laissées de côté : %1$d - Notes épinglées qui ne le resteront pas : %1$d - Notes archivées qui arrivent comme notes normales : %1$d - Couleurs de note non conservées : %1$d - Tags en trop abandonnés (une note a un tag) : %1$d - Notes de tâche importées comme notes ordinaires : %1$d - Éléments chiffrés ou non pris en charge ignorés : %1$d - Fichiers inutilisables : %1$d - Archives dans des archives, non ouvertes : %1$d - Importation… - Importé - %1$d ajoutées, %2$d ignorées comme doublons, %3$d non importées. - Vos fichiers d\'origine n\'ont pas été modifiés. Supprimez-les vous-même une fois vos notes vérifiées ici. - De quelle application viennent-ils ? - Les fichiers semblent venir de plusieurs applications. Choisissez celle à importer ; le reste est ignoré. - Rien n\'a été importé - Cette sauvegarde Standard Notes est chiffrée. Téléchargez une sauvegarde déchiffrée dans Standard Notes et choisissez-la. - C\'est une sauvegarde Notesnook chiffrée. Dans Notesnook, exportez plutôt vos notes en Markdown et choisissez le .zip. - Ces fichiers ne sont pas un export pris en charge. Consultez les applications prises en charge ou choisissez des fichiers Markdown ou texte. - Cette sélection dépasse ce qu\'Encly peut importer d\'un coup (64 Mo de notes, 2 Go au total ou 20 000 notes). Importez-la en plusieurs parties. - Aucune note n\'a été trouvée dans cette sélection. - Les fichiers choisis n\'ont pas pu être lus. - Ce téléphone n\'a pas d\'application de sélection de fichiers qu\'Encly puisse utiliser. - Le coffre-fort s\'est fermé pendant l\'importation. Déverrouillez Encly et réessayez. - Une erreur s\'est produite et rien n\'a été importé. Votre coffre-fort est inchangé. - %1$d sur %2$d sélectionnées - Tout sélectionner - Tout désélectionner - Toutes - Nouvelles seulement - Dans la corbeille - Sans titre - Liste de tâches - Dans la corbeille - Déjà dans le coffre-fort - Importer %1$d - Choisir à nouveau - diff --git a/app/src/main/res/values-fr/strings_security.xml b/app/src/main/res/values-fr/strings_security.xml deleted file mode 100644 index e603440f..00000000 --- a/app/src/main/res/values-fr/strings_security.xml +++ /dev/null @@ -1,127 +0,0 @@ - - - Saisissez ou collez vos 12 mots - Mot %1$d sur %2$d - Le mot %1$d ne fait pas partie des mots de récupération. Vérifiez l\'orthographe. - Ces 12 mots ne forment pas une phrase de récupération valide. Vérifiez chaque mot et leur ordre. - Ce coffre-fort a déjà une phrase de récupération - Ajouter à vos notes ou les remplacer ? - Cette sauvegarde contient %1$s, %2$s et %3$s. - Tout ce qui est ici est conservé et ce qui manque est ajouté. Les éléments déjà présents sont ignorés. - Tout remplacer - Supprimer toutes les notes, tâches et étiquettes de ce téléphone, puis restaurer la sauvegarde. - Conservez toutes vos notes, tâches et étiquettes dans un fichier chiffré et restaurez-les sur n\'importe quel téléphone. - Exporter - Importer - Vos notes sont chiffrées. Votre code PIN (et votre empreinte, si elle est activée) les déverrouille. - Vos notes sont chiffrées. Votre code PIN (et votre empreinte, si elle est activée) les déverrouille, et votre phrase de récupération si vous oubliez le PIN. - Créée lors de la configuration. Encly ne conserve jamais les mots et ne peut pas les réafficher. Vous ne les avez plus ? Remplacez la phrase ci-dessous. - Configurée - Après la réinitialisation, vous pourrez restaurer un fichier de sauvegarde avec ses 12 mots. - Changer le code PIN - - %d note - %d de notes - %d notes - - - %d tâche - %d de tâches - %d tâches - - - %d étiquette - %d d\'étiquettes - %d étiquettes - - - Votre PIN ne peut plus déverrouiller Encly sur ce téléphone : sa clé matérielle a été réinitialisée. Utilisez votre phrase de récupération (ou votre empreinte), puis définissez un nouveau PIN. - - Trop de tentatives. Réessayez dans %d minute - Trop de tentatives. Réessayez dans %d minutes - Trop de tentatives. Réessayez dans %d minutes - - - Trop de tentatives. Réessayez dans %d heure - Trop de tentatives. Réessayez dans %d heures - Trop de tentatives. Réessayez dans %d heures - - Remplacer la phrase de récupération - Obtenez 12 nouveaux mots. Les sauvegardes faites avant nécessitent toujours les anciens. - Remplacer la phrase de récupération ? - Vous obtiendrez 12 nouveaux mots. L\'ancienne phrase ne déverrouille plus vos notes sur ce téléphone. Les sauvegardes déjà faites ne s\'ouvrent qu\'avec l\'ancienne phrase : gardez-la tant que vous conservez ces sauvegardes, ou faites une nouvelle sauvegarde ensuite. - Obtenir de nouveaux mots - Votre ancienne phrase ne fonctionne plus ici - Une fois confirmé, seuls ces mots déverrouillent vos notes si vous oubliez le PIN. Les sauvegardes faites avant nécessitent toujours les anciens mots. - Phrase de récupération remplacée. Faites une nouvelle sauvegarde pour l\'utiliser aussi pour les sauvegardes. - Confidentialité - Confidentialité stricte du clavier - Demande au clavier de ne pas faire de suggestions, pas de prédiction dans le cloud et pas de lecture autour du curseur. Désactive la correction automatique, et certains claviers masquent les emoji ou la saisie vocale. - Verrouiller après la sortie - Durée pendant laquelle Encly reste ouvert quand vous passez à une autre app. Éteindre l’écran ou verrouiller le téléphone le verrouille toujours aussitôt. - Immédiatement - Après 15 secondes - 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. - Un code PIN lié à ce téléphone - Votre code PIN est combiné à une clé de la puce sécurisée du téléphone : impossible de le deviner ailleurs. - Une serrure par moyen d’accès - Le code PIN, la phrase de récupération et l’empreinte ouvrent chacun leur propre emplacement chiffré. L’empreinte est demandée à chaque fois. - Zone de danger - Maintenez 5 secondes pour effacer - - PIN d\'effacement - Un second PIN qui efface vos notes s\'il est saisi sur l\'écran de verrouillage - Désactivé lors de la réinitialisation de la clé de votre PIN. Définissez-le à nouveau si vous en utilisiez un. - Si quelqu\'un vous force à ouvrir Encly, saisissez le PIN d\'effacement au lieu de votre PIN. Encly efface vos notes, tâches et libellés sans aucun signe et s\'ouvre comme un coffre vide. Cela ne fonctionne que sur l\'écran de verrouillage. - Après l\'effacement, Encly s\'ouvre avec le PIN d\'effacement : il devient le PIN du nouveau coffre vide. Votre ancien PIN ne fonctionne plus. La personne qui vous a vu saisir le PIN d\'effacement peut rouvrir Encly avec et ne remarque rien d\'inhabituel. - Les fichiers de sauvegarde déjà exportés ne sont pas touchés. Ils s\'ouvrent toujours avec votre phrase de récupération et sont le seul moyen de revenir en arrière. - Le déverrouillage biométrique reste actif : quelqu\'un peut vous forcer à poser le doigt sur le capteur. Désactivez-le si c\'est un risque pour vous. - Désactiver le déverrouillage biométrique - Cela vous protège quand quelqu\'un vous force à déverrouiller, pas contre une analyse forensique du téléphone. Pendant un blocage après des essais ratés, le PIN d\'effacement ne fonctionne pas non plus. Encly n\'indique jamais si un PIN d\'effacement est défini : un nouveau remplace l\'ancien, et si vous changez votre PIN pour le PIN d\'effacement, celui-ci est désactivé. - Définir le PIN d\'effacement - Désactiver le PIN d\'effacement - Confirmez votre PIN pour configurer le PIN d\'effacement - Créez un PIN d\'effacement - 6 chiffres, différents de votre PIN - Confirmez le PIN d\'effacement - Saisissez à nouveau le PIN d\'effacement - Le PIN d\'effacement doit être différent de votre PIN - Impossible d\'enregistrer le PIN d\'effacement. Veuillez réessayer. - PIN d\'effacement défini - PIN d\'effacement désactivé - La clé de votre PIN a été réinitialisée : un éventuel PIN d\'effacement a donc été désactivé. Définissez-le à nouveau si vous le souhaitez. - Ce PIN est déjà utilisé. Choisissez-en un autre. - PIN de contrainte - Un second PIN qui ouvre un coffre séparé avec ses propres notes - Désactivé lorsque la clé de votre PIN a été réinitialisée. Définissez-le à nouveau si vous en voulez un. - Si quelqu\'un vous force à ouvrir Encly, saisissez le PIN de contrainte au lieu de votre PIN. Encly ouvre un coffre séparé et vide que vous pouvez utiliser normalement : les notes que vous y ajoutez y restent. - Votre vrai coffre reste verrouillé et intact. Contrairement au PIN d\'effacement, le PIN de contrainte n\'efface rien. Votre empreinte et votre phrase de récupération ouvrent toujours le vrai coffre. - Il vous protège quand quelqu\'un vous demande d\'ouvrir l\'appli et de tout lui montrer, pas contre un examen forensique du téléphone. Pendant un verrouillage après des essais ratés, le PIN de contrainte ne fonctionne pas non plus. - Un PIN de contrainte est défini. - Aucun PIN de contrainte n\'est défini. - Définir le PIN de contrainte - Modifier le PIN de contrainte - Désactiver le PIN de contrainte - Confirmez votre PIN pour configurer le PIN de contrainte - Créer un PIN de contrainte - 6 chiffres, différents de votre PIN - Confirmer le PIN de contrainte - Saisissez à nouveau le PIN de contrainte - Le PIN de contrainte doit être différent de votre PIN - Impossible d\'enregistrer le PIN de contrainte. Veuillez réessayer. - PIN de contrainte défini. - PIN de contrainte modifié. Le coffre leurre conserve ses notes. - PIN de contrainte désactivé - La clé de votre PIN a été réinitialisée, le PIN de contrainte ne fonctionne donc plus. Définissez-le à nouveau ; le coffre leurre conserve ses notes. - Désactiver le PIN de contrainte ? - Le coffre leurre et les notes qu\'il contient sont supprimés. Votre vrai coffre n\'est pas affecté. - Désactiver - diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml deleted file mode 100644 index 1b2bf3f5..00000000 --- a/app/src/main/res/values-it/strings.xml +++ /dev/null @@ -1,662 +0,0 @@ - - - Informazioni - Uno spazio locale e crittografato per note, idee e attività, senza sincronizzazione nel cloud. - Protezione dei dati - I dati della cassaforte sono salvati localmente in un database SQLCipher crittografato - Domande frequenti - Feedback - Segnala un bug o suggerisci un miglioramento su GitHub - Perché conta e come contribuire su Ko-fi - Nessun browser trovato. Apri il link manualmente: %1$s - Nessuna app di posta trovata - Informativa sulla privacy - Come salviamo e proteggiamo i tuoi dati - Valuta l\'app - Lascia una valutazione sul Google Play Store - Assistenza e contatti - Contatta lo sviluppatore - Se hai domande o suggerimenti, puoi scrivermi un\'email. - - Continua - - Aggiungi - - Tutte - - Chiave di accesso obbligatoria. Tocca per cambiare il PIN. - PIN a 6 cifre - Autenticazione - - Indietro - - La modifica della protezione biometrica non è stata confermata - Impossibile creare la chiave biometrica - Conferma la tua identità - Autenticazione - Conferma la biometria - Accesso alla chiave - Conferma la biometria per modificare la protezione - Conferma di sicurezza - Conferma la biometria per accedere alla chiave crittografata - Sblocca Encly - Usa il PIN - L\'autenticazione biometrica non è disponibile - - Altri strumenti di blocco - Elenco puntato - Checklist - Titolo - Titolo %1$d - Link - Elenco - Blocca la lista di controllo - Sblocca la lista di controllo - Sposta blocco giù - Sposta blocco su - Elenco numerato - Separatore - - Annulla - - - Vuoi davvero eliminare definitivamente queste note? L\'operazione non può essere annullata. - Vuoi davvero svuotare definitivamente il cestino? L\'operazione non può essere annullata. - Svuotare il cestino? - - Cancella - - Chiudi - - Comprimi - - Crea una nuova etichetta - - Oggi %1$s - Ieri %1$s - - Elimina - Elimina blocco - Elimina - Elimina etichetta - Questa etichetta verrà rimossa da tutte le tue note. Le note non verranno eliminate. - - Elimina - Annulla - Vuoi davvero eliminare definitivamente questa nota? L\'operazione non può essere annullata. - Eliminare la nota? - - Encly è gratuito e open source, senza pubblicità, tracciamento né account. Le donazioni sono il suo unico introito. - Sostieni lo sviluppo - - Fine - - Duplica - - Colori dinamici - - Modifica - - Blocca la modifica - - Ancora nessuna nota - Tocca Nuova nota per scrivere la prima. - Le note eliminate restano qui finché non le ripristini o le elimini per sempre. - - Inserisci un link - - Impossibile aprire il database - - Espandi - - Impossibile caricare il contenuto - - Encly salva le modifiche e, dopo il ritardo scelto in Impostazioni → Sicurezza (15 secondi per impostazione predefinita), chiude il database crittografato e cancella la chiave di sessione. Spegnere lo schermo o bloccare il telefono lo fa subito. Poi sblocchi di nuovo. - Cosa succede quando esco dall\'app? - Usa l\'interruttore della biometria in Impostazioni → Sicurezza. Creare o rimuovere lo slot biometrico richiede un\'autenticazione biometrica forte. - Come attivo o disattivo la biometria? - Apri Impostazioni → Sicurezza, conferma il PIN attuale e poi inserisci due volte quello nuovo. - Come cambio il PIN? - Encly genera una chiave del database casuale a 256 bit. Il PIN protegge uno slot di sblocco AES-GCM separato e la biometria usa una chiave Android Keystore che richiede l\'autenticazione a ogni utilizzo. - Come è protetta la chiave del database? - Nella schermata di blocco tocca «Recupera l’accesso», inserisci le 12 parole e imposta un nuovo PIN. Senza frase di recupero non c’è altro modo di entrare: nessuno, nemmeno lo sviluppatore, può reimpostarlo. - Cosa succede se dimentico il PIN? - Apri Impostazioni → Lingua e scegli una delle lingue disponibili, oppure «Predefinita di sistema» per usare la lingua del dispositivo. La modifica si applica subito. - Come cambio la lingua? - Tocca «Nuova nota» nella schermata principale. La barra sopra la tastiera aggiunge titoli, checklist, elenchi puntati e numerati, citazioni, link e separatori. - Come creo una nuova nota? - È una chiave di riserva di 12 parole. Sblocca la cassaforte se dimentichi il PIN ed è l’unica chiave dei tuoi file di backup. La crei durante la configurazione, o più tardi in Impostazioni → Sicurezza se l’hai saltata. Conservala su carta, offline. - A cosa serve la frase di recupero? - Apri «Informazioni» e usa l\'opzione di feedback, oppure scrivi allo sviluppatore. Non includere mai la frase di recupero, il PIN o il testo privato delle note in una segnalazione di bug. - Come segnalo un bug? - Automaticamente mentre scrivi, e uscire dall’editor completa l’ultimo salvataggio. Se un salvataggio non riesce, l’editor mostra «Non salvato» in rosso. - Come vengono salvate le modifiche? - Note, attività, crittografia locale e recupero dell\'accesso, senza sincronizzazione nel cloud. - Risposte alle domande più comuni - No. La protezione dello schermo è sempre attiva: Encly blocca screenshot, registrazione dello schermo e anteprime non protette nell\'elenco delle app recenti. - Posso fare screenshot di Encly? - Nozioni di base - Sicurezza e recupero - Impostazioni - Risoluzione dei problemi - Note, etichette e attività sono salvate solo nel database SQLCipher locale di Encly. Non c\'è sincronizzazione nel cloud né backup automatico: puoi esportare tu stesso un backup crittografato in Impostazioni → Backup, oppure una copia Markdown leggibile, che non è crittografata. - Dove sono salvati i miei dati? - Le etichette raggruppano le note. Puoi crearle, rinominarle, nasconderle ed eliminarle, trascinarle nell’ordine che vuoi e filtrare la schermata principale per etichetta. - Come funzionano le etichette? - Le attività hanno una descrizione e una priorità. Puoi filtrarle per priorità e segnarle come completate. Encly non mostra mai il testo delle attività nelle notifiche di sistema. - Cosa possono fare le attività? - In Impostazioni → Aspetto scegli uno dei cinque temi, la modalità chiara, scura o di sistema, uno dei tre stili di carattere e la dimensione del testo. Da Android 12 in poi puoi anche prendere il colore d’accento dallo sfondo. - Come cambio il tema? - Encly di proposito non crea mai un database vuoto al posto di una cassaforte persa o illeggibile. Non confermare la cancellazione completa se ti servono ancora i dati locali. - Encly segnala che la cassaforte è danneggiata - - Dimensione del testo - Interlinea - Predefinita - Compatta - Ampia - Editoriale - Moderno - Tecnico - - - Scegli la lingua - Predefinita di sistema - - Aggiungi link - - Elemento dell\'elenco - - - Torna al PIN - Recupera l\'accesso - Inserisci la tua frase di recupero di 12 parole - Recupero - Inserisci il PIN a 6 cifre per continuare - Sblocca le tue note - Sblocco in corso… - Recupera l\'accesso con la frase di recupero - PIN errato - Frase di recupero errata - - Tieni premuto per cancellare tutto - Sembra che i dati crittografati o le chiavi di crittografia siano stati modificati o danneggiati, quindi il recupero non è possibile.\n\nPer continuare a usare l\'app devi reimpostarla e cancellare i dati danneggiati. Tutte le note e le impostazioni salvate verranno eliminate definitivamente. - Purtroppo i tuoi dati sono danneggiati - Questi dati provengono da una versione precedente - Encly ha trovato note salvate da una versione precedente alla 2.0. Encly 2.0 usa un nuovo formato crittografato e non può aprirle, né è possibile convertirle.\n\nPer usare l\'app, ricomincia da capo. Le vecchie note e impostazioni su questo telefono verranno eliminate definitivamente. - Tieni premuto per ricominciare da capo - - FAQ - Impostazioni - Sostieni lo sviluppatore - Etichette - Attività - Cestino - - Altre opzioni - - - Nessuna etichetta - - Nuova nota - Impossibile eliminare la nota - Descrizione… - Annulla tutte le modifiche - Impossibile annullare le modifiche - Impossibile creare una copia - Impossibile leggere il contenuto della nota. La modifica è disattivata per non danneggiare i dati. - - %1$s (copia) - Impossibile ripristinare la nota - Impossibile salvare le modifiche - Impossibile spostare la nota nel cestino - - Impossibile inizializzare l\'app - Impossibile creare la frase di recupero - Impossibile creare la cassaforte crittografata - - Apri il menu - - %1$d%% - - Inserisci di nuovo il PIN per confermare - Conferma il PIN a 6 cifre - Conferma il PIN attuale prima di cambiare la chiave di accesso - Inserisci il PIN attuale - Inserisci un nuovo PIN per proteggere le tue note - PIN cambiato - Crea un PIN a 6 cifre - Il PIN attuale non è corretto - Cancella cifra - I PIN non corrispondono. Riprova. - Impossibile salvare il PIN - Impossibile aggiornare il PIN. Riprova. - - - Tocca per iniziare a scrivere… - - Alta - Bassa - Media - Scegli una priorità - - Citazione - Ripeti - - Ripristina - - Salva - - - - - Cancella ricerca - - Impronta digitale - Sblocco biometrico - Non disponibile su questo dispositivo - Nessuna impronta registrata. Aggiungine una nelle impostazioni di sistema. - La crittografia è attiva - Non configurata. Senza di essa, un PIN dimenticato significa perdere tutto. Tocca per crearne una. - Cancella tutti i dati - Elimina tutte le note, le attività, le etichette e le chiavi di cifratura su questo dispositivo - Tutte le note, le attività, le etichette e le chiavi di cifratura verranno eliminate da questo dispositivo ed Encly ripartirà da zero. L\'operazione non si può annullare: esporta prima un backup se vuoi conservare qualcosa. - Crittografia e protezione dei dati - Sicurezza - - - Aspetto - Generali - Lingua - Privacy - Mostra attività - Mostra la scheda delle attività nella schermata principale - Modifica semplice - Disattiva i blocchi dinamici e modifica le note come testo semplice - - Ordina per: - Per data di creazione (prima le meno recenti) - Creata ↑ - Per data di creazione (prima le più recenti) - Creata ↓ - Per ultima modifica (prima le meno recenti) - Modificata ↑ - Per ultima modifica (prima le più recenti) - Modificata ↓ - Sostieni lo sviluppatore - - Impossibile salvare l\'etichetta. Riprova. - Impossibile eliminare l\'etichetta. Riprova. - Modifica etichetta - Etichetta nascosta - Esiste già un\'etichetta con questo nome - Impossibile salvare l\'ordine delle etichette. Riprova. - Impossibile aggiornare l\'etichetta. Riprova. - - Tocca di nuovo per eliminare - - Nuova attività - Impossibile cancellare le attività completate. Riprova. - Vuoi davvero eliminare tutte le attività completate? L\'operazione non può essere annullata. - Cancella le attività completate - Completata: %1$s - Impossibile salvare l\'attività. Riprova. - Descrizione - Modifica attività - Nessuna attività in corso. - Nessuna attività corrisponde ai filtri selezionati. - Tutte le attività - Completate - Nuova attività - Avanzamento - Impossibile cambiare lo stato dell\'attività. Riprova. - Impossibile aggiornare l\'attività. Riprova. - - Testo - - Tema - Scuro - Chiaro - Sistema - - - Annulla - - Senza titolo - - Passa alla vista a griglia - Passa alla vista a elenco - - - Eliminare %d nota? - Eliminare %d note? - Eliminare %d note? - - - Troppi tentativi. Riprova tra %d secondo - Troppi tentativi. Riprova tra %d secondi - Troppi tentativi. Riprova tra %d secondi - - - %1$d di %2$d attività completata - %1$d di %2$d attività completate - %1$d di %2$d attività completate - - Backup - Backup crittografato, importazione e Markdown - Backup - Crittografato con la tua frase di recupero - Un backup contiene tutte le tue note, attività ed etichette, crittografate con la tua frase di recupero di 12 parole. Encly non lo carica da nessuna parte: scegli tu dove salvare il file. Su un nuovo telefono, le stesse 12 parole lo ripristinano. - Ultima esportazione: %1$s - Nessun backup ancora esportato - Esporta backup - Importa backup - Ripristina note, attività ed etichette da un file di backup - Backup salvato - Conferma che sei tu - Inserisci il PIN per continuare - Frase di recupero - I backup sono crittografati con una frase di recupero, quindi solo le sue 12 parole possono aprirli. Questa cassaforte non ne ha ancora una. Creala ora: ti permette anche di sbloccare la cassaforte se dimentichi il PIN. - Crea frase di recupero - Scrivi queste 12 parole su carta, in ordine, e conservale offline. Chiunque le abbia può aprire i tuoi backup. - Le ho scritte - Le parole non corrispondono. Controlla quello che hai scritto. - Frase di recupero salvata - Conferma la frase di recupero - Inserisci una volta la frase di recupero di 12 parole di questa cassaforte, così Encly può crittografare i backup con essa. - Inserisci la frase di recupero - Inserisci le 12 parole della frase di recupero con cui è stato creato questo backup. - Decrittografa - Importa backup - Unisci - Sostituisci - Sostituire tutti i dati? - Tutte le note, attività ed etichette di questa cassaforte verranno eliminate e sostituite dal backup. Non si può annullare. - Importato: note %1$d, attività %2$d, etichette %3$d. Saltato (già presente): %4$d. - Questo file non è un backup di Encly - Questo backup è stato creato da una versione più recente di Encly. Aggiorna l\'app e riprova. - Il file di backup è danneggiato o incompleto - Il file è troppo grande per essere un backup di Encly - La cassaforte è troppo grande per un solo file di backup (64 MB). Non è stato salvato nulla. - Questo file esiste già. Scegli un nuovo nome: un backup precedente non viene mai sovrascritto. - Nessuna app su questo dispositivo può aprire o salvare file. - Queste parole non aprono questo backup, oppure il file è stato modificato - Inserisci una frase di recupero valida di 12 parole - La cassaforte è stata bloccata. Sbloccala e riprova. - Impossibile leggere o scrivere il backup. Non è stato modificato nulla. - Queste parole non sono la frase di recupero di questa cassaforte - Impossibile salvare la frase di recupero - Ripristina da un backup - Scegli un file di backup - Impossibile ripristinare il backup, quindi la configurazione non è ancora completa. Riprova oppure continua con una cassaforte vuota e importa il file più tardi in Impostazioni → Backup. - Backup non ripristinato - Riprova - Continua senza - Questa versione di Encly non riesce a leggere questa nota. - Apri link - Copia link - Modifica link - Link copiato - Nessuna app su questo dispositivo può aprire questo link. - Si possono salvare o aprire solo link web (http, https) ed email. - Impossibile leggere le note - Impossibile leggere le etichette - Le note sono ancora salvate e cifrate su questo dispositivo. Chiudi Encly e riaprilo per riprovare. - Hai sbloccato Encly con la frase di recupero. Imposta un nuovo PIN per continuare. - Elimina attività - Attività eliminata - Impossibile eliminare l\'attività. Riprova. - Modalità - Carattere - Carta - Foresta - Oceano - Grafite - Mezzanotte - Segue l\'impostazione di sistema. Ora chiaro. - Segue l\'impostazione di sistema. Ora scuro. - Sempre chiaro. - Sempre scuro. - Mezzanotte è sempre scuro. - Prende il colore d\'accento dallo sfondo. Android 12 e versioni successive. - I colori dinamici sono attivi: l\'accento viene dallo sfondo. - Playfair Display e Source Sans 3 - Poppins e Source Sans 3 - IBM Plex Sans ovunque - Checklist visita appartamento - Prima della visita: controllare la pressione dell\'acqua, chiedere quanto è costato il riscaldamento lo scorso inverno. - Personale - Anteprima - Tema, modalità e carattere - Corretto - Cifre inserite: %1$d di %2$d - Sblocca con l\'impronta - Passo %1$d di %2$d - Un taccuino che solo tu puoi aprire. - Note e attività, cifrate su questo telefono. La configurazione richiede circa un minuto. - Nessun account - Nessuna registrazione, nessuna email, nessun reset della password. - Funziona offline - Encly non si connette mai a internet. - Cifrato sul dispositivo - La tua cassaforte è chiusa con una chiave che hai solo tu. - Inizia - Ho un backup - Scegli un PIN di 6 cifre - Sblocca Encly e protegge la chiave della tua cassaforte. Non c\'è un\'email per reimpostarlo. - Inserisci di nuovo il PIN - Così sappiamo che è quello che volevi. - Il PIN resta disponibile come alternativa - Dodici parole riportano tutto indietro - Encly ti darà una frase di recupero. Scrivila su carta: è l\'unica chiave di riserva. - PIN dimenticato? - La frase sblocca la cassaforte e ti permette di impostare un nuovo PIN. - Apre i tuoi backup - I file di backup cifrati si leggono solo con queste parole. - Nuovo telefono - File di backup più frase ripristinano ogni nota, attività ed etichetta. - Nessuno può recuperarla per te, nemmeno noi. Se perdi sia il PIN sia la frase, le note sono perse. - Crea la mia frase - Salta: solo PIN, niente backup - Scrivi queste parole - Su carta, in quest\'ordine. Non in uno screenshot, non nel cloud. - Nascondi parole - Mostra parole - Le ho scritte - Controlla tre parole - Scrivile dalla tua copia su carta, non a memoria. - Parola n. %1$d - Inserisci la parola - Non è la parola n. %1$d - Conferma - Mostra di nuovo le parole - La tua cassaforte è pronta - Tutto ciò che scrivi da ora in poi viene cifrato prima di essere salvato. - PIN impostato - Richiesto a ogni apertura di Encly - Sblocco con impronta attivo - Puoi cambiarlo in Impostazioni, Sicurezza - Frase di recupero verificata - Conserva il foglio in un posto sicuro - Nessuna frase di recupero - I backup sono disattivati. Puoi creare una frase più tardi in Impostazioni, Sicurezza. - Backup ripristinato - Le tue note, attività ed etichette sono tornate - Suggerimento: ogni tanto esporta un backup cifrato da Impostazioni, Backup. È la tua frase ad aprirlo. - Apri il mio taccuino - Ripristina, poi imposta un PIN - Scegli il file di backup di Encly, poi inserisci le 12 parole con cui è stato creato. - 1 · File di backup - Tocca per scegliere un altro file - 2 · Frase di recupero - Queste parole diventano anche la frase di recupero di questo telefono. - Note - Blocca ora - Cerca nei titoli e nel testo - Opzioni di visualizzazione - Nessun risultato per «%1$s» - Oggi - Ieri - - Attività · %d aperta - Attività · %d aperte - Attività · %d aperte - - Attività · nessuna aperta - Ancora nessuna attività - Tocca Nuova attività per aggiungere la prima. - Tutto fatto - Completate · %1$d - Nessuna nota con il tag «%1$s» - Qui compariranno le note con questo tag. - Mostra tutte le note - Il cestino è vuoto - Tocca una nota per leggerla. Tieni premuto per selezionare. - Elimina per sempre - Svuota cestino - Cerca note - Note e attività - Versione, privacy e contatti - Risposte alle domande frequenti - Licenze open source - Le librerie e i font usati da Encly - Costruito sull’open source - Encly è fatto con queste librerie e questi font. Toccane uno per leggerne la licenza. - Librerie - Font - Ancora nessun tag - I tag raggruppano le note. Creane uno qui sopra, poi sceglilo per una nota tenendola premuta. - Tocca un tag per rinominarlo, nasconderlo o eliminarlo. Trascina la maniglia per riordinare. - Aggiungi tag - Nome - Mostra nell’elenco delle note - Un tag nascosto mantiene le note ma non ha un filtro - Trascina per riordinare - Sposta su - Sposta giù - - %d selezionata - %d selezionate - %d selezionate - - Modifica etichette - Titolo - Non salvato - Gestisci etichette - Annullare le modifiche? - Tutto ciò che hai modificato da quando hai aperto questa nota andrà perso. L\'operazione non può essere annullata. - È già tutto tuo - Ogni funzione è disponibile per tutti. Niente è bloccato dietro un pagamento. - Niente pubblicità né tracciamento - Encly non vede mai le tue note e non guadagna dai dati. Solo le donazioni lo sostengono. - Dove vanno i fondi - Dispositivi di test (il prossimo obiettivo: un Mac mini per compilare e testare) e tempo per rilasci e correzioni. - Carta o PayPal, una tantum o mensile - Apre ko-fi.com nel browser. L\'app Encly non gestisce mai i pagamenti. - Cosa fa una pressione prolungata su una nota? - Apre le azioni della nota: cambiare l’etichetta, aggiungere una breve descrizione mostrata sulla scheda, duplicare la nota o spostarla nel cestino. - Come recupero una nota eliminata? - Le note eliminate finiscono nel Cestino del menu. Lì puoi ripristinare una nota o eliminarla per sempre. Encly non svuota mai il cestino da solo. - Cosa fa «Blocca la modifica»? - Rende la nota di sola lettura, così non la modifichi per sbaglio. Puoi comunque selezionare e copiare il testo. Si attiva e disattiva dal menu dell’editor. - Come funzionano i backup? - In Impostazioni → Backup esporti un file crittografato che solo la tua frase di recupero può aprire. Per ripristinare, scegli il file, digita o incolla le 12 parole, poi uniscilo a questa cassaforte o sostituisci tutto. - Mostra tastiera - Nascondi tastiera - Cancellare tutti i dati? - Impossibile cancellare i dati. Non è stato modificato nulla. - Cancella - Sottoattività - Sottoattività - Aggiungi sottoattività - Elimina sottoattività - Tutte le sottoattività sono completate - Completa l\'attività - Mostra sottoattività - Nascondi sottoattività - Espanso - Compresso - Sottoattività eliminata - Prossimo passo: %1$s - Primo passo - - %1$d di %2$d sottoattività completata - %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 - Fissa - Non fissare più - Archivia - Rimuovi dall\'archivio - Fissate - Altre - Archivio - L\'archivio è vuoto - Le note archiviate escono dall\'elenco e dalla ricerca ma restano nella cassaforte. Archivia una nota dal suo menu. - Impossibile archiviare la nota - Impossibile rimuovere la nota dall\'archivio - Colore - Nessun colore - Rosso - Arancione - Giallo - Verde - Turchese - Blu - Viola - Rosa - Esporta come Markdown - Copia come Markdown - Copiato come Markdown - Esportazione leggibile - Salva tutte le note e le attività come file .md leggibili in uno .zip. Il file non è cifrato. - Esportare senza cifratura? - Il file esportato non è cifrato. Chiunque lo ottenga può leggere le tue note. Ovunque tu lo salvi, anche in una cartella cloud, ci sarà una copia leggibile che Encly non può proteggere né ritirare. Poi confermerai con il tuo PIN. - Esportazione Markdown salvata - Posso esportare le note come Markdown? - Sì. Nel menu di una nota scegli «Esporta come Markdown» per un file .md, oppure vai su Impostazioni → Backup → «Esporta come Markdown» per uno .zip con tutte le note (quelle archiviate in una cartella archive, senza il cestino) e un file delle attività. Questi file non sono crittografati: Encly ti avvisa prima, chiede il PIN e salva solo dove scegli tu. - Azioni con scorrimento - Scorri le note - Scorri una nota per agire su di essa - Scorri a destra - Scorri a sinistra - Fissa o non fissare - Colore successivo - Sposta nel cestino - Niente - Spostare nel cestino? - Potrai ripristinare la nota dal cestino. - A cosa servono gli scorrimenti su una nota? - Per impostazione predefinita, scorrere una nota a destra la fissa o la sblocca, a sinistra la archivia. In Impostazioni → Azioni con scorrimento puoi scegliere per ogni direzione: fissa, archivia, colore successivo, sposta nel cestino (chiede sempre conferma) o niente, oppure disattivare gli scorrimenti. TalkBack offre le stesse azioni nel menu delle azioni della nota. - Testo delle note - Il valore predefinito di ogni nota. Una nota può impostare il proprio dal suo menu. - Usa le impostazioni di testo predefinite - Codice sorgente - Encly è open source: leggi il codice su GitHub - diff --git a/app/src/main/res/values-it/strings_import.xml b/app/src/main/res/values-it/strings_import.xml deleted file mode 100644 index 8061f494..00000000 --- a/app/src/main/res/values-it/strings_import.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - Importa da altre app - Importa note - Da Google Keep, Standard Notes, Joplin, Notesnook o file Markdown - Porta qui le note di un\'altra app. Scegli la sua esportazione e Encly riconosce il formato da solo. Tutto resta su questo telefono; non viene caricato nulla. - Scegli file - Scegli cartella - App supportate - Come esportare - Non importato - Google Keep - .zip di Google Takeout (sceglilo così com\'è, senza decomprimerlo) o singole note .json - In Google Takeout seleziona solo Keep, crea l\'esportazione e scarica il .zip. Poi scegli quel .zip qui. - Stato fissata e archiviata, colori e allegati. Solo la prima etichetta diventa il tag. - Standard Notes - Backup decifrato (.txt o .json) o il .zip di un download decifrato - In Standard Notes scarica un backup decifrato, non uno cifrato. Poi scegli il file o il .zip qui. - I backup cifrati vengono rifiutati. Stato fissata e archiviata, file e altri tipi di elementi. Viene mantenuto solo il primo tag. - Joplin - Archivio JEX (.jex), cartella RAW o esportazione Markdown - In Joplin scegli File → Esporta tutto e poi JEX o RAW. Poi scegli il file o la cartella qui. - Allegati e stato delle attività (le attività arrivano come note normali). Una nota riceve il suo primo tag, altrimenti il nome del suo taccuino. - Notesnook - Esportazione Markdown, Markdown con front matter o TXT (.zip) - In Notesnook esporta le note come Markdown con front matter per mantenere date e tag. Poi scegli il .zip qui. I backup cifrati non si possono importare. - Allegati, stato fissata e archiviata, colori. Il primo tag, altrimenti il taccuino, diventa il tag. - Markdown e testo - File .md e .txt o un\'intera cartella, per esempio da Obsidian o Markor - Scegli i file o la cartella che li contiene. Il nome del file diventa il titolo; il front matter semplice (titolo, tag, date) viene letto. - Immagini, allegati, plugin e formattazione oltre a titoli, elenchi, checklist e citazioni. La prima cartella diventa il tag. - Lettura e riconoscimento dei file… - Pronto per l\'importazione - Rilevato: %1$s · %2$s - Le note selezionate vengono aggiunte a questa cassaforte. Nulla viene sostituito e ciò che c\'è già viene saltato. - Note con checklist: %1$d - Tag: %1$d - Note che vanno nel cestino: %1$d - Già in questa cassaforte, saltate: %1$d - Ripetute nella tua selezione, saltate: %1$d - Destinazione: questa cassaforte - Allegati lasciati indietro: %1$d - Note fissate che non resteranno fissate: %1$d - Note archiviate che arrivano come note normali: %1$d - Colori delle note non mantenuti: %1$d - Tag in più scartati (una nota ha un tag): %1$d - Note attività importate come note normali: %1$d - Elementi cifrati o non supportati saltati: %1$d - File non utilizzabili: %1$d - Archivi dentro archivi, non aperti: %1$d - Importazione… - Importato - %1$d aggiunte, %2$d saltate perché duplicate, %3$d non importate. - I tuoi file originali non sono stati modificati. Eliminali tu quando hai controllato le note qui. - Di quale app sono? - I file sembrano provenire da più di un\'app. Scegli quella da importare; il resto viene saltato. - Non è stato importato nulla - Questo backup di Standard Notes è cifrato. Scarica un backup decifrato in Standard Notes e scegli quello. - Questo è un backup cifrato di Notesnook. In Notesnook esporta invece le note come Markdown e scegli il .zip. - Questi file non sono un\'esportazione supportata. Guarda le app supportate o scegli file Markdown o di testo. - Questa selezione è più grande di quanto Encly possa importare in una volta (64 MB di note, 2 GB in totale o 20.000 note). Importala a parti. - Nessuna nota trovata in questa selezione. - Non è stato possibile leggere i file scelti. - Questo telefono non ha un\'app di selezione file che Encly possa usare. - La cassaforte si è chiusa durante l\'importazione. Sblocca Encly e riprova. - Qualcosa è andato storto e non è stato importato nulla. La tua cassaforte è invariata. - %1$d di %2$d selezionate - Seleziona tutto - Deseleziona tutto - Tutte - Solo nuove - Nel cestino - Senza titolo - Checklist - Nel cestino - Già nella cassaforte - Importa %1$d - Scegli di nuovo - diff --git a/app/src/main/res/values-it/strings_security.xml b/app/src/main/res/values-it/strings_security.xml deleted file mode 100644 index ba9a4ec1..00000000 --- a/app/src/main/res/values-it/strings_security.xml +++ /dev/null @@ -1,127 +0,0 @@ - - - Scrivi o incolla le tue 12 parole - Parola %1$d di %2$d - La parola %1$d non è una parola della frase di recupero. Controlla come l\'hai scritta. - Queste 12 parole non formano una frase di recupero valida. Controlla ogni parola e il loro ordine. - Questa cassaforte ha già una frase di recupero - Aggiungere alle tue note o sostituirle? - Questo backup contiene %1$s, %2$s e %3$s. - Tutto ciò che è qui resta e si aggiunge ciò che manca. Gli elementi già presenti vengono saltati. - Sostituisci tutto - Elimina tutte le note, le attività e i tag su questo telefono, poi ripristina il backup. - Conserva tutte le note, le attività e i tag in un file cifrato e ripristinali su qualsiasi telefono. - Esporta - Importa - Le tue note sono cifrate. Il tuo PIN (e l\'impronta, se attiva) le sblocca. - Le tue note sono cifrate. Il tuo PIN (e l\'impronta, se attiva) le sblocca, e la frase di recupero se dimentichi il PIN. - Creata durante la configurazione. Encly non salva mai le parole e non può mostrarle di nuovo. Non le hai? Sostituisci la frase qui sotto. - Configurata - Dopo il ripristino potrai recuperare un file di backup con le sue 12 parole. - Cambia PIN - - %d nota - %d di note - %d note - - - %d attività - %d di attività - %d attività - - - %d tag - %d di tag - %d tag - - - Il tuo PIN non può più sbloccare Encly su questo telefono: la sua chiave hardware è stata reimpostata. Usa la frase di recupero (o l\'impronta), poi imposta un nuovo PIN. - - Troppi tentativi. Riprova tra %d minuto - Troppi tentativi. Riprova tra %d minuti - Troppi tentativi. Riprova tra %d minuti - - - Troppi tentativi. Riprova tra %d ora - Troppi tentativi. Riprova tra %d ore - Troppi tentativi. Riprova tra %d ore - - Sostituisci la frase di recupero - Ricevi 12 nuove parole. I backup fatti prima richiedono ancora quelle vecchie. - Sostituire la frase di recupero? - Riceverai 12 nuove parole. La vecchia frase non sblocca più le tue note su questo telefono. I backup già fatti si aprono solo con la vecchia frase: conservala finché conservi quei backup, oppure fai un nuovo backup dopo. - Ricevi nuove parole - La vecchia frase qui non funziona più - Una volta confermato, solo queste parole sbloccano le tue note se dimentichi il PIN. I backup fatti prima richiedono ancora le vecchie parole. - Frase di recupero sostituita. Fai un nuovo backup per usarla anche per i backup. - Privacy - Privacy rigorosa della tastiera - Chiede alla tastiera nessun suggerimento, nessuna previsione nel cloud e nessuna lettura intorno al cursore. Disattiva la correzione automatica e alcune tastiere nascondono emoji o input vocale. - Blocca dopo l’uscita - Per quanto Encly resta aperto quando passi a un’altra app. Spegnere lo schermo o bloccare il telefono lo blocca sempre subito. - Subito - Dopo 15 secondi - 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. - Un PIN legato a questo telefono - Il PIN è combinato con una chiave del chip sicuro del telefono, quindi non si può indovinare altrove. - Una serratura per ogni accesso - PIN, frase di recupero e impronta aprono ciascuno il proprio spazio cifrato. L’impronta viene chiesta ogni volta. - Zona pericolosa - Tieni premuto 5 secondi per cancellare - - PIN di cancellazione - Un secondo PIN che cancella le tue note se lo digiti nella schermata di blocco - Disattivato quando la chiave del PIN è stata reimpostata. Impostalo di nuovo se ne usavi uno. - Se qualcuno ti costringe ad aprire Encly, digita il PIN di cancellazione invece del tuo PIN. Encly cancella note, attività ed etichette senza lasciare segni e si apre come una cassaforte vuota. Funziona solo nella schermata di blocco. - Dopo la cancellazione, Encly si apre con il PIN di cancellazione: diventa il PIN della nuova cassaforte vuota. Il vecchio PIN smette di funzionare, così chi ti ha visto digitare il PIN di cancellazione può riaprire Encly con esso senza notare nulla di strano. - I file di backup che hai già esportato non vengono toccati. Si aprono ancora con la frase di recupero e sono l\'unico modo per tornare indietro. - Lo sblocco biometrico resta attivo: qualcuno può costringerti ad appoggiare il dito sul sensore. Disattivalo se per te è un rischio. - Disattiva lo sblocco biometrico - Ti protegge quando qualcuno ti costringe a sbloccare, non da un\'analisi forense del telefono. Durante un blocco dopo tentativi errati anche il PIN di cancellazione non funziona. Encly non mostra mai se un PIN di cancellazione è impostato: uno nuovo sostituisce il precedente, e se cambi il tuo PIN nel PIN di cancellazione, questo viene disattivato. - Imposta PIN di cancellazione - Disattiva PIN di cancellazione - Conferma il tuo PIN per configurare il PIN di cancellazione - Crea un PIN di cancellazione - 6 cifre, diverse dal tuo PIN - Conferma il PIN di cancellazione - Digita di nuovo il PIN di cancellazione - Il PIN di cancellazione deve essere diverso dal tuo PIN - Impossibile salvare il PIN di cancellazione. Riprova. - PIN di cancellazione impostato - PIN di cancellazione disattivato - La chiave del tuo PIN è stata reimpostata, quindi l\'eventuale PIN di cancellazione è stato disattivato. Impostalo di nuovo se ti serve. - Questo PIN è già in uso. Scegline un altro. - PIN di coercizione - Un secondo PIN che apre una cassaforte separata con note proprie - Disattivato quando la chiave del tuo PIN è stata reimpostata. Impostalo di nuovo se ne vuoi uno. - Se qualcuno ti costringe ad aprire Encly, digita il PIN di coercizione invece del tuo PIN. Encly apre una cassaforte separata e vuota che puoi usare normalmente: le note che aggiungi lì restano al suo interno. - La tua vera cassaforte resta bloccata e intatta. A differenza del PIN di cancellazione, il PIN di coercizione non cancella nulla. La tua impronta e la frase di recupero aprono sempre la cassaforte vera. - Ti protegge quando qualcuno ti dice di aprire l\'app e mostrargliela, non da un esame forense del telefono. Durante un blocco dopo tentativi falliti anche il PIN di coercizione non funziona. - Un PIN di coercizione è impostato. - Nessun PIN di coercizione è impostato. - Imposta PIN di coercizione - Cambia PIN di coercizione - Disattiva PIN di coercizione - Conferma il tuo PIN per impostare il PIN di coercizione - Crea un PIN di coercizione - 6 cifre, diverse dal tuo PIN - Conferma il PIN di coercizione - Digita di nuovo il PIN di coercizione - Il PIN di coercizione deve essere diverso dal tuo PIN - Impossibile salvare il PIN di coercizione. Riprova. - PIN di coercizione impostato. - PIN di coercizione cambiato. La cassaforte civetta conserva le sue note. - PIN di coercizione disattivato - La chiave del tuo PIN è stata reimpostata, quindi il PIN di coercizione ha smesso di funzionare. Impostalo di nuovo: la cassaforte civetta conserva le sue note. - Disattivare il PIN di coercizione? - La cassaforte civetta e le note che contiene vengono eliminate. La tua vera cassaforte non è toccata. - Disattiva - diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml deleted file mode 100644 index afe9db6d..00000000 --- a/app/src/main/res/values-nl/strings.xml +++ /dev/null @@ -1,656 +0,0 @@ - - - Over de app - Een lokale, versleutelde plek voor notities, ideeën en taken, zonder cloudsynchronisatie. - Gegevensbescherming - De kluisgegevens worden lokaal opgeslagen in een versleutelde SQLCipher-database - Veelgestelde vragen - Feedback - Meld een bug of stel een verbetering voor op GitHub - Waarom het telt en hoe je helpt via Ko-fi - Geen browser gevonden. Open de link handmatig: %1$s - Geen e-mailapp gevonden - Privacybeleid - Hoe we je gegevens opslaan en beschermen - App beoordelen - Geef een beoordeling in de Google Play Store - Ondersteuning en contact - Contact opnemen met de ontwikkelaar - Heb je vragen of suggesties? Stuur me gerust een e-mail. - - Doorgaan - - Toevoegen - - Alle - - Verplichte toegangssleutel. Tik om je pincode te wijzigen. - Pincode van 6 cijfers - Verificatie - - Terug - - De wijziging van de biometrische beveiliging is niet bevestigd - Kan de biometrische sleutel niet maken - Bevestig je identiteit - Verificatie - Bevestig je biometrie - Toegang tot de sleutel - Bevestig je biometrie om de beveiliging te wijzigen - Beveiligingsbevestiging - Bevestig je biometrie om toegang te krijgen tot de versleutelde sleutel - Encly ontgrendelen - Pincode gebruiken - Biometrische verificatie is niet beschikbaar - - Meer bloktools - Lijst met opsommingstekens - Checklist - Kop - Kop %1$d - Link - Lijst - Checklist vergrendelen - Checklist ontgrendelen - Blok omlaag verplaatsen - Blok omhoog verplaatsen - Genummerde lijst - Scheidingslijn - - Annuleren - - - Weet je zeker dat je deze notities definitief wilt verwijderen? Dit kan niet ongedaan worden gemaakt. - Weet je zeker dat je de prullenbak definitief wilt legen? Dit kan niet ongedaan worden gemaakt. - Prullenbak legen? - - Wissen - - Sluiten - - Samenvouwen - - Nieuw label maken - - Vandaag %1$s - Gisteren %1$s - - Verwijderen - Blok verwijderen - Verwijderen - Label verwijderen - Dit label wordt uit al je notities verwijderd. De notities zelf worden niet verwijderd. - - Verwijderen - Annuleren - Weet je zeker dat je deze notitie definitief wilt verwijderen? Dit kan niet ongedaan worden gemaakt. - Notitie verwijderen? - - Encly is gratis en open source, zonder advertenties, tracking en accounts. Donaties zijn de enige inkomsten. - Steun de ontwikkeling - - Klaar - - Dupliceren - - Dynamische kleuren - - Bewerken - - Bewerken vergrendelen - - Nog geen notities - Tik op Nieuwe notitie om je eerste te schrijven. - Verwijderde notities blijven hier tot je ze herstelt of definitief verwijdert. - - Voer een link in - - Kan de database niet openen - - Uitvouwen - - Kan de inhoud niet laden - - Encly slaat je wijzigingen op en sluit na de vertraging die je in Instellingen → Beveiliging kiest (standaard 15 seconden) de versleutelde database en wist de sessiesleutel. Het scherm uitzetten of de telefoon vergrendelen doet dit meteen. Daarna ontgrendel je opnieuw. - Wat gebeurt er als ik de app verlaat? - Gebruik de biometrieschakelaar in Instellingen → Beveiliging. Voor het maken of verwijderen van het biometrische slot is sterke biometrische verificatie nodig. - Hoe zet ik biometrie aan of uit? - Open Instellingen → Beveiliging, bevestig je huidige pincode en voer daarna twee keer de nieuwe in. - Hoe wijzig ik mijn pincode? - Encly maakt een willekeurige databasesleutel van 256 bits. Je pincode beschermt een afzonderlijk AES-GCM-ontgrendelslot en biometrie gebruikt een Android Keystore-sleutel die bij elk gebruik verificatie vereist. - Hoe is de databasesleutel beveiligd? - Tik op het vergrendelscherm op ‘Toegang herstellen’, voer je 12 woorden in en stel een nieuwe pincode in. Zonder herstelzin is er geen andere weg naar binnen: niemand, ook de ontwikkelaar niet, kan hem resetten. - Wat als ik mijn pincode vergeet? - Open Instellingen → Taal en kies een van de beschikbare talen, of \'Systeemstandaard\' om de taal van je apparaat te volgen. De wijziging gaat meteen in. - Hoe wijzig ik de taal? - Tik op het startscherm op ‘Nieuwe notitie’. De balk boven het toetsenbord voegt koppen, checklists, opsommingen, genummerde lijsten, citaten, links en scheidingslijnen toe. - Hoe maak ik een nieuwe notitie? - Het is een reservesleutel van 12 woorden. Hij ontgrendelt de kluis als je je pincode vergeet en is de enige sleutel tot je back-upbestanden. Je maakt hem tijdens het instellen, of later via Instellingen → Beveiliging als je hem hebt overgeslagen. Bewaar hem op papier, offline. - Waarvoor is de herstelzin? - Open \'Over de app\' en gebruik de feedbackoptie, of schrijf de ontwikkelaar. Voeg nooit je herstelzin, pincode of privétekst uit notities toe aan een bugmelding. - Hoe meld ik een bug? - Automatisch terwijl je typt, en als je de editor verlaat wordt de laatste opslag afgerond. Mislukt het opslaan ooit, dan toont de editor ‘Niet opgeslagen’ in rood. - Hoe worden wijzigingen opgeslagen? - Notities, taken, lokale versleuteling en toegangsherstel, zonder cloudsynchronisatie. - Antwoorden op de meest gestelde vragen - Nee. Schermbeveiliging staat altijd aan: Encly blokkeert screenshots, schermopnames en onbeveiligde voorbeelden in de lijst met recente apps. - Kan ik screenshots maken van Encly? - De basis - Beveiliging en herstel - Instellingen - Problemen oplossen - Notities, labels en taken worden alleen opgeslagen in de lokale SQLCipher-database van Encly. Er is geen cloudsynchronisatie en geen automatische back-up: je kunt zelf een versleutelde back-up exporteren via Instellingen → Back-up, of een leesbare Markdown-kopie, die niet versleuteld is. - Waar worden mijn gegevens opgeslagen? - Labels groeperen je notities. Je kunt ze maken, hernoemen, verbergen en verwijderen, in elke volgorde slepen en het startscherm op label filteren. - Hoe werken labels? - Taken hebben een beschrijving en een prioriteit. Je kunt ze op prioriteit filteren en als voltooid markeren. Encly zet nooit de tekst van taken in systeemmeldingen. - Wat kun je met taken? - Via Instellingen → Weergave kies je een van vijf thema’s, lichte, donkere of systeemmodus, een van drie letterstijlen en de tekstgrootte. Vanaf Android 12 kan de accentkleur ook van je achtergrond komen. - Hoe wijzig ik het thema? - Encly maakt bewust nooit een lege database aan in plaats van een verloren of onleesbare kluis. Bevestig het volledig wissen niet als je je lokale gegevens nog nodig hebt. - Encly meldt dat de kluis beschadigd is - - Tekstgrootte - Regelafstand - Standaard - Compact - Ruim - Redactioneel - Modern - Technisch - - - Taal kiezen - Systeemstandaard - - Link toevoegen - - Lijstitem - - - Terug naar pincode - Toegang herstellen - Voer je herstelzin van 12 woorden in - Herstel - Voer je pincode van 6 cijfers in om door te gaan - Ontgrendel je notities - Ontgrendelen… - Toegang herstellen met je herstelzin - Onjuiste pincode - Onjuiste herstelzin - - Houd ingedrukt om alles te wissen - Het lijkt erop dat de versleutelde gegevens of de versleutelingssleutels zijn gewijzigd of beschadigd, waardoor herstel niet mogelijk is.\n\nOm de app te blijven gebruiken, moet je hem resetten en de beschadigde gegevens wissen. Hierdoor worden alle opgeslagen notities en instellingen definitief verwijderd. - Helaas zijn je gegevens beschadigd - Deze gegevens komen uit een oudere versie - Encly heeft notities gevonden die zijn opgeslagen door een versie van vóór 2.0. Encly 2.0 gebruikt een nieuwe versleutelde indeling en kan ze niet openen, en omzetten is niet mogelijk.\n\nBegin opnieuw om de app te gebruiken. De oude notities en instellingen op deze telefoon worden dan definitief verwijderd. - Houd ingedrukt om opnieuw te beginnen - - Veelgestelde vragen - Instellingen - Steun de ontwikkelaar - Labels - Taken - Prullenbak - - Meer opties - - - Geen label - - Nieuwe notitie - Kan de notitie niet verwijderen - Beschrijving… - Alle wijzigingen negeren - Kan de wijzigingen niet negeren - Kan geen kopie maken - Kan de inhoud van de notitie niet lezen. Bewerken is uitgeschakeld zodat de gegevens niet beschadigd raken. - - %1$s (kopie) - Kan de notitie niet terugzetten - Kan de wijzigingen niet opslaan - Kan de notitie niet naar de prullenbak verplaatsen - - Kan de app niet initialiseren - Kan de herstelzin niet maken - Kan de versleutelde kluis niet maken - - Menu openen - - %1$d%% - - Voer de pincode nogmaals in om te bevestigen - Bevestig je pincode van 6 cijfers - Bevestig je huidige pincode voordat je je toegangssleutel wijzigt - Voer je huidige pincode in - Voer een nieuwe pincode in om je notities te beschermen - Pincode gewijzigd - Maak een pincode van 6 cijfers - De huidige pincode is onjuist - Cijfer wissen - De pincodes komen niet overeen. Probeer het opnieuw. - Kan de pincode niet opslaan - Kan de pincode niet bijwerken. Probeer het opnieuw. - - - Tik om te beginnen met bewerken… - - Hoog - Laag - Gemiddeld - Kies een prioriteit - - Citaat - Opnieuw - - Terugzetten - - Opslaan - - - - - Zoekopdracht wissen - - Vingerafdruk - Biometrisch ontgrendelen - Niet beschikbaar op dit apparaat - Geen vingerafdruk ingesteld. Voeg er een toe in de systeeminstellingen. - Versleuteling staat aan - Niet ingesteld. Zonder deze zin betekent een vergeten pincode dat je alles kwijt bent. Tik om er een te maken. - Alle gegevens wissen - Alle notities, taken, labels en de versleutelingssleutels op dit apparaat verwijderen - Alle notities, taken, labels en de versleutelingssleutels worden van dit apparaat verwijderd en Encly begint opnieuw. Dit kan niet ongedaan worden gemaakt: exporteer eerst een back-up als je iets wilt bewaren. - Versleuteling en gegevensbescherming - Beveiliging - - - Weergave - Algemeen - Taal - Privacy - Taken tonen - De takenkaart op het startscherm tonen - Eenvoudig bewerken - Dynamische blokken uitzetten en notities als platte tekst bewerken - - Sorteren op: - Op aanmaakdatum (oudste eerst) - Gemaakt ↑ - Op aanmaakdatum (nieuwste eerst) - Gemaakt ↓ - Op laatst bewerkt (oudste eerst) - Bewerkt ↑ - Op laatst bewerkt (nieuwste eerst) - Bewerkt ↓ - Steun de ontwikkelaar - - Kan het label niet opslaan. Probeer het opnieuw. - Kan het label niet verwijderen. Probeer het opnieuw. - Label bewerken - Verborgen label - Er bestaat al een label met deze naam - Kan de volgorde van de labels niet opslaan. Probeer het opnieuw. - Kan het label niet bijwerken. Probeer het opnieuw. - - Tik nogmaals om te verwijderen - - Nieuwe taak - Kan de voltooide taken niet wissen. Probeer het opnieuw. - Weet je zeker dat je alle voltooide taken wilt verwijderen? Dit kan niet ongedaan worden gemaakt. - Voltooide taken wissen - Voltooid: %1$s - Kan de taak niet opslaan. Probeer het opnieuw. - Beschrijving - Taak bewerken - Geen actieve taken. - Geen taken die aan de gekozen filters voldoen. - Alle taken - Voltooid - Nieuwe taak - Voortgang - Kan de status van de taak niet wijzigen. Probeer het opnieuw. - Kan de taak niet bijwerken. Probeer het opnieuw. - - Tekst - - Thema - Donker - Licht - Systeem - - - Ongedaan maken - - Zonder titel - - Naar rasterweergave - Naar lijstweergave - - - %d notitie verwijderen? - %d notities verwijderen? - - - Te veel pogingen. Probeer het over %d seconde opnieuw - Te veel pogingen. Probeer het over %d seconden opnieuw - - - %1$d van %2$d taak voltooid - %1$d van %2$d taken voltooid - - Back-up - Versleutelde back-up, import en Markdown - Back-up - Versleuteld met je herstelzin - Een back-up bevat al je notities, taken en labels, versleuteld met je herstelzin van 12 woorden. Encly uploadt hem nergens heen: jij kiest waar het bestand wordt opgeslagen. Op een nieuwe telefoon zetten dezelfde 12 woorden hem terug. - Laatste export: %1$s - Nog geen back-up geëxporteerd - Back-up exporteren - Back-up importeren - Notities, taken en labels terugzetten uit een back-upbestand - Back-up opgeslagen - Bevestig dat jij het bent - Voer je pincode in om door te gaan - Herstelzin - Back-ups worden versleuteld met een herstelzin, dus alleen de 12 woorden daarvan kunnen ze openen. Deze kluis heeft er nog geen. Maak er nu een: je kunt de kluis er ook mee ontgrendelen als je je pincode vergeet. - Herstelzin maken - Schrijf deze 12 woorden op papier, in volgorde, en bewaar ze offline. Iedereen die ze heeft, kan je back-ups openen. - Ik heb ze opgeschreven - De woorden kloppen niet. Controleer wat je hebt opgeschreven. - Herstelzin opgeslagen - Bevestig je herstelzin - Voer de herstelzin van 12 woorden van deze kluis één keer in, zodat Encly er back-ups mee kan versleutelen. - Herstelzin invoeren - Voer de 12 woorden in van de herstelzin waarmee deze back-up is gemaakt. - Ontsleutelen - Back-up importeren - Samenvoegen - Vervangen - Alle gegevens vervangen? - Alle notities, taken en labels in deze kluis worden verwijderd en vervangen door de back-up. Dit kan niet ongedaan worden gemaakt. - Geïmporteerd: notities %1$d, taken %2$d, labels %3$d. Overgeslagen (al aanwezig): %4$d. - Dit bestand is geen Encly-back-up - Deze back-up is gemaakt met een nieuwere versie van Encly. Werk de app bij en probeer het opnieuw. - Het back-upbestand is beschadigd of onvolledig - Het bestand is te groot voor een Encly-back-up - Je kluis is te groot voor één back-upbestand (64 MB). Er is niets opgeslagen. - Dat bestand bestaat al. Kies een nieuwe naam: een oudere back-up wordt nooit overschreven. - Geen app op dit apparaat kan bestanden openen of opslaan. - Deze woorden openen deze back-up niet, of het bestand is gewijzigd - Voer een geldige herstelzin van 12 woorden in - De kluis is vergrendeld. Ontgrendel hem en probeer het opnieuw. - Kan de back-up niet lezen of schrijven. Er is niets gewijzigd. - Deze woorden zijn niet de herstelzin van deze kluis - Kan de herstelzin niet opslaan - Terugzetten uit een back-up - Een back-upbestand kiezen - De back-up kon niet worden teruggezet, dus de instelling is nog niet voltooid. Probeer het opnieuw, of ga verder met een lege kluis en importeer het bestand later via Instellingen → Back-up. - Back-up niet teruggezet - Opnieuw proberen - Doorgaan zonder - Deze versie van Encly kan deze notitie niet lezen. - Link openen - Link kopiëren - Link bewerken - Link gekopieerd - Geen app op dit apparaat kan deze link openen. - Alleen weblinks (http, https) en e-maillinks kunnen worden opgeslagen of geopend. - Kan je notities niet lezen - Kan je labels niet lezen - Je notities staan nog steeds versleuteld op dit apparaat. Sluit Encly en open het opnieuw om het nog eens te proberen. - Je hebt Encly ontgrendeld met je herstelzin. Stel een nieuwe pincode in om door te gaan. - Taak verwijderen - Taak verwijderd - Kan de taak niet verwijderen. Probeer het opnieuw. - Modus - Lettertype - Papier - Bos - Oceaan - Grafiet - Middernacht - Volgt de systeeminstelling. Nu licht. - Volgt de systeeminstelling. Nu donker. - Altijd licht. - Altijd donker. - Middernacht is altijd donker. - Neemt de accentkleur van je achtergrond. Android 12 en hoger. - Dynamische kleuren staan aan: het accent komt van je achtergrond. - Playfair Display en Source Sans 3 - Poppins en Source Sans 3 - Overal IBM Plex Sans - Checklist bezichtiging - Voor de bezichtiging: waterdruk controleren, vragen wat de verwarming vorige winter kostte. - Persoonlijk - Voorbeeld - Thema, modus en lettertype - Juist - Ingevoerde cijfers: %1$d van %2$d - Ontgrendelen met vingerafdruk - Stap %1$d van %2$d - Een notitieboek dat alleen jij kunt openen. - Notities en taken, versleuteld op deze telefoon. Instellen duurt ongeveer een minuut. - Geen account - Geen aanmelding, geen e-mail, geen wachtwoordherstel. - Werkt offline - Encly maakt nooit verbinding met internet. - Versleuteld op het apparaat - Je kluis is vergrendeld met een sleutel die alleen jij hebt. - Aan de slag - Ik heb een back-up - Kies een pincode van 6 cijfers - Die ontgrendelt Encly en beschermt de sleutel van je kluis. Er is geen e-mail om hem te herstellen. - Voer de pincode opnieuw in - Zo weten we zeker dat het de bedoelde is. - De pincode blijft werken als reserve - Twaalf woorden brengen alles terug - Encly geeft je een herstelzin. Schrijf die op papier; het is de enige reservesleutel. - Pincode vergeten? - De zin ontgrendelt je kluis en laat je een nieuwe pincode instellen. - Opent je back-ups - Versleutelde back-upbestanden zijn alleen met deze woorden te lezen. - Nieuwe telefoon - Back-upbestand plus zin zet elke notitie, taak en elk label terug. - Niemand kan hem voor je herstellen, ook wij niet. Verlies je zowel de pincode als de zin, dan zijn de notities weg. - Mijn zin maken - Overslaan: alleen pincode, geen back-ups - Schrijf deze woorden op - Op papier, in deze volgorde. Niet in een screenshot, niet in de cloud. - Woorden verbergen - Woorden tonen - Ik heb ze opgeschreven - Controleer drie woorden - Typ ze over van je papieren kopie, niet uit je hoofd. - Woord %1$d - Voer het woord in - Dat is niet woord %1$d - Bevestigen - Woorden opnieuw tonen - Je kluis is klaar - Alles wat je vanaf nu schrijft, wordt versleuteld voordat het wordt opgeslagen. - Pincode ingesteld - Gevraagd telkens als Encly opent - Ontgrendelen met vingerafdruk aan - Wijzig dit in Instellingen, Beveiliging - Herstelzin gecontroleerd - Bewaar het papier op een veilige plek - Geen herstelzin - Back-ups staan uit. Je kunt later een zin maken in Instellingen, Beveiliging. - Back-up teruggezet - Je notities, taken en labels zijn terug - Tip: exporteer af en toe een versleutelde back-up via Instellingen, Back-up. Je zin is wat hem opent. - Mijn notitieboek openen - Terugzetten, dan pincode instellen - Kies je Encly-back-upbestand en voer dan de 12 woorden in waarmee het is gemaakt. - 1 · Back-upbestand - Tik om een ander bestand te kiezen - 2 · Herstelzin - Deze woorden worden ook de herstelzin van deze telefoon. - Notities - Nu vergrendelen - Zoeken in titels en tekst - Weergaveopties - Niets gevonden voor ‘%1$s’ - Vandaag - Gisteren - - Taken · %d open - Taken · %d open - - Taken · geen open - Nog geen taken - Tik op Nieuwe taak om je eerste toe te voegen. - Alles klaar - Klaar · %1$d - Geen notities met de tag ‘%1$s’ - Notities met deze tag verschijnen hier. - Alle notities tonen - De prullenbak is leeg - Tik op een notitie om te lezen. Houd ingedrukt om te selecteren. - Definitief verwijderen - Prullenbak legen - Notities zoeken - Notities en taken - Versie, privacybeleid en contact - Antwoorden op veelgestelde vragen - Opensourcelicenties - De bibliotheken en lettertypen die Encly gebruikt - Gebouwd op open source - Encly is gemaakt met deze bibliotheken en lettertypen. Tik erop om de licentie te lezen. - Bibliotheken - Lettertypen - Nog geen tags - Tags groeperen je notities. Maak er hierboven een aan en kies hem voor een notitie via lang indrukken. - Tik op een tag om hem te hernoemen, verbergen of verwijderen. Sleep de greep om te ordenen. - Tag toevoegen - Naam - Tonen in de notitielijst - Een verborgen tag houdt zijn notities maar krijgt geen filter - Sleep om te ordenen - Omhoog - Omlaag - - %d geselecteerd - %d geselecteerd - - Labels bewerken - Titel - Niet opgeslagen - Labels beheren - Wijzigingen negeren? - Alles wat je hebt gewijzigd sinds je deze notitie opende, gaat verloren. Dit kan niet ongedaan worden gemaakt. - Alles is al van jou - Elke functie is er voor iedereen. Niets zit achter een betaling. - Geen advertenties, geen tracking - Encly ziet je notities nooit en verdient niets aan data. Alleen donaties betalen ervoor. - Waar het geld heen gaat - Testapparaten (volgende doel: een Mac mini om te bouwen en testen) en tijd voor releases en fixes. - Kaart of PayPal, eenmalig of maandelijks - Opent ko-fi.com in je browser. Encly zelf verwerkt nooit betalingen. - Wat doet lang drukken op een notitie? - Het opent de acties van de notitie: het label wijzigen, een korte beschrijving op de kaart toevoegen, de notitie dupliceren of naar de prullenbak verplaatsen. - Hoe krijg ik een verwijderde notitie terug? - Verwijderde notities gaan naar de Prullenbak in het menu. Daar kun je een notitie herstellen of definitief verwijderen. Encly leegt de prullenbak nooit vanzelf. - Wat doet ‘Bewerken vergrendelen’? - De notitie wordt alleen-lezen, zodat je hem niet per ongeluk wijzigt. Je kunt de tekst nog steeds selecteren en kopiëren. Zet het aan of uit via het menu van de editor. - Hoe werken back-ups? - Via Instellingen → Back-up exporteer je één versleuteld bestand dat alleen je herstelzin kan openen. Om te herstellen kies je het bestand, typ of plak je de 12 woorden en voeg je het samen met deze kluis of vervang je alles. - Toetsenbord tonen - Toetsenbord verbergen - Alle gegevens wissen? - De gegevens konden niet worden gewist. Er is niets gewijzigd. - Wissen - Subtaken - Subtaak - Subtaak toevoegen - Subtaak verwijderen - Alle subtaken zijn klaar - Taak voltooien - Subtaken tonen - Subtaken verbergen - Uitgevouwen - Samengevouwen - Subtaak verwijderd - Volgende stap: %1$s - Eerste stap - - %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 - Vastmaken - Losmaken - Archiveren - Uit archief halen - Vastgemaakt - Overige - Archief - Het archief is leeg - Gearchiveerde notities verdwijnen uit de lijst en de zoekopdracht, maar blijven in de kluis. Archiveer een notitie via het menu. - De notitie kon niet worden gearchiveerd - De notitie kon niet uit het archief worden gehaald - Kleur - Geen kleur - Rood - Oranje - Geel - Groen - Turquoise - Blauw - Paars - Roze - Exporteren als Markdown - Kopiëren als Markdown - Gekopieerd als Markdown - Leesbare export - Sla alle notities en taken op als leesbare .md-bestanden in een .zip. Het bestand is niet versleuteld. - Exporteren zonder versleuteling? - Het geëxporteerde bestand is niet versleuteld. Iedereen die het in handen krijgt, kan je notities lezen. Waar je het ook opslaat, ook in een cloudmap, komt een leesbare kopie te staan die Encly niet kan beschermen of terughalen. Daarna bevestig je met je pincode. - Markdown-export opgeslagen - Kan ik mijn notities als Markdown exporteren? - Ja. Kies in het menu van een notitie ‘Exporteren als Markdown’ voor één .md-bestand, of ga naar Instellingen → Back-up → ‘Exporteren als Markdown’ voor een .zip met alle notities (gearchiveerde in een map archive, zonder prullenbak) en een takenbestand. Deze bestanden zijn niet versleuteld: Encly waarschuwt eerst, vraagt om je pincode en slaat alleen op waar jij kiest. - Veegacties - Vegen op notities - Veeg een notitiekaart om erop te handelen - Naar rechts vegen - Naar links vegen - Vastmaken of losmaken - Volgende kleur - Naar prullenbak - Niets - Naar de prullenbak verplaatsen? - Je kunt de notitie uit de prullenbak terugzetten. - Wat doen veegbewegingen op een notitie? - Standaard maakt naar rechts vegen een notitie vast of los, en naar links archiveert ze. Onder Instellingen → Veegacties kies je per richting: vastmaken, archiveren, volgende kleur, naar prullenbak (vraagt altijd eerst) of niets, of zet je vegen uit. TalkBack biedt dezelfde acties in het actiemenu van de notitie. - Tekst in notities - De standaard voor elke notitie. Een notitie kan via haar menu een eigen waarde instellen. - Standaardtekstinstellingen gebruiken - Broncode - Encly is open source: lees de code op GitHub - diff --git a/app/src/main/res/values-nl/strings_import.xml b/app/src/main/res/values-nl/strings_import.xml deleted file mode 100644 index 32d0ece5..00000000 --- a/app/src/main/res/values-nl/strings_import.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - Importeren uit andere apps - Notities importeren - Uit Google Keep, Standard Notes, Joplin, Notesnook of Markdown-bestanden - Haal notities uit een andere app. Kies de export en Encly herkent het formaat zelf. Alles blijft op deze telefoon; er wordt niets geüpload. - Bestanden kiezen - Map kiezen - Ondersteunde apps - Zo exporteer je - Niet geïmporteerd - Google Keep - Google Takeout-.zip (kies hem zoals hij is, niet uitpakken) of losse .json-notities - Selecteer in Google Takeout alleen Keep, maak de export en download de .zip. Kies die .zip dan hier. - Vastgezet- en archiefstatus, kleuren en bijlagen. Alleen het eerste label wordt de tag. - Standard Notes - Ontsleutelde back-up (.txt of .json) of de .zip van een ontsleutelde download - Download in Standard Notes een ontsleutelde back-up, geen versleutelde. Kies dan het bestand of de .zip hier. - Versleutelde back-ups worden geweigerd. Vastgezet- en archiefstatus, bestanden en andere itemtypes. Alleen de eerste tag blijft. - Joplin - JEX-archief (.jex), RAW-map of Markdown-export - Kies in Joplin Bestand → Alles exporteren en dan JEX of RAW. Kies dan het bestand of de map hier. - Bijlagen en de taakstatus (taken komen aan als gewone notities). Een notitie krijgt zijn eerste tag, anders de naam van zijn notitieboek. - Notesnook - Markdown-, Markdown-met-front-matter- of TXT-export (.zip) - Exporteer je notities in Notesnook als Markdown met front matter om data en tags te behouden. Kies dan de .zip hier. Versleutelde back-ups zijn niet te importeren. - Bijlagen, vastgezet- en archiefstatus, kleuren. De eerste tag, anders het notitieboek, wordt de tag. - Markdown en tekst - .md- en .txt-bestanden of een hele map, bijvoorbeeld uit Obsidian of Markor - Kies de bestanden, of de map waarin ze staan. De bestandsnaam wordt de titel; eenvoudige front matter (titel, tags, data) wordt gelezen. - Afbeeldingen, bijlagen, plug-ins en opmaak buiten koppen, lijsten, checklists en citaten. De eerste map wordt de tag. - Je bestanden worden gelezen en herkend… - Klaar om te importeren - Herkend: %1$s · %2$s - De aangevinkte notities worden aan deze kluis toegevoegd. Er wordt niets vervangen en wat er al is, wordt overgeslagen. - Notities met checklists: %1$d - Tags: %1$d - Notities die naar de prullenbak gaan: %1$d - Al in deze kluis, overgeslagen: %1$d - Herhaald in je selectie, overgeslagen: %1$d - Bestemming: deze kluis - Achterblijvende bijlagen: %1$d - Vastgezette notities die niet vastgezet blijven: %1$d - Gearchiveerde notities die als gewone notities aankomen: %1$d - Notitiekleuren die niet blijven: %1$d - Extra tags weggelaten (een notitie heeft één tag): %1$d - Takennotities geïmporteerd als gewone notities: %1$d - Versleutelde of niet-ondersteunde items overgeslagen: %1$d - Bestanden die niet bruikbaar waren: %1$d - Archieven in archieven, niet geopend: %1$d - Importeren… - Geïmporteerd - %1$d toegevoegd, %2$d overgeslagen als dubbel, %3$d niet geïmporteerd. - Je originele bestanden zijn niet gewijzigd. Verwijder ze zelf zodra je de notities hier hebt gecontroleerd. - Uit welke app komen deze? - De bestanden lijken uit meer dan één app te komen. Kies de app om te importeren; de rest wordt overgeslagen. - Er is niets geïmporteerd - Deze Standard Notes-back-up is versleuteld. Download in Standard Notes een ontsleutelde back-up en kies die. - Dit is een versleutelde Notesnook-back-up. Exporteer je notities in Notesnook in plaats daarvan als Markdown en kies de .zip. - Deze bestanden zijn geen ondersteunde export. Bekijk de ondersteunde apps of kies Markdown- of tekstbestanden. - Deze selectie is groter dan Encly in één keer kan importeren (64 MB aan notities, 2 GB in totaal of 20.000 notities). Importeer hem in delen. - Er zijn geen notities gevonden in deze selectie. - De gekozen bestanden konden niet worden gelezen. - Deze telefoon heeft geen bestandskiezer die Encly kan gebruiken. - De kluis is tijdens het importeren gesloten. Ontgrendel Encly en probeer het opnieuw. - Er ging iets mis en er is niets geïmporteerd. Je kluis is ongewijzigd. - %1$d van %2$d geselecteerd - Alles selecteren - Niets selecteren - Alle - Alleen nieuwe - In prullenbak - Zonder titel - Checklist - In prullenbak - Al in de kluis - %1$d importeren - Opnieuw kiezen - diff --git a/app/src/main/res/values-nl/strings_security.xml b/app/src/main/res/values-nl/strings_security.xml deleted file mode 100644 index 2af80eb7..00000000 --- a/app/src/main/res/values-nl/strings_security.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - Typ of plak je 12 woorden - Woord %1$d van %2$d - Woord %1$d is geen woord uit een herstelzin. Controleer de spelling. - Deze 12 woorden vormen geen geldige herstelzin. Controleer elk woord en de volgorde. - Deze kluis heeft al een herstelzin - Toevoegen aan je notities of ze vervangen? - Deze back-up bevat %1$s, %2$s en %3$s. - Alles hier blijft en wat ontbreekt wordt toegevoegd. Wat er al is, wordt overgeslagen. - Alles vervangen - Verwijder alle notities, taken en tags op deze telefoon en zet daarna de back-up terug. - Bewaar al je notities, taken en tags in één versleuteld bestand en zet ze terug op elke telefoon. - Exporteren - Importeren - Je notities zijn versleuteld. Je pincode (en je vingerafdruk, als die aan staat) ontgrendelt ze. - Je notities zijn versleuteld. Je pincode (en je vingerafdruk, als die aan staat) ontgrendelt ze, en je herstelzin als je de pincode vergeet. - Gemaakt tijdens het instellen. Encly bewaart de woorden nooit en kan ze niet opnieuw tonen. Heb je ze niet? Vervang de zin hieronder. - Ingesteld - Na de reset kun je een back-upbestand terugzetten met de 12 woorden ervan. - Pincode wijzigen - - %d notitie - %d notities - - - %d taak - %d taken - - - %d tag - %d tags - - - Je pincode kan Encly op deze telefoon niet meer ontgrendelen: de hardwaresleutel is gereset. Gebruik je herstelzin (of vingerafdruk) en stel daarna een nieuwe pincode in. - - Te veel pogingen. Probeer het over %d minuut opnieuw - Te veel pogingen. Probeer het over %d minuten opnieuw - - - Te veel pogingen. Probeer het over %d uur opnieuw - Te veel pogingen. Probeer het over %d uur opnieuw - - Herstelzin vervangen - Krijg 12 nieuwe woorden. Eerder gemaakte back-ups hebben nog steeds de oude nodig. - Herstelzin vervangen? - Je krijgt 12 nieuwe woorden. De oude zin ontgrendelt je notities op deze telefoon niet meer. Back-ups die je al hebt gemaakt, openen alleen met de oude zin, dus bewaar die zolang je die back-ups bewaart, of maak daarna een nieuwe back-up. - Nieuwe woorden krijgen - Je oude zin werkt hier niet meer - Zodra je bevestigt, ontgrendelen alleen deze woorden je notities als je de pincode vergeet. Eerder gemaakte back-ups hebben nog steeds de oude woorden nodig. - Herstelzin vervangen. Maak een nieuwe back-up om hem ook voor back-ups te gebruiken. - Privacy - Strikte toetsenbordprivacy - Vraagt het toetsenbord om geen suggesties, geen voorspelling in de cloud en geen meelezen rond de cursor. Zet autocorrectie uit, en sommige toetsenborden verbergen emoji of spraakinvoer. - Vergrendelen na verlaten - Hoelang Encly open blijft als je naar een andere app gaat. Het scherm uitzetten of de telefoon vergrendelen vergrendelt het altijd meteen. - Meteen - Na 15 seconden - 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. - Een pincode gebonden aan deze telefoon - Je pincode wordt gecombineerd met een sleutel in de beveiligde chip van de telefoon, dus hij is nergens anders te raden. - Een eigen slot voor elke toegang - Pincode, herstelzin en vingerafdruk openen elk hun eigen versleutelde slot. De vingerafdruk is elke keer nodig. - Gevarenzone - Houd 5 seconden vast om te wissen - - Wis-pincode - Een tweede pincode die je notities wist als je hem op het vergrendelscherm invoert - Uitgeschakeld toen de sleutel van je pincode werd gereset. Stel hem opnieuw in als je er een gebruikte. - Als iemand je dwingt Encly te openen, voer dan de wis-pincode in plaats van je pincode in. Encly wist je notities, taken en labels zonder zichtbaar teken en opent als een lege kluis. Dit werkt alleen op het vergrendelscherm. - Na het wissen opent Encly met de wis-pincode: die wordt de pincode van de nieuwe, lege kluis. Je oude pincode werkt niet meer. Wie je de wis-pincode zag invoeren, kan Encly er opnieuw mee openen en merkt niets ongewoons. - Back-upbestanden die je al hebt geëxporteerd, blijven onaangeroerd. Ze openen nog steeds met je herstelzin en zijn de enige weg terug. - Biometrisch ontgrendelen blijft aan: iemand kan je vinger op de sensor dwingen. Zet het uit als dat voor jou een risico is. - Biometrisch ontgrendelen uitzetten - Dit beschermt je als iemand je dwingt te ontgrendelen, niet tegen forensisch onderzoek van de telefoon. Tijdens een blokkering na mislukte pogingen werkt ook de wis-pincode niet. Encly laat nooit zien of er een wis-pincode is ingesteld: een nieuwe vervangt de oude, en als je je pincode wijzigt in de wis-pincode, wordt die uitgeschakeld. - Wis-pincode instellen - Wis-pincode uitzetten - Bevestig je pincode om de wis-pincode in te stellen - Maak een wis-pincode - 6 cijfers, anders dan je pincode - Bevestig de wis-pincode - Voer de wis-pincode nogmaals in - De wis-pincode moet anders zijn dan je pincode - Kan de wis-pincode niet opslaan. Probeer het opnieuw. - Wis-pincode ingesteld - Wis-pincode uitgezet - De sleutel van je pincode is gereset, dus een eventuele wis-pincode is uitgeschakeld. Stel hem opnieuw in als je er een wilt. - Deze pincode is al in gebruik. Kies een andere. - Dwangpincode - Een tweede pincode die een aparte kluis met eigen notities opent - Uitgeschakeld toen je pinsleutel werd gereset. Stel hem opnieuw in als je er een wilt. - Als iemand je dwingt Encly te openen, voer dan de dwangpincode in plaats van je pincode in. Encly opent een aparte, lege kluis die je gewoon kunt gebruiken: notities die je daar toevoegt, blijven daarin bewaard. - Je echte kluis blijft vergrendeld en onaangeroerd. Anders dan de wis-pincode wist de dwangpincode niets. Je vingerafdruk en je herstelzin openen altijd de echte kluis. - Hij beschermt je als iemand zegt dat je de app moet openen en alles moet laten zien, niet tegen een forensisch onderzoek van de telefoon. Tijdens een vergrendeling na foute pogingen werkt de dwangpincode ook niet. - Er is een dwangpincode ingesteld. - Er is geen dwangpincode ingesteld. - Dwangpincode instellen - Dwangpincode wijzigen - Dwangpincode uitschakelen - Bevestig je pincode om de dwangpincode in te stellen - Een dwangpincode maken - 6 cijfers, anders dan je pincode - Bevestig de dwangpincode - Voer de dwangpincode nogmaals in - De dwangpincode moet anders zijn dan je pincode - De dwangpincode kon niet worden opgeslagen. Probeer het opnieuw. - Dwangpincode ingesteld. - Dwangpincode gewijzigd. De schijnkluis behoudt zijn notities. - Dwangpincode uitgeschakeld - Je pinsleutel is gereset, dus de dwangpincode werkt niet meer. Stel hem opnieuw in; de schijnkluis behoudt zijn notities. - De dwangpincode uitschakelen? - De schijnkluis en de notities daarin worden verwijderd. Je echte kluis blijft ongemoeid. - Uitschakelen - diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml deleted file mode 100644 index 320f5c7b..00000000 --- a/app/src/main/res/values-pl/strings.xml +++ /dev/null @@ -1,668 +0,0 @@ - - - O aplikacji - Lokalna, zaszyfrowana przestrzeń na notatki, pomysły i zadania – bez synchronizacji w chmurze. - Ochrona danych - Dane sejfu są przechowywane lokalnie w zaszyfrowanej bazie danych SQLCipher - Najczęstsze pytania - Opinie - Zgłoś błąd lub zaproponuj ulepszenie na GitHubie - Dlaczego to ważne i jak wesprzeć na Ko-fi - Nie znaleziono przeglądarki. Otwórz link ręcznie: %1$s - Nie znaleziono aplikacji pocztowej - Polityka prywatności - Jak przechowujemy i chronimy Twoje dane - Oceń aplikację - Wystaw ocenę w Sklepie Google Play - Pomoc i kontakt - Napisz do dewelopera - Jeśli masz pytania lub sugestie, możesz napisać do mnie e-mail. - - Dalej - - Dodaj - - Wszystkie - - Wymagany klucz dostępu. Stuknij, aby zmienić PIN. - 6-cyfrowy PIN - Uwierzytelnianie - - Wstecz - - Zmiana ochrony biometrycznej nie została potwierdzona - Nie udało się utworzyć klucza biometrycznego - Potwierdź swoją tożsamość - Uwierzytelnianie - Potwierdź biometrię - Dostęp do klucza - Potwierdź biometrię, aby zmienić ochronę - Potwierdzenie bezpieczeństwa - Potwierdź biometrię, aby uzyskać dostęp do zaszyfrowanego klucza - Odblokuj Encly - Użyj PIN-u - Uwierzytelnianie biometryczne jest niedostępne - - Więcej narzędzi bloku - Lista punktowana - Lista kontrolna - Nagłówek - Nagłówek %1$d - Link - Lista - Zablokuj listę kontrolną - Odblokuj listę kontrolną - Przenieś blok w dół - Przenieś blok w górę - Lista numerowana - Separator - - Anuluj - - - Czy na pewno chcesz trwale usunąć te notatki? Tej operacji nie można cofnąć. - Czy na pewno chcesz trwale opróżnić kosz? Tej operacji nie można cofnąć. - Opróżnić kosz? - - Wyczyść - - Zamknij - - Zwiń - - Utwórz nową etykietę - - Dziś %1$s - Wczoraj %1$s - - Usuń - Usuń blok - Usuń - Usuń etykietę - Ta etykieta zostanie usunięta ze wszystkich notatek. Same notatki nie zostaną usunięte. - - Usuń - Anuluj - Czy na pewno chcesz trwale usunąć tę notatkę? Tej operacji nie można cofnąć. - Usunąć notatkę? - - Encly jest darmowy i open source, bez reklam, śledzenia i kont. Darowizny to jego jedyny dochód. - Wesprzyj rozwój - - Gotowe - - Duplikuj - - Dynamiczne kolory - - Edytuj - - Zablokuj edycję - - Nie ma jeszcze notatek - Stuknij Nowa notatka, aby napisać pierwszą. - Usunięte notatki czekają tu, aż je przywrócisz lub usuniesz na zawsze. - - Wpisz link - - Nie udało się otworzyć bazy danych - - Rozwiń - - Nie udało się wczytać treści - - Encly zapisuje zmiany i po opóźnieniu wybranym w Ustawienia → Bezpieczeństwo (domyślnie 15 sekund) zamyka zaszyfrowaną bazę i czyści klucz sesji. Wyłączenie ekranu lub zablokowanie telefonu robi to od razu. Potem odblokowujesz ponownie. - Co się dzieje, gdy wychodzę z aplikacji? - Użyj przełącznika biometrii w Ustawienia → Bezpieczeństwo. Utworzenie lub usunięcie slotu biometrycznego wymaga silnego uwierzytelnienia biometrycznego. - Jak włączyć lub wyłączyć biometrię? - Otwórz Ustawienia → Bezpieczeństwo, potwierdź obecny PIN, a następnie dwukrotnie wpisz nowy. - Jak zmienić PIN? - Encly tworzy losowy 256-bitowy klucz bazy danych. PIN chroni osobny slot odblokowania AES-GCM, a biometria korzysta z klucza Android Keystore, który wymaga uwierzytelnienia przy każdym użyciu. - Jak chroniony jest klucz bazy danych? - Na ekranie blokady stuknij „Odzyskaj dostęp”, wpisz 12 słów i ustaw nowy PIN. Bez frazy odzyskiwania nie ma innej drogi: nikt, nawet twórca aplikacji, nie może go zresetować. - Co jeśli zapomnę PIN-u? - Otwórz Ustawienia → Język i wybierz jeden z dostępnych języków albo „Domyślny systemu”, aby używać języka urządzenia. Zmiana działa od razu. - Jak zmienić język? - Stuknij „Nowa notatka” na ekranie głównym. Pasek nad klawiaturą dodaje nagłówki, listy kontrolne, listy punktowane i numerowane, cytaty, linki i separatory. - Jak utworzyć nową notatkę? - To zapasowy klucz z 12 słów. Odblokowuje sejf, jeśli zapomnisz PIN-u, i jest jedynym kluczem do twoich kopii zapasowych. Tworzysz go podczas konfiguracji albo później w Ustawienia → Bezpieczeństwo, jeśli go pominięto. Przechowuj go na papierze, offline. - Do czego służy fraza odzyskiwania? - Otwórz „O aplikacji” i skorzystaj z opcji opinii albo napisz do dewelopera. Nigdy nie dołączaj do zgłoszenia błędu frazy odzyskiwania, PIN-u ani prywatnej treści notatek. - Jak zgłosić błąd? - Automatycznie podczas pisania, a wyjście z edytora kończy ostatni zapis. Jeśli zapis kiedyś się nie uda, edytor pokaże czerwone „Nie zapisano”. - Jak zapisywane są zmiany? - Notatki, zadania, lokalne szyfrowanie i odzyskiwanie dostępu – bez synchronizacji w chmurze. - Odpowiedzi na najczęstsze pytania - Nie. Ochrona ekranu jest zawsze włączona: Encly blokuje zrzuty ekranu, nagrywanie ekranu i niezabezpieczone podglądy na liście ostatnich aplikacji. - Czy mogę robić zrzuty ekranu w Encly? - Podstawy - Bezpieczeństwo i odzyskiwanie - Ustawienia - Rozwiązywanie problemów - Notatki, etykiety i zadania są przechowywane wyłącznie w lokalnej bazie danych SQLCipher aplikacji Encly. Nie ma synchronizacji w chmurze ani automatycznych kopii: zaszyfrowaną kopię możesz wyeksportować samodzielnie w Ustawienia → Kopia zapasowa, a także czytelną kopię w formacie Markdown, która nie jest zaszyfrowana. - Gdzie przechowywane są moje dane? - Etykiety grupują notatki. Możesz je tworzyć, zmieniać nazwy, ukrywać i usuwać, przeciągać w dowolnej kolejności oraz filtrować po nich ekran główny. - Jak działają etykiety? - Zadania mają opis i priorytet. Możesz je filtrować według priorytetu i oznaczać jako wykonane. Encly nigdy nie umieszcza treści zadań w powiadomieniach systemowych. - Co potrafią zadania? - W Ustawienia → Wygląd wybierz jeden z pięciu motywów, tryb jasny, ciemny lub systemowy, jeden z trzech stylów czcionki i rozmiar tekstu. Na Androidzie 12 i nowszym kolor akcentu może też pochodzić z tapety. - Jak zmienić motyw? - Encly celowo nigdy nie tworzy pustej bazy danych w miejscu utraconego lub nieczytelnego sejfu. Nie potwierdzaj pełnego wyczyszczenia, jeśli nadal potrzebujesz danych lokalnych. - Encly zgłasza uszkodzenie sejfu - - Rozmiar tekstu - Odstępy między wierszami - Domyślne - Kompaktowe - Przestronne - Redakcyjny - Nowoczesny - Techniczny - - - Wybierz język - Domyślny systemu - - Dodaj link - - Element listy - - - Wróć do PIN-u - Odzyskaj dostęp - Wpisz 12-wyrazową frazę odzyskiwania - Odzyskiwanie - Wpisz 6-cyfrowy PIN, aby kontynuować - Odblokuj notatki - Odblokowywanie… - Odzyskaj dostęp za pomocą frazy odzyskiwania - Nieprawidłowy PIN - Nieprawidłowa fraza odzyskiwania - - Przytrzymaj, aby wyczyścić wszystko - Wygląda na to, że zaszyfrowane dane lub klucze szyfrowania zostały zmienione albo uszkodzone, więc odzyskanie danych jest niemożliwe.\n\nAby dalej korzystać z aplikacji, musisz ją zresetować i wyczyścić uszkodzone dane. Spowoduje to trwałe usunięcie wszystkich zapisanych notatek i ustawień. - Niestety Twoje dane są uszkodzone - Te dane pochodzą ze starszej wersji - Encly znalazła notatki zapisane przez wersję sprzed 2.0. Encly 2.0 używa nowego zaszyfrowanego formatu i nie może ich otworzyć, a ich konwersja nie jest możliwa.\n\nAby korzystać z aplikacji, zacznij od nowa. Stare notatki i ustawienia na tym telefonie zostaną trwale usunięte. - Przytrzymaj, aby zacząć od nowa - - FAQ - Ustawienia - Wesprzyj dewelopera - Etykiety - Zadania - Kosz - - Więcej opcji - - - Bez etykiety - - Nowa notatka - Nie udało się usunąć notatki - Opis… - Odrzuć wszystkie zmiany - Nie udało się odrzucić zmian - Nie udało się utworzyć kopii - Nie udało się odczytać treści notatki. Edycja jest wyłączona, aby nie uszkodzić danych. - - %1$s (kopia) - Nie udało się przywrócić notatki - Nie udało się zapisać zmian - Nie udało się przenieść notatki do kosza - - Nie udało się zainicjować aplikacji - Nie udało się utworzyć frazy odzyskiwania - Nie udało się utworzyć zaszyfrowanego sejfu - - Otwórz menu - - %1$d%% - - Wpisz PIN ponownie, aby go potwierdzić - Potwierdź 6-cyfrowy PIN - Potwierdź obecny PIN przed zmianą klucza dostępu - Wpisz obecny PIN - Wpisz nowy PIN, aby chronić swoje notatki - PIN zmieniony - Utwórz 6-cyfrowy PIN - Obecny PIN jest nieprawidłowy - Usuń cyfrę - PIN-y nie są zgodne. Spróbuj ponownie. - Nie udało się zapisać PIN-u - Nie udało się zaktualizować PIN-u. Spróbuj ponownie. - - - Stuknij, aby zacząć edycję… - - Wysoki - Niski - Średni - Wybierz priorytet - - Cytat - Ponów - - Przywróć - - Zapisz - - - - - Wyczyść wyszukiwanie - - Odcisk palca - Odblokowanie biometryczne - Niedostępne na tym urządzeniu - Nie dodano odcisku palca. Dodaj go w ustawieniach systemu. - Szyfrowanie jest aktywne - Nieskonfigurowana. Bez niej zapomniany PIN oznacza utratę wszystkiego. Dotknij, aby ją utworzyć. - Wymaż wszystkie dane - Usuń wszystkie notatki, zadania, etykiety i klucze szyfrowania z tego urządzenia - Wszystkie notatki, zadania, etykiety i klucze szyfrowania zostaną usunięte z tego urządzenia, a Encly zacznie od nowa. Tego nie można cofnąć: najpierw wyeksportuj kopię zapasową, jeśli chcesz coś zachować. - Szyfrowanie i ochrona danych - Bezpieczeństwo - - - Wygląd - Ogólne - Język - Prywatność - Pokazuj zadania - Pokazuj kartę zadań na ekranie głównym - Prosta edycja - Wyłącz bloki dynamiczne i edytuj notatki jako zwykły tekst - - Sortuj według: - Według daty utworzenia (od najstarszych) - Utworzono ↑ - Według daty utworzenia (od najnowszych) - Utworzono ↓ - Według ostatniej edycji (od najstarszych) - Edytowano ↑ - Według ostatniej edycji (od najnowszych) - Edytowano ↓ - Wesprzyj dewelopera - - Nie udało się zapisać etykiety. Spróbuj ponownie. - Nie udało się usunąć etykiety. Spróbuj ponownie. - Edytuj etykietę - Ukryta etykieta - Etykieta o tej nazwie już istnieje - Nie udało się zapisać kolejności etykiet. Spróbuj ponownie. - Nie udało się zaktualizować etykiety. Spróbuj ponownie. - - Stuknij ponownie, aby usunąć - - Nowe zadanie - Nie udało się wyczyścić wykonanych zadań. Spróbuj ponownie. - Czy na pewno chcesz usunąć wszystkie wykonane zadania? Tej operacji nie można cofnąć. - Wyczyść wykonane zadania - Wykonano: %1$s - Nie udało się zapisać zadania. Spróbuj ponownie. - Opis - Edytuj zadanie - Brak aktywnych zadań. - Brak zadań pasujących do wybranych filtrów. - Wszystkie zadania - Wykonane - Nowe zadanie - Postęp - Nie udało się zmienić stanu zadania. Spróbuj ponownie. - Nie udało się zaktualizować zadania. Spróbuj ponownie. - - Tekst - - Motyw - Ciemny - Jasny - Systemowy - - - Cofnij - - Bez tytułu - - Przełącz na widok siatki - Przełącz na widok listy - - - Usunąć %d notatkę? - Usunąć %d notatki? - Usunąć %d notatek? - Usunąć %d notatki? - - - Zbyt wiele prób. Spróbuj ponownie za %d sekundę - Zbyt wiele prób. Spróbuj ponownie za %d sekundy - Zbyt wiele prób. Spróbuj ponownie za %d sekund - Zbyt wiele prób. Spróbuj ponownie za %d sekundy - - - Wykonano %1$d z %2$d zadania - Wykonano %1$d z %2$d zadań - Wykonano %1$d z %2$d zadań - Wykonano %1$d z %2$d zadania - - Kopia zapasowa - Szyfrowana kopia, import i Markdown - Kopia zapasowa - Zaszyfrowana Twoją frazą odzyskiwania - Kopia zapasowa zawiera wszystkie Twoje notatki, zadania i etykiety, zaszyfrowane 12-wyrazową frazą odzyskiwania. Encly nigdzie jej nie wysyła: to Ty wybierasz, gdzie zapisać plik. Na nowym telefonie te same 12 słów ją przywróci. - Ostatni eksport: %1$s - Nie wyeksportowano jeszcze żadnej kopii - Eksportuj kopię - Importuj kopię - Przywróć notatki, zadania i etykiety z pliku kopii - Kopia zapasowa zapisana - Potwierdź, że to Ty - Wpisz PIN, aby kontynuować - Fraza odzyskiwania - Kopie są szyfrowane frazą odzyskiwania, więc otworzyć je może tylko jej 12 słów. Ten sejf jeszcze jej nie ma. Utwórz ją teraz: pozwoli też odblokować sejf, jeśli zapomnisz PIN. - Utwórz frazę odzyskiwania - Zapisz te 12 słów na papierze, po kolei, i przechowuj je offline. Każdy, kto je ma, może otworzyć Twoje kopie. - Zapisałem(-am) je - Słowa się nie zgadzają. Sprawdź, co zapisałeś(-aś). - Fraza odzyskiwania zapisana - Potwierdź frazę odzyskiwania - Wpisz raz 12-wyrazową frazę odzyskiwania tego sejfu, aby Encly mogła szyfrować nią kopie. - Wpisz frazę odzyskiwania - Wpisz 12 słów frazy odzyskiwania, którą utworzono tę kopię. - Odszyfruj - Import kopii - Scal - Zastąp - Zastąpić wszystkie dane? - Wszystkie notatki, zadania i etykiety w tym sejfie zostaną usunięte i zastąpione kopią. Tej operacji nie można cofnąć. - Zaimportowano: notatki %1$d, zadania %2$d, etykiety %3$d. Pominięto (już istnieją): %4$d. - Ten plik nie jest kopią zapasową Encly - Tę kopię utworzyła nowsza wersja Encly. Zaktualizuj aplikację i spróbuj ponownie. - Plik kopii jest uszkodzony lub niekompletny - Plik jest za duży, aby był kopią Encly - Sejf jest za duży na jeden plik kopii (64 MB). Nic nie zostało zapisane. - Ten plik już istnieje. Wybierz nową nazwę: starsza kopia nigdy nie jest nadpisywana. - Żadna aplikacja na tym urządzeniu nie może otwierać ani zapisywać plików. - Te słowa nie otwierają tej kopii albo plik został zmieniony - Wpisz prawidłową 12-wyrazową frazę odzyskiwania - Sejf został zablokowany. Odblokuj go i spróbuj ponownie. - Nie udało się odczytać ani zapisać kopii. Nic nie zostało zmienione. - Te słowa nie są frazą odzyskiwania tego sejfu - Nie udało się zapisać frazy odzyskiwania - Przywróć z kopii - Wybierz plik kopii - Nie udało się przywrócić kopii, więc konfiguracja nie jest jeszcze zakończona. Spróbuj ponownie albo kontynuuj z pustym sejfem i zaimportuj plik później w Ustawienia → Kopia zapasowa. - Kopia nie została przywrócona - Spróbuj ponownie - Kontynuuj bez niej - Ta wersja Encly nie może odczytać tej notatki. - Otwórz link - Kopiuj link - Edytuj link - Link skopiowany - Żadna aplikacja na tym urządzeniu nie może otworzyć tego linku. - Zapisywać i otwierać można tylko linki internetowe (http, https) i e-mailowe. - Nie udało się odczytać notatek - Nie udało się odczytać etykiet - Notatki nadal są zapisane w postaci zaszyfrowanej na tym urządzeniu. Zamknij Encly i otwórz ponownie, aby spróbować jeszcze raz. - Odblokowano Encly frazą odzyskiwania. Ustaw nowy PIN, aby kontynuować. - Usuń zadanie - Zadanie usunięte - Nie udało się usunąć zadania. Spróbuj ponownie. - Tryb - Czcionka - Papier - Las - Ocean - Grafit - Północ - Zgodnie z ustawieniem systemu. Teraz jasny. - Zgodnie z ustawieniem systemu. Teraz ciemny. - Zawsze jasny. - Zawsze ciemny. - Motyw Północ jest zawsze ciemny. - Kolor akcentu z tapety. Android 12 i nowszy. - Dynamiczne kolory są włączone: akcent pochodzi z tapety. - Playfair Display i Source Sans 3 - Poppins i Source Sans 3 - Wszędzie IBM Plex Sans - Lista na oglądanie mieszkania - Przed wizytą: sprawdzić ciśnienie wody, zapytać, ile kosztowało ogrzewanie zeszłej zimy. - Osobiste - Podgląd - Motyw, tryb i czcionka - Poprawnie - Wpisane cyfry: %1$d z %2$d - Odblokuj odciskiem palca - Krok %1$d z %2$d - Notatnik, który otworzysz tylko ty. - Notatki i zadania zaszyfrowane na tym telefonie. Konfiguracja zajmie około minuty. - Bez konta - Bez rejestracji, bez e-maila, bez resetowania hasła. - Działa offline - Encly nigdy nie łączy się z internetem. - Szyfrowane na urządzeniu - Twój sejf jest zamknięty kluczem, który masz tylko ty. - Zaczynamy - Mam kopię zapasową - Wybierz 6-cyfrowy PIN - Odblokowuje Encly i chroni klucz do twojego sejfu. Nie da się go zresetować przez e-mail. - Wpisz PIN jeszcze raz - Żebyśmy mieli pewność, że to ten właściwy. - PIN nadal działa jako zapasowy sposób - Dwanaście słów przywraca wszystko - Encly da ci frazę odzyskiwania. Zapisz ją na papierze: to jedyny zapasowy klucz. - Nie pamiętasz PIN-u? - Fraza odblokowuje sejf i pozwala ustawić nowy PIN. - Otwiera twoje kopie - Zaszyfrowane pliki kopii da się odczytać tylko z tymi słowami. - Nowy telefon - Plik kopii i fraza przywracają każdą notatkę, zadanie i etykietę. - Nikt nie odzyska jej za ciebie, nawet my. Jeśli stracisz i PIN, i frazę, notatki przepadną. - Utwórz moją frazę - Pomiń: tylko PIN, bez kopii - Zapisz te słowa - Na papierze, w tej kolejności. Nie na zrzucie ekranu, nie w chmurze. - Ukryj słowa - Pokaż słowa - Słowa zapisane - Sprawdź trzy słowa - Przepisz je z kartki, nie z pamięci. - Słowo nr %1$d - Wpisz słowo - To nie jest słowo nr %1$d - Potwierdź - Pokaż słowa jeszcze raz - Twój sejf jest gotowy - Wszystko, co od teraz napiszesz, jest szyfrowane przed zapisaniem. - PIN ustawiony - Pytamy o niego przy każdym otwarciu Encly - Odblokowanie odciskiem palca włączone - Zmienisz to w Ustawieniach, Bezpieczeństwo - Fraza odzyskiwania sprawdzona - Przechowuj kartkę w bezpiecznym miejscu - Brak frazy odzyskiwania - Kopie zapasowe są wyłączone. Frazę możesz utworzyć później w Ustawieniach, Bezpieczeństwo. - Kopia przywrócona - Twoje notatki, zadania i etykiety wróciły - Wskazówka: co jakiś czas eksportuj zaszyfrowaną kopię w Ustawieniach, Kopia zapasowa. Otwiera ją twoja fraza. - Otwórz mój notatnik - Przywróć, potem ustaw PIN - Wybierz plik kopii Encly, a potem wpisz 12 słów, z którymi go utworzono. - 1 · Plik kopii - Dotknij, aby wybrać inny plik - 2 · Fraza odzyskiwania - Te słowa staną się też frazą odzyskiwania tego telefonu. - Notatki - Zablokuj teraz - Szukaj w tytułach i treści - Opcje widoku - Brak wyników dla „%1$s” - Dzisiaj - Wczoraj - - Zadania · %d otwarte - Zadania · %d otwarte - Zadania · %d otwartych - Zadania · %d otwartego - - Zadania · brak otwartych - Nie ma jeszcze zadań - Stuknij Nowe zadanie, aby dodać pierwsze. - Wszystko zrobione - Zrobione · %1$d - Brak notatek z tagiem „%1$s” - Tutaj pojawią się notatki z tym tagiem. - Pokaż wszystkie notatki - Kosz jest pusty - Stuknij notatkę, aby ją przeczytać. Przytrzymaj, aby zaznaczyć. - Usuń na zawsze - Opróżnij kosz - Szukaj notatek - Notatki i zadania - Wersja, polityka prywatności i kontakt - Odpowiedzi na częste pytania - Licencje open source - Biblioteki i czcionki, których używa Encly - Zbudowane na open source - Encly powstał dzięki tym bibliotekom i czcionkom. Stuknij, aby przeczytać licencję. - Biblioteki - Czcionki - Nie ma jeszcze tagów - Tagi grupują notatki. Utwórz tag powyżej, a potem wybierz go dla notatki, przytrzymując ją. - Stuknij tag, aby zmienić nazwę, ukryć lub usunąć. Przeciągnij uchwyt, aby zmienić kolejność. - Dodaj tag - Nazwa - Pokazuj na liście notatek - Ukryty tag zachowuje notatki, ale nie ma filtra - Przeciągnij, aby zmienić kolejność - Przenieś wyżej - Przenieś niżej - - Zaznaczono %d - Zaznaczono %d - Zaznaczono %d - Zaznaczono %d - - Edytuj etykiety - Tytuł - Nie zapisano - Zarządzaj etykietami - Odrzucić zmiany? - Wszystko, co zmieniono od otwarcia tej notatki, zostanie utracone. Tej operacji nie można cofnąć. - Wszystko już jest twoje - Każda funkcja jest dostępna dla wszystkich. Nic nie jest ukryte za płatnością. - Bez reklam i śledzenia - Encly nigdy nie widzi Twoich notatek i nie zarabia na danych. Utrzymują go wyłącznie darowizny. - Na co idą pieniądze - Urządzenia testowe (najbliższy cel: Mac mini do budowania i testowania) oraz czas na wydania i poprawki. - Karta lub PayPal, jednorazowo albo co miesiąc - Otwiera ko-fi.com w przeglądarce. Sam Encly nigdy nie obsługuje płatności. - Co robi długie naciśnięcie notatki? - Otwiera akcje notatki: zmianę etykiety, dodanie krótkiego opisu widocznego na karcie, duplikowanie notatki lub przeniesienie jej do kosza. - Jak odzyskać usuniętą notatkę? - Usunięte notatki trafiają do Kosza w menu. Tam możesz przywrócić notatkę lub usunąć ją na zawsze. Encly nigdy sam nie opróżnia kosza. - Co robi „Zablokuj edycję”? - Ustawia notatkę tylko do odczytu, żeby nie zmienić jej przypadkiem. Tekst nadal można zaznaczyć i skopiować. Włączysz i wyłączysz to w menu edytora. - Jak działają kopie zapasowe? - W Ustawienia → Kopia zapasowa eksportujesz jeden zaszyfrowany plik, który otworzy tylko twoja fraza odzyskiwania. Aby przywrócić, wybierz plik, wpisz lub wklej 12 słów, a następnie scal go z tym sejfem albo zastąp wszystko. - Pokaż klawiaturę - Ukryj klawiaturę - Wymazać wszystkie dane? - Nie udało się usunąć danych. Nic nie zostało zmienione. - Wymaż - Podzadania - Podzadanie - Dodaj podzadanie - Usuń podzadanie - Wszystkie podzadania wykonane - Wykonaj zadanie - Pokaż podzadania - Ukryj podzadania - Rozwinięte - Zwinięte - Podzadanie usunięte - Następny krok: %1$s - Pierwszy krok - - Wykonano %1$d z %2$d podzadania - Wykonano %1$d z %2$d podzadań - 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 - Przypnij - Odepnij - Archiwizuj - Przywróć z archiwum - Przypięte - Pozostałe - Archiwum - Archiwum jest puste - Zarchiwizowane notatki znikają z listy i wyszukiwania, ale zostają w sejfie. Zarchiwizuj notatkę z jej menu. - Nie udało się zarchiwizować notatki - Nie udało się przywrócić notatki z archiwum - Kolor - Brak koloru - Czerwony - Pomarańczowy - Żółty - Zielony - Turkusowy - Niebieski - Fioletowy - Różowy - Eksportuj jako Markdown - Kopiuj jako Markdown - Skopiowano jako Markdown - Czytelny eksport - Zapisz wszystkie notatki i zadania jako czytelne pliki .md w archiwum .zip. Plik nie jest zaszyfrowany. - Eksportować bez szyfrowania? - Wyeksportowany plik nie jest zaszyfrowany. Każdy, kto go zdobędzie, przeczyta twoje notatki. Miejsce, w którym go zapiszesz, także folder w chmurze, będzie zawierać czytelną kopię, której Encly nie może ani chronić, ani cofnąć. Następnie potwierdzisz kodem PIN. - Eksport Markdown zapisany - Czy mogę wyeksportować notatki jako Markdown? - Tak. W menu notatki wybierz „Eksportuj jako Markdown”, aby dostać jeden plik .md, albo przejdź do Ustawienia → Kopia zapasowa → „Eksportuj jako Markdown”, aby dostać archiwum .zip ze wszystkimi notatkami (zarchiwizowane w folderze archive, bez kosza) i plikiem z zadaniami. Te pliki nie są zaszyfrowane: Encly najpierw ostrzeże, poprosi o PIN i zapisze tylko tam, gdzie wskażesz. - Gesty przesunięcia - Przesuwanie notatek - Przesuń kartę notatki, aby wykonać akcję - Przesunięcie w prawo - Przesunięcie w lewo - Przypnij lub odepnij - Następny kolor - Przenieś do kosza - Nic - Przenieść do kosza? - Notatkę można przywrócić z kosza. - Do czego służą przesunięcia notatki? - Domyślnie przesunięcie notatki w prawo przypina ją lub odpina, a w lewo archiwizuje. W Ustawienia → Gesty przesunięcia możesz wybrać dla każdego kierunku: przypnij, archiwizuj, następny kolor, przenieś do kosza (zawsze z pytaniem) lub nic, a także wyłączyć przesuwanie. TalkBack oferuje te same akcje w menu akcji notatki. - Tekst w notatkach - Wartość domyślna dla każdej notatki. Notatka może ustawić własną w swoim menu. - Użyj domyślnych ustawień tekstu - Kod źródłowy - Encly jest open source: przeczytaj kod na GitHubie - diff --git a/app/src/main/res/values-pl/strings_import.xml b/app/src/main/res/values-pl/strings_import.xml deleted file mode 100644 index df2a0c2c..00000000 --- a/app/src/main/res/values-pl/strings_import.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - Import z innych aplikacji - Importuj notatki - Z Google Keep, Standard Notes, Joplin, Notesnook lub plików Markdown - Przenieś notatki z innej aplikacji. Wybierz jej eksport, a Encly sam rozpozna format. Wszystko zostaje na tym telefonie, nic nie jest wysyłane. - Wybierz pliki - Wybierz folder - Obsługiwane aplikacje - Jak wyeksportować - Nie zostanie zaimportowane - Google Keep - Plik .zip z Google Takeout (wybierz go bez rozpakowywania) lub pojedyncze notatki .json - W Google Takeout zaznacz tylko Keep, utwórz eksport i pobierz plik .zip. Potem wybierz ten .zip tutaj. - Stan przypięcia i archiwizacji, kolory i załączniki. Tagiem zostaje tylko pierwsza etykieta. - Standard Notes - Odszyfrowana kopia (.txt lub .json) albo .zip odszyfrowanego pobrania - W Standard Notes pobierz odszyfrowaną kopię, a nie zaszyfrowaną. Potem wybierz plik lub .zip tutaj. - Zaszyfrowane kopie są odrzucane. Stan przypięcia i archiwizacji, pliki i inne typy elementów. Zachowany zostaje tylko pierwszy tag. - Joplin - Archiwum JEX (.jex), folder RAW lub eksport Markdown - W Joplin wybierz File → Export all, a potem JEX lub RAW. Następnie wybierz plik lub folder tutaj. - Załączniki i status zadań (zadania trafiają jako zwykłe notatki). Notatka dostaje swój pierwszy tag, a gdy go nie ma, nazwę notatnika. - Notesnook - Eksport Markdown, Markdown z front matter lub TXT (.zip) - W Notesnook wyeksportuj notatki jako Markdown z front matter, aby zachować daty i tagi. Potem wybierz .zip tutaj. Zaszyfrowanych kopii nie można importować. - Załączniki, stan przypięcia i archiwizacji, kolory. Tagiem zostaje pierwszy tag, a gdy go nie ma, notatnik. - Markdown i tekst - Pliki .md i .txt albo cały folder, np. z Obsidiana lub Markora - Wybierz pliki albo folder, w którym są. Nazwa pliku staje się tytułem; prosty front matter (tytuł, tagi, daty) jest odczytywany. - Obrazy, załączniki, wtyczki i formatowanie poza nagłówkami, listami, listami kontrolnymi i cytatami. Tagiem zostaje pierwszy folder. - Czytam i rozpoznaję Twoje pliki… - Gotowe do importu - Rozpoznano: %1$s · %2$s - Zaznaczone notatki zostaną dodane do tego sejfu. Nic nie jest zastępowane, a to, co już tu jest, zostaje pominięte. - Notatki z listami kontrolnymi: %1$d - Tagi: %1$d - Notatki, które trafią do kosza: %1$d - Już w tym sejfie, pominięto: %1$d - Powtórzone w Twoim wyborze, pominięto: %1$d - Miejsce docelowe: ten sejf - Załączniki, które zostaną: %1$d - Przypięte notatki, które nie pozostaną przypięte: %1$d - Zarchiwizowane notatki, które trafią jako zwykłe: %1$d - Kolory notatek, które nie zostaną zachowane: %1$d - Pominięte dodatkowe tagi (notatka ma jeden tag): %1$d - Notatki-zadania zaimportowane jako zwykłe notatki: %1$d - Pominięte elementy zaszyfrowane lub nieobsługiwane: %1$d - Pliki, których nie dało się użyć: %1$d - Archiwa w archiwach, nieotwarte: %1$d - Importuję… - Zaimportowano - Dodano: %1$d, pominięto jako duplikaty: %2$d, nie zaimportowano: %3$d. - Twoje oryginalne pliki nie zostały zmienione. Usuń je sam po sprawdzeniu notatek tutaj. - Z jakiej aplikacji są te pliki? - Pliki wyglądają na pochodzące z więcej niż jednej aplikacji. Wybierz tę do zaimportowania; reszta zostanie pominięta. - Nic nie zaimportowano - Ta kopia Standard Notes jest zaszyfrowana. Pobierz w Standard Notes odszyfrowaną kopię i wybierz ją. - To zaszyfrowana kopia Notesnook. W Notesnook wyeksportuj notatki jako Markdown i wybierz plik .zip. - Te pliki nie są obsługiwanym eksportem. Sprawdź obsługiwane aplikacje albo wybierz pliki Markdown lub tekstowe. - Ten wybór jest większy, niż Encly może zaimportować naraz (64 MB notatek, 2 GB łącznie lub 20 000 notatek). Zaimportuj go w częściach. - W tym wyborze nie znaleziono notatek. - Nie udało się odczytać wybranych plików. - Na tym telefonie nie ma aplikacji do wybierania plików, której Encly mógłby użyć. - Sejf zamknął się podczas importu. Odblokuj Encly i spróbuj ponownie. - Coś poszło nie tak i nic nie zaimportowano. Twój sejf pozostał bez zmian. - Zaznaczono %1$d z %2$d - Zaznacz wszystkie - Odznacz wszystkie - Wszystkie - Tylko nowe - W koszu - Bez tytułu - Lista kontrolna - W koszu - Już w sejfie - Importuj: %1$d - Wybierz ponownie - diff --git a/app/src/main/res/values-pl/strings_security.xml b/app/src/main/res/values-pl/strings_security.xml deleted file mode 100644 index 0b2711ba..00000000 --- a/app/src/main/res/values-pl/strings_security.xml +++ /dev/null @@ -1,132 +0,0 @@ - - - Wpisz lub wklej swoje 12 słów - Słowo %1$d z %2$d - Słowo %1$d nie należy do słów frazy odzyskiwania. Sprawdź pisownię. - Te 12 słów nie tworzy prawidłowej frazy odzyskiwania. Sprawdź każde słowo i ich kolejność. - Ten sejf ma już frazę odzyskiwania - Dodać do notatek czy je zastąpić? - Ta kopia zawiera: %1$s, %2$s i %3$s. - Wszystko tutaj zostaje, a brakujące elementy zostaną dodane. Te, które już są, zostaną pominięte. - Zastąp wszystko - Usuń wszystkie notatki, zadania i tagi z tego telefonu, a potem przywróć kopię. - Zachowaj wszystkie notatki, zadania i tagi w jednym zaszyfrowanym pliku i przywróć je na dowolnym telefonie. - Eksport - Import - Twoje notatki są zaszyfrowane. Odblokowuje je Twój PIN (i odcisk palca, jeśli jest włączony). - Twoje notatki są zaszyfrowane. Odblokowuje je Twój PIN (i odcisk palca, jeśli jest włączony), a gdy zapomnisz PIN, fraza odzyskiwania. - Utworzona podczas konfiguracji. Encly nigdy nie przechowuje słów i nie może ich ponownie pokazać. Nie masz ich? Zastąp frazę poniżej. - Skonfigurowana - Po zresetowaniu możesz przywrócić plik kopii zapasowej za pomocą jego 12 słów. - Zmień PIN - - %d notatka - %d notatki - %d notatek - %d notatki - - - %d zadanie - %d zadania - %d zadań - %d zadania - - - %d tag - %d tagi - %d tagów - %d tagu - - - Twój PIN nie może już odblokować Encly na tym telefonie: jego klucz sprzętowy został zresetowany. Użyj frazy odzyskiwania (lub odcisku palca), a potem ustaw nowy PIN. - - Zbyt wiele prób. Spróbuj ponownie za %d minutę - Zbyt wiele prób. Spróbuj ponownie za %d minuty - Zbyt wiele prób. Spróbuj ponownie za %d minut - Zbyt wiele prób. Spróbuj ponownie za %d minuty - - - Zbyt wiele prób. Spróbuj ponownie za %d godzinę - Zbyt wiele prób. Spróbuj ponownie za %d godziny - Zbyt wiele prób. Spróbuj ponownie za %d godzin - Zbyt wiele prób. Spróbuj ponownie za %d godziny - - Zmień frazę odzyskiwania - Otrzymaj 12 nowych słów. Kopie zapasowe zrobione wcześniej nadal wymagają starych. - Zmienić frazę odzyskiwania? - Otrzymasz 12 nowych słów. Stara fraza przestanie odblokowywać Twoje notatki na tym telefonie. Kopie zapasowe, które już masz, otworzą się tylko starą frazą, więc zachowaj ją, dopóki przechowujesz te kopie, albo zrób potem nową kopię. - Otrzymaj nowe słowa - Stara fraza przestaje tu działać - Po potwierdzeniu tylko te słowa odblokują Twoje notatki, gdy zapomnisz PIN. Kopie zapasowe zrobione wcześniej nadal wymagają starych słów. - Fraza odzyskiwania zmieniona. Zrób nową kopię zapasową, aby używać jej także do kopii. - Prywatność - Ścisła prywatność klawiatury - Prosi klawiaturę o brak podpowiedzi, brak przewidywania w chmurze i nieodczytywanie tekstu wokół kursora. Wyłącza autokorektę, a niektóre klawiatury ukrywają emoji lub wprowadzanie głosowe. - Blokuj po wyjściu - Jak długo Encly pozostaje otwarty, gdy przełączasz się do innej aplikacji. Wyłączenie ekranu lub zablokowanie telefonu zawsze blokuje go od razu. - Od razu - Po 15 sekundach - 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. - PIN powiązany z tym telefonem - PIN jest łączony z kluczem w bezpiecznym układzie telefonu, więc nie da się go odgadnąć nigdzie indziej. - Osobny zamek dla każdego wejścia - PIN, fraza odzyskiwania i odcisk palca otwierają własne zaszyfrowane miejsca. Odcisk jest wymagany za każdym razem. - Strefa zagrożenia - Przytrzymaj 5 sekund, aby usunąć - - PIN kasujący - Drugi PIN, który kasuje notatki, gdy wpiszesz go na ekranie blokady - Wyłączony po zresetowaniu klucza PIN. Ustaw go ponownie, jeśli go używałeś. - Jeśli ktoś zmusza cię do otwarcia Encly, wpisz PIN kasujący zamiast swojego PIN-u. Encly bez żadnego śladu skasuje notatki, zadania i etykiety i otworzy się jako pusty sejf. Działa tylko na ekranie blokady. - Po skasowaniu Encly otwiera się PIN-em kasującym: staje się on PIN-em nowego, pustego sejfu. Twój stary PIN przestaje działać, więc ktoś, kto widział, jak wpisujesz PIN kasujący, może nim znów otworzyć Encly i nie zauważy nic dziwnego. - Wyeksportowane już pliki kopii zapasowych pozostają nietknięte. Nadal otwierają się frazą odzyskiwania i są jedyną drogą powrotu. - Odblokowanie biometryczne pozostaje włączone: ktoś może zmusić cię do przyłożenia palca do czytnika. Wyłącz je, jeśli to dla ciebie ryzyko. - Wyłącz odblokowanie biometryczne - Chroni, gdy ktoś zmusza cię do odblokowania, ale nie przed analizą kryminalistyczną telefonu. Podczas blokady po nieudanych próbach PIN kasujący również nie działa. Encly nigdy nie pokazuje, czy PIN kasujący jest ustawiony: nowy zastępuje stary, a jeśli zmienisz swój PIN na PIN kasujący, zostanie on wyłączony. - Ustaw PIN kasujący - Wyłącz PIN kasujący - Potwierdź swój PIN, aby skonfigurować PIN kasujący - Utwórz PIN kasujący - 6 cyfr, inny niż twój PIN - Potwierdź PIN kasujący - Wpisz PIN kasujący ponownie - PIN kasujący musi różnić się od twojego PIN-u - Nie udało się zapisać PIN-u kasującego. Spróbuj ponownie. - PIN kasujący ustawiony - PIN kasujący wyłączony - Klucz twojego PIN-u został zresetowany, więc ewentualny PIN kasujący został wyłączony. Ustaw go ponownie, jeśli chcesz. - Ten PIN jest już używany. Wybierz inny. - PIN przymusowy - Drugi PIN, który otwiera osobny sejf z własnymi notatkami - Wyłączony, gdy klucz twojego PIN-u został zresetowany. Ustaw go ponownie, jeśli chcesz go mieć. - Jeśli ktoś zmusza cię do otwarcia Encly, wpisz PIN przymusowy zamiast swojego PIN-u. Encly otworzy osobny, pusty sejf, z którego możesz korzystać jak zwykle: dodane tam notatki w nim zostają. - Twój prawdziwy sejf pozostaje zablokowany i nietknięty. W przeciwieństwie do PIN-u kasującego PIN przymusowy niczego nie usuwa. Odcisk palca i fraza odzyskiwania zawsze otwierają prawdziwy sejf. - Chroni cię, gdy ktoś każe ci otworzyć aplikację i wszystko pokazać, ale nie przed kryminalistycznym badaniem telefonu. W czasie blokady po błędnych próbach PIN przymusowy też nie działa. - PIN przymusowy jest ustawiony. - PIN przymusowy nie jest ustawiony. - Ustaw PIN przymusowy - Zmień PIN przymusowy - Wyłącz PIN przymusowy - Potwierdź swój PIN, aby ustawić PIN przymusowy - Utwórz PIN przymusowy - 6 cyfr, inny niż twój PIN - Potwierdź PIN przymusowy - Wpisz PIN przymusowy ponownie - PIN przymusowy musi być inny niż twój PIN - Nie udało się zapisać PIN-u przymusowego. Spróbuj ponownie. - PIN przymusowy ustawiony. - PIN przymusowy zmieniony. Sejf-przynęta zachowuje swoje notatki. - PIN przymusowy wyłączony - Klucz twojego PIN-u został zresetowany, więc PIN przymusowy przestał działać. Ustaw go ponownie; sejf-przynęta zachowuje swoje notatki. - Wyłączyć PIN przymusowy? - Sejf-przynęta i notatki w nim zostaną usunięte. Twój prawdziwy sejf pozostanie bez zmian. - Wyłącz - diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml deleted file mode 100644 index 58d0f56c..00000000 --- a/app/src/main/res/values-pt/strings.xml +++ /dev/null @@ -1,662 +0,0 @@ - - - Sobre - Um espaço local e criptografado para notas, ideias e tarefas, sem sincronização na nuvem. - Proteção de dados - Os dados do cofre são armazenados localmente em um banco de dados SQLCipher criptografado - Perguntas frequentes - Feedback - Relate um bug ou sugira uma melhoria no GitHub - Porque importa e como ajudar no Ko-fi - Nenhum navegador encontrado. Abra o link manualmente: %1$s - Nenhum app de e-mail encontrado - Política de privacidade - Como armazenamos e protegemos seus dados - Avaliar o app - Deixe uma avaliação na Google Play Store - Suporte e contato - Falar com o desenvolvedor - Se você tiver dúvidas ou sugestões, pode me enviar um e-mail. - - Continuar - - Adicionar - - Todas - - Chave de acesso obrigatória. Toque para alterar o PIN. - PIN de 6 dígitos - Autenticação - - Voltar - - A alteração da proteção biométrica não foi confirmada - Não foi possível criar a chave biométrica - Confirme sua identidade - Autenticação - Confirme sua biometria - Acesso à chave - Confirme sua biometria para alterar a proteção - Confirmação de segurança - Confirme sua biometria para acessar a chave criptografada - Desbloquear o Encly - Usar PIN - A autenticação biométrica não está disponível - - Mais ferramentas de bloco - Lista com marcadores - Lista de verificação - Título - Título %1$d - Link - Lista - Bloquear lista de tarefas - Desbloquear lista de tarefas - Mover bloco para baixo - Mover bloco para cima - Lista numerada - Separador - - Cancelar - - - Quer mesmo excluir estas notas permanentemente? Esta ação não pode ser desfeita. - Quer mesmo esvaziar a lixeira permanentemente? Esta ação não pode ser desfeita. - Esvaziar a lixeira? - - Limpar - - Fechar - - Recolher - - Criar uma etiqueta - - Hoje %1$s - Ontem %1$s - - Excluir - Excluir bloco - Excluir - Excluir etiqueta - Esta etiqueta será removida de todas as suas notas. As notas em si não serão excluídas. - - Excluir - Cancelar - Quer mesmo excluir esta nota permanentemente? Esta ação não pode ser desfeita. - Excluir a nota? - - O Encly é gratuito e de código aberto, sem anúncios, sem rastreamento e sem contas. As doações são a sua única receita. - Apoiar o desenvolvimento - - Concluído - - Duplicar - - Cores dinâmicas - - Editar - - Bloquear edição - - Ainda não há notas - Toque em Nova nota para escrever a primeira. - As notas eliminadas ficam aqui até as restaurar ou eliminar de vez. - - Digite um link - - Não foi possível abrir o banco de dados - - Expandir - - Não foi possível carregar o conteúdo - - O Encly salva as suas alterações e, após o atraso escolhido em Configurações → Segurança (15 segundos por padrão), fecha o banco de dados criptografado e apaga a chave de sessão. Desligar a tela ou bloquear o telefone faz isso na hora. Depois, você desbloqueia de novo. - O que acontece quando saio do app? - Use a chave de biometria em Configurações → Segurança. Criar ou remover o slot biométrico exige autenticação biométrica forte. - Como ativo ou desativo a biometria? - Abra Configurações → Segurança, confirme seu PIN atual e digite o novo duas vezes. - Como altero meu PIN? - O Encly gera uma chave aleatória de 256 bits para o banco de dados. Seu PIN protege um slot de desbloqueio AES-GCM separado, e a biometria usa uma chave do Android Keystore que exige autenticação a cada uso. - Como a chave do banco de dados é protegida? - Na tela de bloqueio, toque em \"Recuperar acesso\", digite as 12 palavras e defina um novo PIN. Sem frase de recuperação não há outra forma de entrar: ninguém, nem o desenvolvedor, pode redefini-lo. - E se eu esquecer meu PIN? - Abra Configurações → Idioma e escolha um dos idiomas disponíveis, ou \"Padrão do sistema\" para seguir o idioma do dispositivo. A alteração é aplicada na hora. - Como altero o idioma? - Toque em \"Nova nota\" na tela inicial. A barra acima do teclado adiciona títulos, listas de verificação, listas com marcadores e numeradas, citações, links e separadores. - Como crio uma nova nota? - É uma chave reserva de 12 palavras. Ela desbloqueia o cofre se você esquecer o PIN e é a única chave dos seus arquivos de backup. Você a cria durante a configuração, ou depois em Configurações → Segurança se tiver pulado. Guarde-a no papel, offline. - Para que serve a frase de recuperação? - Abra \"Sobre\" e use a opção de feedback ou escreva para o desenvolvedor. Nunca inclua sua frase de recuperação, seu PIN ou o texto privado das notas em um relatório de bug. - Como relato um bug? - Automaticamente enquanto você digita, e sair do editor conclui o último salvamento. Se um salvamento falhar, o editor mostra \"Não guardado\" em vermelho. - Como as alterações são salvas? - Notas, tarefas, criptografia local e recuperação de acesso, sem sincronização na nuvem. - Respostas às perguntas mais comuns - Não. A proteção de tela está sempre ativa: o Encly bloqueia capturas de tela, gravação de tela e pré-visualizações sem proteção na lista de apps recentes. - Posso fazer capturas de tela do Encly? - Noções básicas - Segurança e recuperação - Configurações - Solução de problemas - Notas, etiquetas e tarefas são armazenadas apenas no banco de dados SQLCipher local do Encly. Não há sincronização na nuvem nem backup automático: você mesmo pode exportar um backup criptografado em Configurações → Backup, ou uma cópia legível em Markdown, que não é criptografada. - Onde meus dados ficam armazenados? - As etiquetas agrupam as notas. Você pode criá-las, renomeá-las, ocultá-las e excluí-las, arrastá-las em qualquer ordem e filtrar a tela inicial por etiqueta. - Como funcionam as etiquetas? - As tarefas têm descrição e prioridade. Você pode filtrá-las por prioridade e marcá-las como concluídas. O Encly nunca coloca o texto das tarefas nas notificações do sistema. - O que as tarefas fazem? - Em Configurações → Aparência escolha um de cinco temas, modo claro, escuro ou do sistema, um de três estilos de fonte e o tamanho do texto. No Android 12 e posteriores você também pode usar a cor de destaque do papel de parede. - Como altero o tema? - O Encly nunca cria de propósito um banco de dados vazio no lugar de um cofre perdido ou ilegível. Não confirme a limpeza total se ainda precisar dos seus dados locais. - O Encly informa que o cofre está danificado - - Tamanho do texto - Espaçamento entre linhas - Padrão - Compacto - Espaçoso - Editorial - Moderno - Técnico - - - Escolher idioma - Padrão do sistema - - Adicionar link - - Item da lista - - - Voltar ao PIN - Recuperar acesso - Digite sua frase de recuperação de 12 palavras - Recuperação - Digite seu PIN de 6 dígitos para continuar - Desbloqueie suas notas - Desbloqueando… - Recuperar o acesso com a frase de recuperação - PIN incorreto - Frase de recuperação incorreta - - Mantenha pressionado para apagar tudo - Parece que os dados criptografados ou as chaves de criptografia foram alterados ou danificados, por isso não é possível recuperá-los.\n\nPara continuar usando o app, você precisa redefini-lo e apagar os dados danificados. Isso exclui permanentemente todas as notas e configurações salvas. - Infelizmente, seus dados estão danificados - Estes dados são de uma versão anterior - O Encly encontrou notas salvas por uma versão anterior à 2.0. O Encly 2.0 usa um novo formato criptografado e não consegue abri-las, e não é possível convertê-las.\n\nPara usar o app, comece do zero. Isso exclui para sempre as notas e configurações antigas deste telefone. - Mantenha pressionado para começar do zero - - Perguntas frequentes - Configurações - Apoiar o desenvolvedor - Etiquetas - Tarefas - Lixeira - - Mais opções - - - Sem etiqueta - - Nova nota - Não foi possível excluir a nota - Descrição… - Descartar todas as alterações - Não foi possível descartar as alterações - Não foi possível criar uma cópia - Não foi possível ler o conteúdo da nota. A edição foi desativada para não danificar os dados. - - %1$s (cópia) - Não foi possível restaurar a nota - Não foi possível salvar as alterações - Não foi possível mover a nota para a lixeira - - Não foi possível inicializar o app - Não foi possível criar a frase de recuperação - Não foi possível criar o cofre criptografado - - Abrir menu - - %1$d%% - - Digite o PIN novamente para confirmar - Confirme seu PIN de 6 dígitos - Confirme seu PIN atual antes de alterar sua chave de acesso - Digite seu PIN atual - Digite um novo PIN para proteger suas notas - PIN alterado - Crie um PIN de 6 dígitos - O PIN atual está incorreto - Apagar dígito - Os PINs não coincidem. Tente novamente. - Não foi possível salvar o PIN - Não foi possível atualizar o PIN. Tente novamente. - - - Toque para começar a editar… - - Alta - Baixa - Média - Escolha uma prioridade - - Citação - Refazer - - Restaurar - - Salvar - - - - - Limpar pesquisa - - Impressão digital - Desbloqueio biométrico - Indisponível neste dispositivo - Nenhuma digital cadastrada. Adicione uma nas configurações do sistema. - A criptografia está ativa - Não configurada. Sem ela, esquecer o PIN significa perder tudo. Toque para criar uma. - Apagar todos os dados - Excluir todas as notas, tarefas e etiquetas e as chaves de criptografia deste dispositivo - Todas as notas, tarefas e etiquetas e as chaves de criptografia serão excluídas deste dispositivo, e o Encly vai recomeçar do zero. Isso não pode ser desfeito: exporte um backup antes se quiser guardar algo. - Criptografia e proteção de dados - Segurança - - - Aparência - Geral - Idioma - Privacidade - Mostrar tarefas - Mostrar o cartão de tarefas na tela inicial - Edição simples - Desativar os blocos dinâmicos e editar as notas como texto simples - - Ordenar por: - Por data de criação (mais antigas primeiro) - Criada ↑ - Por data de criação (mais recentes primeiro) - Criada ↓ - Por última edição (mais antigas primeiro) - Editada ↑ - Por última edição (mais recentes primeiro) - Editada ↓ - Apoiar o desenvolvedor - - Não foi possível salvar a etiqueta. Tente novamente. - Não foi possível excluir a etiqueta. Tente novamente. - Editar etiqueta - Etiqueta oculta - Já existe uma etiqueta com este nome - Não foi possível salvar a ordem das etiquetas. Tente novamente. - Não foi possível atualizar a etiqueta. Tente novamente. - - Toque de novo para excluir - - Nova tarefa - Não foi possível limpar as tarefas concluídas. Tente novamente. - Quer mesmo excluir todas as tarefas concluídas? Esta ação não pode ser desfeita. - Limpar tarefas concluídas - Concluída: %1$s - Não foi possível salvar a tarefa. Tente novamente. - Descrição - Editar tarefa - Nenhuma tarefa ativa. - Nenhuma tarefa corresponde aos filtros selecionados. - Todas as tarefas - Concluídas - Nova tarefa - Progresso - Não foi possível alterar o status da tarefa. Tente novamente. - Não foi possível atualizar a tarefa. Tente novamente. - - Texto - - Tema - Escuro - Claro - Sistema - - - Desfazer - - Sem título - - Mudar para visualização em grade - Mudar para visualização em lista - - - Excluir %d nota? - Excluir %d notas? - Excluir %d notas? - - - Muitas tentativas. Tente novamente em %d segundo - Muitas tentativas. Tente novamente em %d segundos - Muitas tentativas. Tente novamente em %d segundos - - - %1$d de %2$d tarefa concluída - %1$d de %2$d tarefas concluídas - %1$d de %2$d tarefas concluídas - - Backup - Backup criptografado, importação e Markdown - Backup - Criptografado com sua frase de recuperação - Um backup contém todas as suas notas, tarefas e etiquetas, criptografadas com sua frase de recuperação de 12 palavras. O Encly nunca o envia para lugar nenhum: você escolhe onde o arquivo é salvo. Em um celular novo, as mesmas 12 palavras o restauram. - Última exportação: %1$s - Nenhum backup exportado ainda - Exportar backup - Importar backup - Restaurar notas, tarefas e etiquetas de um arquivo de backup - Backup salvo - Confirme que é você - Digite seu PIN para continuar - Frase de recuperação - Os backups são criptografados com uma frase de recuperação, então só as 12 palavras dela podem abri-los. Este cofre ainda não tem uma. Crie agora: ela também permite desbloquear o cofre se você esquecer o PIN. - Criar frase de recuperação - Anote estas 12 palavras em papel, na ordem, e guarde-as offline. Quem as tiver pode abrir seus backups. - Já anotei - As palavras não conferem. Verifique o que você anotou. - Frase de recuperação salva - Confirme sua frase de recuperação - Digite uma vez a frase de recuperação de 12 palavras deste cofre para que o Encly possa criptografar backups com ela. - Digite a frase de recuperação - Digite as 12 palavras da frase de recuperação com a qual este backup foi feito. - Descriptografar - Importar backup - Mesclar - Substituir - Substituir todos os dados? - Todas as notas, tarefas e etiquetas deste cofre serão apagadas e substituídas pelo backup. Isso não pode ser desfeito. - Importado: notas %1$d, tarefas %2$d, etiquetas %3$d. Ignorado (já existia): %4$d. - Este arquivo não é um backup do Encly - Este backup foi feito por uma versão mais nova do Encly. Atualize o app e tente novamente. - O arquivo de backup está danificado ou incompleto - O arquivo é grande demais para ser um backup do Encly - Seu cofre é grande demais para um único arquivo de backup (64 MB). Nada foi salvo. - Esse arquivo já existe. Escolha um novo nome: um backup anterior nunca é substituído. - Nenhum app neste dispositivo pode abrir ou salvar arquivos. - Estas palavras não abrem este backup, ou o arquivo foi modificado - Digite uma frase de recuperação válida de 12 palavras - O cofre foi bloqueado. Desbloqueie-o e tente novamente. - Não foi possível ler ou gravar o backup. Nada foi alterado. - Estas palavras não são a frase de recuperação deste cofre - Não foi possível salvar a frase de recuperação - Restaurar de um backup - Escolher um arquivo de backup - Não foi possível restaurar o backup, então a configuração ainda não foi concluída. Tente de novo ou continue com um cofre vazio e importe o arquivo depois em Configurações → Backup. - Backup não restaurado - Tentar de novo - Continuar sem ele - Esta versão do Encly não consegue ler esta nota. - Abrir link - Copiar link - Editar link - Link copiado - Nenhum app deste dispositivo consegue abrir este link. - Só é possível salvar ou abrir links da web (http, https) e de e-mail. - Não foi possível ler suas notas - Não foi possível ler suas etiquetas - Suas notas continuam guardadas e criptografadas neste dispositivo. Feche o Encly e abra de novo para tentar outra vez. - Você desbloqueou o Encly com a frase de recuperação. Defina um novo PIN para continuar. - Excluir tarefa - Tarefa excluída - Não foi possível excluir a tarefa. Tente novamente. - Modo - Fonte - Papel - Floresta - Oceano - Grafite - Meia-noite - Segue a configuração do sistema. Agora claro. - Segue a configuração do sistema. Agora escuro. - Sempre claro. - Sempre escuro. - Meia-noite é sempre escuro. - Usa a cor de destaque do seu papel de parede. Android 12 ou mais recente. - As cores dinâmicas estão ativadas: o destaque vem do seu papel de parede. - Playfair Display e Source Sans 3 - Poppins e Source Sans 3 - IBM Plex Sans em tudo - Checklist da visita ao apartamento - Antes da visita: verificar a pressão da água, perguntar quanto custou o aquecimento no inverno passado. - Pessoal - Prévia - Tema, modo e fonte - Correto - Dígitos inseridos: %1$d de %2$d - Desbloquear com a digital - Passo %1$d de %2$d - Um caderno que só você pode abrir. - Notas e tarefas, criptografadas neste celular. A configuração leva cerca de um minuto. - Sem conta - Sem cadastro, sem e-mail, sem redefinição de senha. - Funciona offline - O Encly nunca se conecta à internet. - Criptografado no aparelho - Seu cofre é trancado com uma chave que só você tem. - Começar - Tenho um backup - Escolha um PIN de 6 dígitos - Ele desbloqueia o Encly e protege a chave do seu cofre. Não há e-mail para redefini-lo. - Digite o PIN novamente - Para termos certeza de que é o que você queria. - O PIN continua funcionando como alternativa - Doze palavras trazem tudo de volta - O Encly vai lhe dar uma frase de recuperação. Anote-a no papel; ela é a única chave reserva. - Esqueceu o PIN? - A frase desbloqueia seu cofre e permite definir um novo PIN. - Abre seus backups - Arquivos de backup criptografados só podem ser lidos com estas palavras. - Celular novo - Arquivo de backup mais frase restauram cada nota, tarefa e etiqueta. - Ninguém pode recuperá-la por você, nem mesmo nós. Se perder o PIN e a frase, as notas se perdem. - Criar minha frase - Pular: só PIN, sem backups - Anote estas palavras - No papel, nesta ordem. Nem em captura de tela, nem na nuvem. - Ocultar palavras - Mostrar palavras - Já anotei - Confira três palavras - Digite-as a partir do papel, não de memória. - Palavra nº %1$d - Digite a palavra - Essa não é a palavra nº %1$d - Confirmar - Mostrar as palavras de novo - Seu cofre está pronto - Tudo o que você escrever a partir de agora é criptografado antes de ser salvo. - PIN definido - Pedido sempre que o Encly abre - Desbloqueio por digital ativado - Altere em Configurações, Segurança - Frase de recuperação conferida - Guarde o papel em um lugar seguro - Sem frase de recuperação - Os backups estão desativados. Você pode criar uma frase depois em Configurações, Segurança. - Backup restaurado - Suas notas, tarefas e etiquetas estão de volta - Dica: exporte de vez em quando um backup criptografado em Configurações, Backup. É a sua frase que o abre. - Abrir meu caderno - Restaurar e depois definir um PIN - Escolha seu arquivo de backup do Encly e digite as 12 palavras com que ele foi criado. - 1 · Arquivo de backup - Toque para escolher outro arquivo - 2 · Frase de recuperação - Estas palavras também passam a ser a frase de recuperação deste celular. - Notas - Bloquear agora - Pesquisar títulos e texto - Opções de visualização - Nada corresponde a “%1$s” - Hoje - Ontem - - Tarefas · %d aberta - Tarefas · %d abertas - Tarefas · %d abertas - - Tarefas · nenhuma aberta - Ainda não há tarefas - Toque em Nova tarefa para adicionar a primeira. - Tudo feito - Concluídas · %1$d - Nenhuma nota com a etiqueta «%1$s» - As notas com esta etiqueta aparecem aqui. - Mostrar todas as notas - A lixeira está vazia - Toque numa nota para a ler. Mantenha premido para selecionar. - Eliminar de vez - Esvaziar lixeira - Pesquisar notas - Notas e tarefas - Versão, privacidade e contacto - Respostas a perguntas frequentes - Licenças de código aberto - As bibliotecas e tipos de letra que o Encly usa - Feito com código aberto - O Encly é feito com estas bibliotecas e tipos de letra. Toque numa para ler a licença. - Bibliotecas - Tipos de letra - Ainda não há etiquetas - As etiquetas agrupam as notas. Crie uma acima e escolha-a para uma nota com um toque longo. - Toque numa etiqueta para a renomear, ocultar ou eliminar. Arraste a pega para reordenar. - Adicionar etiqueta - Nome - Mostrar na lista de notas - Uma etiqueta oculta mantém as notas, mas não tem filtro - Arraste para reordenar - Mover para cima - Mover para baixo - - %d selecionada - %d selecionadas - %d selecionadas - - Editar etiquetas - Título - Não guardado - Gerir etiquetas - Descartar as alterações? - Tudo o que alterou desde que abriu esta nota será perdido. Esta ação não pode ser desfeita. - Tudo já é seu - Todas as funcionalidades estão disponíveis para todos. Nada fica bloqueado atrás de um pagamento. - Sem anúncios nem rastreio - O Encly nunca vê as suas notas e não ganha nada com dados. Só as doações o sustentam. - Para onde vai o dinheiro - Dispositivos de teste (próximo objetivo: um Mac mini para compilar e testar) e tempo para versões e correções. - Cartão ou PayPal, uma vez ou todos os meses - Abre ko-fi.com no seu navegador. O próprio Encly nunca processa pagamentos. - O que faz um toque longo numa nota? - Abre as ações da nota: mudar a etiqueta, adicionar uma descrição curta mostrada no cartão, duplicar a nota ou movê-la para a lixeira. - Como recupero uma nota excluída? - As notas excluídas vão para a Lixeira no menu. Lá você pode restaurar uma nota ou excluí-la para sempre. O Encly nunca esvazia a lixeira sozinho. - O que faz \"Bloquear edição\"? - Deixa a nota somente leitura para que não seja alterada por engano. Você ainda pode selecionar e copiar o texto. Ative ou desative no menu do editor. - Como funcionam os backups? - Em Configurações → Backup você exporta um arquivo criptografado que só a sua frase de recuperação abre. Para restaurar, escolha o arquivo, digite ou cole as 12 palavras e depois mescle com este cofre ou substitua tudo. - Mostrar teclado - Ocultar teclado - Apagar todos os dados? - Não foi possível apagar os dados. Nada foi alterado. - Apagar - Subtarefas - Subtarefa - Adicionar subtarefa - Excluir subtarefa - Todas as subtarefas estão concluídas - Concluir tarefa - Mostrar subtarefas - Ocultar subtarefas - Expandido - Recolhido - Subtarefa excluída - Próximo passo: %1$s - Primeiro passo - - %1$d de %2$d subtarefa concluída - %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 - Fixar - Desafixar - Arquivar - Desarquivar - Fixadas - Outras - Arquivo - O arquivo está vazio - As notas arquivadas saem da lista e da pesquisa, mas continuam no cofre. Arquive uma nota a partir do seu menu. - Não foi possível arquivar a nota - Não foi possível tirar a nota do arquivo - Cor - Sem cor - Vermelho - Laranja - Amarelo - Verde - Turquesa - Azul - Roxo - Rosa - Exportar como Markdown - Copiar como Markdown - Copiado como Markdown - Exportação legível - Salve todas as notas e tarefas como arquivos .md legíveis em um .zip. O arquivo não é criptografado. - Exportar sem criptografia? - O arquivo exportado não é criptografado. Qualquer pessoa que o obtenha pode ler suas notas. Onde quer que você o salve, inclusive em uma pasta na nuvem, ficará uma cópia legível que o Encly não pode proteger nem retirar. Em seguida, você confirmará com seu PIN. - Exportação Markdown salva - Posso exportar minhas notas como Markdown? - Sim. No menu de uma nota, escolha “Exportar como Markdown” para um arquivo .md, ou use Configurações → Backup → “Exportar como Markdown” para um .zip com todas as notas (as arquivadas em uma pasta archive, sem a lixeira) e um arquivo de tarefas. Esses arquivos não são criptografados: o Encly avisa antes, pede seu PIN e salva apenas onde você escolher. - Ações de deslizar - Deslizar notas - Deslize uma nota para agir sobre ela - Deslizar para a direita - Deslizar para a esquerda - Fixar ou desafixar - Próxima cor - Mover para a lixeira - Nada - Mover para a lixeira? - Você poderá restaurar a nota da lixeira. - Para que servem os deslizes em uma nota? - Por padrão, deslizar uma nota para a direita a fixa ou desafixa, e para a esquerda a arquiva. Em Configurações → Ações de deslizar, você escolhe para cada direção: fixar, arquivar, próxima cor, mover para a lixeira (sempre pergunta) ou nada, ou desativa os deslizes. O TalkBack oferece as mesmas ações no menu de ações da nota. - Texto das notas - O padrão de cada nota. Uma nota pode definir o seu no próprio menu. - Usar o texto padrão - Código-fonte - O Encly é de código aberto: leia o código no GitHub - diff --git a/app/src/main/res/values-pt/strings_import.xml b/app/src/main/res/values-pt/strings_import.xml deleted file mode 100644 index aa78f301..00000000 --- a/app/src/main/res/values-pt/strings_import.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - Importar de outros apps - Importar notas - Do Google Keep, Standard Notes, Joplin, Notesnook ou arquivos Markdown - Traga notas de outro app. Escolha a exportação dele e o Encly reconhece o formato sozinho. Tudo fica neste telefone; nada é enviado. - Escolher arquivos - Escolher pasta - Apps compatíveis - Como exportar - Não importado - Google Keep - .zip do Google Takeout (escolha como está, sem descompactar) ou notas .json avulsas - No Google Takeout selecione só o Keep, crie a exportação e baixe o .zip. Depois escolha esse .zip aqui. - Estado de fixada e arquivada, cores e anexos. Só o primeiro marcador vira a tag. - Standard Notes - Backup descriptografado (.txt ou .json) ou o .zip de um download descriptografado - No Standard Notes baixe um backup descriptografado, não um criptografado. Depois escolha o arquivo ou o .zip aqui. - Backups criptografados são recusados. Estado de fixada e arquivada, arquivos e outros tipos de item. Só a primeira tag é mantida. - Joplin - Arquivo JEX (.jex), pasta RAW ou exportação Markdown - No Joplin escolha File → Export all e depois JEX ou RAW. Depois escolha o arquivo ou a pasta aqui. - Anexos e o status das tarefas (as tarefas chegam como notas comuns). Uma nota recebe a primeira tag; sem tag, o nome do caderno. - Notesnook - Exportação Markdown, Markdown com front matter ou TXT (.zip) - No Notesnook exporte as notas como Markdown com front matter para manter datas e tags. Depois escolha o .zip aqui. Backups criptografados não podem ser importados. - Anexos, estado de fixada e arquivada, cores. A primeira tag, ou o caderno se não houver, vira a tag. - Markdown e texto - Arquivos .md e .txt ou uma pasta inteira, por exemplo do Obsidian ou do Markor - Escolha os arquivos ou a pasta que os contém. O nome do arquivo vira o título; o front matter simples (título, tags, datas) é lido. - Imagens, anexos, plugins e formatação além de títulos, listas, checklists e citações. A primeira pasta vira a tag. - Lendo e reconhecendo seus arquivos… - Pronto para importar - Detectado: %1$s · %2$s - As notas marcadas são adicionadas a este cofre. Nada é substituído e o que já está aqui é ignorado. - Notas com checklists: %1$d - Tags: %1$d - Notas que vão para a lixeira: %1$d - Já neste cofre, ignoradas: %1$d - Repetidas na sua seleção, ignoradas: %1$d - Destino: este cofre - Anexos que ficam para trás: %1$d - Notas fixadas que não continuarão fixadas: %1$d - Notas arquivadas que chegam como notas comuns: %1$d - Cores de nota não mantidas: %1$d - Tags extras descartadas (uma nota tem uma tag): %1$d - Notas de tarefa importadas como notas comuns: %1$d - Itens criptografados ou sem suporte ignorados: %1$d - Arquivos que não puderam ser usados: %1$d - Arquivos dentro de arquivos, não abertos: %1$d - Importando… - Importado - %1$d adicionadas, %2$d ignoradas como duplicadas, %3$d não importadas. - Seus arquivos originais não foram alterados. Apague-os você mesmo depois de conferir as notas aqui. - De qual app são? - Os arquivos parecem vir de mais de um app. Escolha o que importar; o resto é ignorado. - Nada foi importado - Este backup do Standard Notes está criptografado. Baixe um backup descriptografado no Standard Notes e escolha-o. - Este é um backup criptografado do Notesnook. No Notesnook exporte as notas como Markdown e escolha o .zip. - Estes arquivos não são uma exportação compatível. Veja os apps compatíveis ou escolha arquivos Markdown ou de texto. - Esta seleção é maior do que o Encly consegue importar de uma vez (64 MB de notas, 2 GB no total ou 20.000 notas). Importe em partes. - Nenhuma nota foi encontrada nesta seleção. - Não foi possível ler os arquivos escolhidos. - Este telefone não tem um app seletor de arquivos que o Encly possa usar. - O cofre foi fechado durante a importação. Desbloqueie o Encly e tente de novo. - Algo deu errado e nada foi importado. Seu cofre não mudou. - %1$d de %2$d selecionadas - Selecionar tudo - Limpar seleção - Todas - Só novas - Na lixeira - Sem título - Checklist - Na lixeira - Já no cofre - Importar %1$d - Escolher de novo - diff --git a/app/src/main/res/values-pt/strings_security.xml b/app/src/main/res/values-pt/strings_security.xml deleted file mode 100644 index e97abf45..00000000 --- a/app/src/main/res/values-pt/strings_security.xml +++ /dev/null @@ -1,127 +0,0 @@ - - - Digite ou cole suas 12 palavras - Palavra %1$d de %2$d - A palavra %1$d não é uma palavra de frase de recuperação. Confira a grafia. - Estas 12 palavras não formam uma frase de recuperação válida. Confira cada palavra e a ordem delas. - Este cofre já tem uma frase de recuperação - Adicionar às suas notas ou substituí-las? - Este backup contém %1$s, %2$s e %3$s. - Tudo o que está aqui fica e o que falta é adicionado. Itens que já existem são ignorados. - Substituir tudo - Apaga todas as notas, tarefas e etiquetas deste celular e depois restaura o backup. - Guarde todas as suas notas, tarefas e etiquetas em um arquivo criptografado e restaure-as em qualquer celular. - Exportar - Importar - Suas notas estão criptografadas. Seu PIN (e sua digital, se ativada) as desbloqueia. - Suas notas estão criptografadas. Seu PIN (e sua digital, se ativada) as desbloqueia, e sua frase de recuperação se você esquecer o PIN. - Criada durante a configuração. O Encly nunca guarda as palavras e não pode mostrá-las de novo. Não as tem? Substitua a frase abaixo. - Configurada - Depois de redefinir, você pode restaurar um arquivo de backup com as 12 palavras dele. - Alterar PIN - - %d nota - %d de notas - %d notas - - - %d tarefa - %d de tarefas - %d tarefas - - - %d etiqueta - %d de etiquetas - %d etiquetas - - - Seu PIN não pode mais desbloquear o Encly neste telefone: a chave de hardware dele foi redefinida. Use sua frase de recuperação (ou impressão digital) e depois defina um novo PIN. - - Muitas tentativas. Tente novamente em %d minuto - Muitas tentativas. Tente novamente em %d minutos - Muitas tentativas. Tente novamente em %d minutos - - - Muitas tentativas. Tente novamente em %d hora - Muitas tentativas. Tente novamente em %d horas - Muitas tentativas. Tente novamente em %d horas - - Substituir a frase de recuperação - Receba 12 palavras novas. Os backups feitos antes continuam precisando das antigas. - Substituir a frase de recuperação? - Você vai receber 12 palavras novas. A frase antiga deixa de desbloquear suas notas neste telefone. Os backups que você já fez só abrem com a frase antiga, então guarde-a enquanto guardar esses backups, ou faça um novo backup depois. - Receber palavras novas - Sua frase antiga deixa de funcionar aqui - Depois de confirmar, só estas palavras desbloqueiam suas notas se você esquecer o PIN. Os backups feitos antes continuam precisando das palavras antigas. - Frase de recuperação substituída. Faça um novo backup para usá-la também nos backups. - Privacidade - Privacidade rigorosa do teclado - Pede ao teclado que não mostre sugestões, não use previsão na nuvem e não leia o texto ao redor do cursor. Desativa a correção automática, e alguns teclados ocultam emojis ou a entrada por voz. - Bloquear ao sair - Quanto tempo o Encly fica aberto quando você muda para outro app. Desligar a tela ou bloquear o telefone sempre o bloqueia na hora. - Imediatamente - Após 15 segundos - 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. - Um PIN ligado a este telefone - O seu PIN é combinado com uma chave do chip seguro do telefone, por isso não pode ser adivinhado em nenhum outro lugar. - Uma fechadura para cada acesso - PIN, frase de recuperação e impressão digital abrem cada um o seu próprio espaço criptografado. A digital é pedida sempre. - Zona de perigo - Segure 5 segundos para apagar - - PIN de apagamento - Um segundo PIN que apaga suas notas quando digitado na tela de bloqueio - Desativado quando a chave do seu PIN foi redefinida. Defina-o de novo se você usava um. - Se alguém obrigar você a abrir o Encly, digite o PIN de apagamento em vez do seu PIN. O Encly apaga suas notas, tarefas e etiquetas sem deixar sinal e abre como um cofre vazio. Só funciona na tela de bloqueio. - Depois do apagamento, o Encly abre com o PIN de apagamento: ele passa a ser o PIN do novo cofre vazio. Seu PIN antigo deixa de funcionar, então quem viu você digitar o PIN de apagamento pode abrir o Encly com ele de novo sem notar nada estranho. - Os arquivos de backup que você já exportou não são tocados. Eles continuam abrindo com sua frase de recuperação e são o único caminho de volta. - O desbloqueio biométrico continua ativado: alguém pode forçar seu dedo no sensor. Desative-o se isso for um risco para você. - Desativar desbloqueio biométrico - Isso protege você quando alguém o obriga a desbloquear, não contra uma perícia forense do telefone. Durante um bloqueio por tentativas erradas, o PIN de apagamento também não funciona. O Encly nunca mostra se há um PIN de apagamento: um novo substitui o anterior, e se você mudar seu PIN para o PIN de apagamento, ele é desativado. - Definir PIN de apagamento - Desativar PIN de apagamento - Confirme seu PIN para configurar o PIN de apagamento - Crie um PIN de apagamento - 6 dígitos, diferentes do seu PIN - Confirme o PIN de apagamento - Digite o PIN de apagamento de novo - O PIN de apagamento precisa ser diferente do seu PIN - Não foi possível salvar o PIN de apagamento. Tente novamente. - PIN de apagamento definido - PIN de apagamento desativado - A chave do seu PIN foi redefinida, então o PIN de apagamento, se houver, foi desativado. Defina-o de novo se quiser um. - Este PIN já está em uso. Escolha outro diferente. - PIN de coação - Um segundo PIN que abre um cofre separado com notas próprias - Desativado quando a chave do seu PIN foi redefinida. Defina-o de novo se quiser um. - Se alguém obrigar você a abrir o Encly, digite o PIN de coação em vez do seu PIN. O Encly abre um cofre separado e vazio que você pode usar normalmente: as notas que você adicionar ali ficam guardadas nele. - Seu cofre real continua bloqueado e intacto. Diferente do PIN de apagamento, o PIN de coação não apaga nada. Sua digital e sua frase de recuperação sempre abrem o cofre real. - Ele protege quando alguém manda você abrir o app e mostrar tudo, não contra um exame forense do telefone. Durante um bloqueio após tentativas erradas, o PIN de coação também não funciona. - Há um PIN de coação definido. - Nenhum PIN de coação definido. - Definir PIN de coação - Alterar PIN de coação - Desativar PIN de coação - Confirme seu PIN para configurar o PIN de coação - Criar um PIN de coação - 6 dígitos, diferentes do seu PIN - Confirmar o PIN de coação - Digite o PIN de coação novamente - O PIN de coação deve ser diferente do seu PIN - Não foi possível salvar o PIN de coação. Tente novamente. - PIN de coação definido. - PIN de coação alterado. O cofre isca mantém as suas notas. - PIN de coação desativado - A chave do seu PIN foi redefinida, então o PIN de coação parou de funcionar. Defina-o de novo; o cofre isca mantém as suas notas. - Desativar o PIN de coação? - O cofre isca e as notas dentro dele são excluídos. Seu cofre real não é afetado. - Desativar - diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml deleted file mode 100644 index 76ac3fcf..00000000 --- a/app/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,668 +0,0 @@ - - - Про застосунок - Локальний зашифрований простір для нотаток, ідей та завдань без хмарної синхронізації. - Захист даних - Дані сховища зберігаються локально в зашифрованій базі даних SQLCipher - Часті запитання - Зворотний зв\'язок - Повідомити про помилку або запропонувати покращення на GitHub - Навіщо це і як підтримати на Ko-fi - Не знайдено браузера. Відкрийте посилання вручну: %1$s - Поштовий клієнт не знайдено - Політика конфіденційності - Як ми зберігаємо та захищаємо ваші дані - Оцінити застосунок - Поставте оцінку в Google Play Store - Підтримка та зв\'язок - Написати розробнику - Якщо у вас є питання або пропозиції, ви можете написати мені на пошту. - - Продовжити - - Додати - - Всі - - Обов\'язковий ключ доступу. Натисніть, щоб змінити PIN. - 6-значний PIN - Авторизація - - Назад - - Зміну біометричного захисту не підтверджено - Не вдалося створити біометричний ключ - Підтвердіть свою особу - Автентифікація - Підтвердіть біометрію - Доступ до ключа - Підтвердіть біометрію для зміни захисту - Підтвердження безпеки - Підтвердіть біометрію для доступу до зашифрованого ключа - Розблокування Encly - Використати PIN - Біометрична автентифікація недоступна - - Більше інструментів блоку - Маркований список - Список завдань - Заголовок - Заголовок %1$d - Посилання - Список - Заблокувати чекліст - Розблокувати чекліст - Перемістити блок вниз - Перемістити блок вгору - Нумерований список - Роздільник - - Скасувати - - - Ви дійсно хочете видалити ці нотатки назавжди? Цю дію не можна буде скасувати. - Ви дійсно хочете назавжди очистити кошик? Цю дію не можна буде скасувати. - Очистити кошик? - - Очистити - - Закрити - - Згорнути - - Створити нову мітку - - Сьогодні %1$s - Вчора %1$s - - Видалити - Видалити блок - Видалити - Видалити мітку - Цю мітку буде видалено з усіх ваших нотаток. Самі нотатки не видалятимуться - - Видалити - Скасувати - Ви дійсно хочете видалити нотатку назавжди? Цю дію не можна буде скасувати. - Видалити нотатку? - - Encly безкоштовний і з відкритим кодом: без реклами, стеження й облікових записів. Пожертви — його єдиний дохід. - Підтримати розробку - - Готово - - Дублікат - - Динамічний колір - - Редагувати - - Заблокувати редагування - - Нотаток ще немає - Натисніть «Нова нотатка», щоб написати першу. - Видалені нотатки чекають тут, доки ви їх не відновите або не видалите назавжди. - - Введіть посилання - - Не вдалося відкрити базу даних - - Розгорнути - - Не вдалося завантажити вміст - - Encly зберігає зміни й після затримки, вибраної в Налаштування → Безпека (за замовчуванням 15 секунд), закриває зашифровану базу та очищає сесійний ключ. Вимкнення екрана чи блокування телефона робить це одразу. Після цього потрібно розблокувати знову. - Що відбувається після згортання застосунку? - У Налаштування → Безпека використайте перемикач біометрії. Щоб увімкнути або вимкнути розблокування біометрією, потрібно підтвердити його надійною біометрією (наприклад, відбитком пальця). - Як увімкнути або вимкнути біометрію? - Відкрийте Налаштування → Безпека, підтвердьте поточний PIN, а потім двічі введіть новий. - Як змінити PIN? - Encly створює випадковий 256-бітний ключ бази даних. PIN захищає окрему зашифровану (AES-GCM) копію цього ключа, а біометрія використовує ключ Android Keystore, який щоразу потребує підтвердження біометрією. - Як захищений ключ бази даних? - На екрані блокування натисніть «Відновити доступ», введіть 12 слів і задайте новий PIN. Без фрази відновлення іншого способу увійти немає: скинути його не може ніхто, навіть розробник. - Що буде, якщо я забуду PIN? - Відкрийте Налаштування → Мова та виберіть одну з доступних мов або «Як у системі», щоб використовувати мову пристрою. Зміна застосовується одразу. - Як змінити мову? - Натисніть «Нова нотатка» на головному екрані. Панель над клавіатурою додає заголовки, чеклісти, марковані й нумеровані списки, цитати, посилання та роздільники. - Як створити нову нотатку? - Це запасний ключ із 12 слів. Він відкриває сховище, якщо ви забули PIN, і це єдиний ключ до ваших резервних копій. Фразу створюють під час налаштування або пізніше в Налаштування → Безпека, якщо ви її пропустили. Зберігайте її на папері, офлайн. - Для чого потрібна фраза відновлення? - Відкрийте «Про застосунок» і скористайтеся зворотним зв\'язком або напишіть розробнику. Ніколи не додавайте до повідомлення про помилку фразу відновлення, PIN або приватний текст нотаток. - Як повідомити про помилку? - Автоматично, поки ви пишете, а вихід із редактора завершує останнє збереження. Якщо збереження колись не вдасться, редактор покаже червоне «Не збережено». - Як зберігаються зміни? - Нотатки, завдання, локальне шифрування та відновлення доступу без хмарної синхронізації. - Відповіді на найпоширеніші запитання - Ні. Захист екрана увімкнений постійно: Encly блокує знімки екрана, запис екрана та незахищений попередній перегляд у списку нещодавніх застосунків. - Чи можна робити скріншоти Encly? - Основні функції - Безпека та відновлення - Налаштування - Вирішення проблем - Нотатки, мітки та завдання зберігаються тільки в локальній SQLCipher-базі Encly. Хмарної синхронізації та автоматичного резервного копіювання немає: зашифровану копію можна експортувати самостійно в Налаштування → Резервна копія, а також читабельну копію у форматі Markdown — вона не зашифрована. - Де зберігаються мої дані? - Мітки групують нотатки. Їх можна створювати, перейменовувати, приховувати й видаляти, перетягувати в будь-якому порядку та фільтрувати за ними головний екран. - Як працюють мітки? - Завдання мають опис і пріоритет. Їх можна фільтрувати за пріоритетом і відмічати виконаними. Encly не надсилає текст завдань у системні сповіщення. - Що вміють завдання? - У Налаштування → Зовнішній вигляд виберіть одну з п’яти тем, світлий, темний чи системний режим, один із трьох стилів шрифту та розмір тексту. На Android 12 і новіших можна також брати акцентний колір зі шпалер. - Як змінити тему? - Encly навмисно ніколи не створює порожню базу даних поверх втраченого або нечитабельного сховища. Не підтверджуйте повне очищення, якщо вам ще потрібні локальні дані. - Encly повідомляє про пошкодження сховища - - Розмір тексту - Міжряддя - Звичайне - Компактне - Просторе - Редакційний - Сучасний - Технічний - - - Виберіть мову - Як у системі - - Додати посилання - - Елемент списку - - - Повернутися до PIN - Відновити доступ - Введіть фразу відновлення з 12 слів - Відновлення - Введіть 6-значний PIN для доступу - Розблокуйте нотатки - Розблокування… - Відновити доступ за фразою відновлення - Неправильний PIN-код - Неправильна фраза відновлення - - Утримуйте, щоб очистити все - Схоже, що зашифровані дані або ключі шифрування були змінені або пошкоджені, тож відновлення неможливе.\n\nЩоб і далі користуватися застосунком, потрібно скинути його та стерти пошкоджені дані. Усі збережені нотатки й налаштування буде видалено назавжди. - На жаль, дані пошкоджені - Ці дані зі старішої версії - Encly знайшла нотатки, збережені версією до 2.0. Encly 2.0 використовує новий формат шифрування і не може їх відкрити, а перетворити їх неможливо.\n\nЩоб користуватися застосунком, почніть з нуля. Старі нотатки й налаштування на цьому телефоні буде видалено назавжди. - Утримуйте, щоб почати з нуля - - Часті запитання - Налаштування - Підтримка розробника - Мітки - Завдання - Кошик - - Інші дії - - - Без мітки - - Нова нотатка - Не вдалося видалити нотатку - Опис… - Скасувати всі зміни - Не вдалося скасувати зміни - Не вдалося створити копію - Не вдалося прочитати вміст нотатки. Щоб не пошкодити дані, редагування вимкнено. - - %1$s (копія) - Не вдалося відновити нотатку - Не вдалося зберегти зміни - Не вдалося перемістити нотатку в кошик - - Не вдалося запустити застосунок - Не вдалося створити фразу відновлення - Не вдалося створити захищене сховище - - Відкрити меню - - %1$d%% - - Повторно введіть PIN-код для підтвердження - Підтвердіть 6-значний PIN-код - Підтвердіть поточний PIN перед зміною ключа доступу - Введіть поточний PIN - Введіть новий PIN-код для захисту нотаток - PIN змінено - Створіть 6-значний PIN-код - Поточний PIN неправильний - Стерти - PIN-коди не збігаються. Спробуйте ще раз. - Не вдалося зберегти PIN - Не вдалося оновити PIN. Спробуйте ще раз. - - - Натисніть, щоб почати редагування… - - Високий - Низький - Середній - Виберіть пріоритет - - Цитата - Повторити дію - - Відновити - - Зберегти - - - - - Очистити пошук - - Відбиток пальця - Розблокування біометрією - Недоступно на цьому пристрої - Відбиток пальця не додано. Додайте його в налаштуваннях системи. - Шифрування активне - Не налаштовано. Без неї забутий PIN означає втрату всього. Торкніться, щоб створити. - Стерти всі дані - Видалити всі нотатки, завдання, мітки та ключі шифрування на цьому пристрої - Усі нотатки, завдання, мітки та ключі шифрування буде видалено з цього пристрою, і Encly почне з початку. Цю дію неможливо скасувати: спершу експортуйте резервну копію, якщо хочете щось зберегти. - Шифрування та захист даних - Безпека - - - Зовнішній вигляд - Загальні - Мова - Конфіденційність - Показувати завдання - Відображати блок завдань на головній сторінці - Просте редагування - Відключити динамічні блоки та використовувати просте редагування нотаток - - Сортувати: - За датою створення (зростання) - Ств ↑ - За датою створення (спадання) - Ств ↓ - За датою редагування (зростання) - Ред ↑ - За датою редагування (спадання) - Ред ↓ - Підтримка розробника - - Не вдалося зберегти мітку. Спробуйте ще раз. - Не вдалося видалити мітку. Спробуйте ще раз. - Редагувати мітку - Прихована мітка - Мітка з такою назвою вже існує - Не вдалося зберегти порядок міток. Спробуйте ще раз. - Не вдалося оновити мітку. Спробуйте ще раз. - - Натисніть ще раз для видалення - - Нове завдання - Не вдалося очистити виконані завдання. Спробуйте ще раз. - Ви дійсно хочете видалити всі виконані завдання? Цю дію неможливо буде скасувати. - Очистити виконані завдання - Виконано: %1$s - Не вдалося зберегти завдання. Спробуйте ще раз. - Опис - Редагувати завдання - Немає активних завдань. - Немає завдань за вибраними фільтрами. - Всі завдання - Виконані - Нове завдання - Прогрес виконання - Не вдалося змінити стан завдання. Спробуйте ще раз. - Не вдалося оновити завдання. Спробуйте ще раз. - - Текст - - Тема - Темна - Світла - Як у системі - - - Скасувати дію - - Без назви - - Перемкнути на сітку - Перемкнути на список - - - Видалити %d нотатку? - Видалити %d нотатки? - Видалити %d нотаток? - Видалити %d нотатки? - - - Забагато спроб. Спробуйте через %d секунду - Забагато спроб. Спробуйте через %d секунди - Забагато спроб. Спробуйте через %d секунд - Забагато спроб. Спробуйте через %d секунди - - - Виконано %1$d з %2$d завдання - Виконано %1$d з %2$d завдань - Виконано %1$d з %2$d завдань - Виконано %1$d з %2$d завдання - - Резервна копія - Зашифрована копія, імпорт і Markdown - Резервна копія - Зашифровано вашою фразою відновлення - Резервна копія містить усі ваші нотатки, завдання та мітки, зашифровані вашою фразою відновлення з 12 слів. Encly нікуди її не вивантажує: ви самі обираєте, де зберегти файл. На новому телефоні ті самі 12 слів її відновлять. - Останній експорт: %1$s - Резервну копію ще не експортовано - Експортувати копію - Імпортувати копію - Відновити нотатки, завдання та мітки з файлу копії - Резервну копію збережено - Підтвердьте, що це ви - Введіть PIN, щоб продовжити - Фраза відновлення - Резервні копії шифруються фразою відновлення, тож відкрити їх можуть лише її 12 слів. У цьому сховищі її ще немає. Створіть її зараз: вона також дозволить розблокувати сховище, якщо ви забудете PIN. - Створити фразу відновлення - Запишіть ці 12 слів на папері, по порядку, і зберігайте офлайн. Будь-хто, хто їх має, може відкрити ваші копії. - Я їх записав(-ла) - Слова не збігаються. Перевірте, що ви записали. - Фразу відновлення збережено - Підтвердьте фразу відновлення - Один раз введіть фразу відновлення цього сховища з 12 слів, щоб Encly могла шифрувати нею копії. - Введіть фразу відновлення - Введіть 12 слів фрази відновлення, з якою створено цю копію. - Розшифрувати - Імпорт копії - Об\'єднати - Замінити - Замінити всі дані? - Усі нотатки, завдання та мітки в цьому сховищі буде видалено й замінено копією. Цю дію неможливо скасувати. - Імпортовано нотатки: %1$d, завдання: %2$d, мітки: %3$d. Пропущено (вже є): %4$d. - Цей файл не є резервною копією Encly - Цю копію створила новіша версія Encly. Оновіть застосунок і спробуйте ще раз. - Файл копії пошкоджений або неповний - Файл завеликий для резервної копії Encly - Сховище завелике для одного файлу копії (64 МБ). Нічого не збережено. - Такий файл уже існує. Виберіть нову назву: старіша копія ніколи не перезаписується. - На цьому пристрої немає застосунку для відкриття чи збереження файлів. - Ці слова не відкривають цю копію, або файл було змінено - Введіть правильну фразу відновлення з 12 слів - Сховище заблоковано. Розблокуйте його і спробуйте ще раз. - Не вдалося прочитати або записати копію. Нічого не змінено. - Ці слова не є фразою відновлення цього сховища - Не вдалося зберегти фразу відновлення - Відновити з копії - Обрати файл копії - Не вдалося відновити копію, тому налаштування ще не завершено. Спробуйте ще раз або продовжте з порожнім сховищем та імпортуйте файл пізніше в Налаштування → Резервна копія. - Копію не відновлено - Спробувати ще раз - Продовжити без неї - Цю нотатку не вдається прочитати в цій версії Encly. - Відкрити посилання - Копіювати посилання - Змінити посилання - Посилання скопійовано - На цьому пристрої немає застосунку, який може відкрити це посилання. - Зберігати й відкривати можна лише вебпосилання (http, https) та адреси пошти. - Не вдалося прочитати нотатки - Не вдалося прочитати мітки - Нотатки й далі зберігаються на пристрої в зашифрованому вигляді. Закрийте Encly і відкрийте знову, щоб повторити спробу. - Ви розблокували Encly фразою відновлення. Щоб продовжити, задайте новий PIN-код. - Видалити завдання - Завдання видалено - Не вдалося видалити завдання. Спробуйте ще раз. - Режим - Шрифт - Папір - Ліс - Океан - Графіт - Опівніч - Як у системі. Зараз світла тема. - Як у системі. Зараз темна тема. - Завжди світла. - Завжди темна. - Опівніч завжди темна. - Акцентний колір береться зі шпалер. Android 12 і новіші. - Динамічний колір увімкнено: акцент береться зі шпалер. - Playfair Display і Source Sans 3 - Poppins і Source Sans 3 - IBM Plex Sans усюди - Огляд квартири: чекліст - Перед переглядом: перевірити тиск води, спитати, скільки коштувало опалення минулої зими. - Особисте - Попередній перегляд - Тема, режим і шрифт - Правильно - Введено цифр: %1$d з %2$d - Розблокувати відбитком пальця - Крок %1$d з %2$d - Записник, який можете відкрити лише ви. - Нотатки й завдання, зашифровані на цьому телефоні. Налаштування займе близько хвилини. - Без облікового запису - Без реєстрації, без пошти, без скидання пароля. - Працює офлайн - Encly ніколи не підключається до інтернету. - Зашифровано на пристрої - Ваше сховище замкнене ключем, який є лише у вас. - Почати - У мене є резервна копія - Оберіть 6-значний PIN - Він відмикає Encly і захищає ключ до вашого сховища. Скинути його через пошту неможливо. - Введіть PIN ще раз - Щоб переконатися, що це саме той PIN. - PIN і далі працюватиме як запасний спосіб - Дванадцять слів повертають усе - Encly дасть вам фразу відновлення. Запишіть її на папері: це єдиний запасний ключ. - Забули PIN? - Фраза відмикає сховище й дозволяє задати новий PIN. - Відкриває резервні копії - Зашифровані файли копій можна прочитати лише з цими словами. - Новий телефон - Файл копії разом із фразою відновлює всі нотатки, завдання й мітки. - Ніхто не відновить її за вас, навіть ми. Якщо втратити і PIN, і фразу, нотатки буде втрачено. - Створити мою фразу - Пропустити: лише PIN, без резервних копій - Запишіть ці слова - На папері, у цьому порядку. Не на скриншоті й не в хмарі. - Сховати слова - Показати слова - Слова записано - Перевірте три слова - Вводьте їх із паперу, а не з пам’яті. - Слово №%1$d - Введіть слово - Це не слово №%1$d - Підтвердити - Показати слова ще раз - Ваше сховище готове - Усе, що ви напишете відтепер, шифрується перед збереженням. - PIN задано - Запитується щоразу, коли відкривається Encly - Розблокування відбитком увімкнено - Змінити можна в Налаштуваннях, розділ «Безпека» - Фразу відновлення перевірено - Зберігайте папірець у надійному місці - Без фрази відновлення - Резервні копії вимкнено. Створити фразу можна пізніше в Налаштуваннях, розділ «Безпека». - Резервну копію відновлено - Ваші нотатки, завдання й мітки повернулися - Порада: час від часу експортуйте зашифровану копію в Налаштуваннях, розділ «Резервна копія». Відкриває її саме ваша фраза. - Відкрити мій записник - Відновлення, потім PIN - Оберіть файл резервної копії Encly, потім введіть 12 слів, з якими її створено. - 1 · Файл копії - Торкніться, щоб обрати інший файл - 2 · Фраза відновлення - Ці слова також стануть фразою відновлення на цьому телефоні. - Нотатки - Заблокувати зараз - Пошук у заголовках і тексті - Параметри перегляду - Нічого не знайдено за запитом «%1$s» - Сьогодні - Учора - - Завдання · %d відкрите - Завдання · %d відкриті - Завдання · %d відкритих - Завдання · %d відкритих - - Завдання · немає відкритих - Завдань ще немає - Натисніть «Нове завдання», щоб додати перше. - Усе виконано - Виконано · %1$d - Немає нотаток з тегом «%1$s» - Тут з’являться нотатки з цим тегом. - Показати всі нотатки - Кошик порожній - Натисніть нотатку, щоб прочитати. Утримуйте, щоб вибрати. - Видалити назавжди - Очистити кошик - Пошук нотаток - Нотатки й завдання - Версія, політика конфіденційності й контакти - Відповіді на часті запитання - Ліцензії відкритого коду - Бібліотеки та шрифти, які використовує Encly - Побудовано на відкритому коді - Encly створено з цими бібліотеками та шрифтами. Натисніть, щоб прочитати ліцензію. - Бібліотеки - Шрифти - Тегів ще немає - Теги групують нотатки. Створіть тег вище, а потім виберіть його для нотатки в меню довгого натискання. - Натисніть тег, щоб перейменувати, приховати чи видалити. Тягніть за ручку, щоб змінити порядок. - Додати тег - Назва - Показувати в списку нотаток - Прихований тег зберігає нотатки, але не має фільтра - Перетягніть, щоб змінити порядок - Перемістити вгору - Перемістити вниз - - Вибрано %d - Вибрано %d - Вибрано %d - Вибрано %d - - Редагувати мітки - Заголовок - Не збережено - Керувати мітками - Скасувати зміни? - Усе, що ви змінили відтоді, як відкрили цю нотатку, буде втрачено. Цю дію не можна буде скасувати. - Усе вже ваше - Кожна функція доступна всім. Нічого не заблоковано за оплатою. - Без реклами й стеження - Encly не бачить ваших нотаток і не заробляє на даних. Єдине, що його утримує, — пожертви. - На що йдуть кошти - Тестові пристрої (найближча ціль — Mac mini для збирання й тестування) та час на релізи й виправлення. - Картка або PayPal, разово чи щомісяця - Відкриває ko-fi.com у браузері. Сам Encly ніколи не обробляє платежі. - Що робить довге натискання на нотатку? - Відкриває дії з нотаткою: змінити мітку, додати короткий опис на картці, дублювати нотатку або перемістити її в кошик. - Як повернути видалену нотатку? - Видалені нотатки потрапляють у Кошик у меню. Там нотатку можна відновити або видалити назавжди. Encly ніколи не очищає кошик сам. - Що робить «Заблокувати редагування»? - Робить нотатку лише для читання, щоб її не змінити випадково. Текст однаково можна виділити й скопіювати. Вмикається й вимикається в меню редактора. - Як працюють резервні копії? - У Налаштування → Резервна копія ви експортуєте один зашифрований файл, який відкриває лише ваша фраза відновлення. Щоб відновити, виберіть файл, введіть або вставте 12 слів, а тоді об’єднайте копію з цим сховищем або замініть усе. - Показати клавіатуру - Сховати клавіатуру - Стерти всі дані? - Не вдалося стерти дані. Нічого не змінено. - Стерти - Підзавдання - Підзавдання - Додати підзавдання - Видалити підзавдання - Усі підзавдання виконано - Завершити завдання - Показати підзавдання - Сховати підзавдання - Розгорнуто - Згорнуто - Підзавдання видалено - Наступний крок: %1$s - Перший крок - - %1$d з %2$d підзавдання виконано - %1$d з %2$d підзавдань виконано - %1$d з %2$d підзавдань виконано - %1$d з %2$d підзавдання виконано - - Вставити блок - Знайти в нотатці - Знайти в цій нотатці - %1$d з %2$d - Збігів немає - Наступний збіг - Попередній збіг - Закрити пошук - Дублювати блок - Почати із шаблону - Порожня - Зустріч - Щоденник - Нотатки проєкту - Учасники - Порядок денний - Нотатки - Дії до виконання - Сьогодні - Вдячність - Завтра - Мета - Завдання - Рішення - Відкриті питання - Зробити - Закріпити - Відкріпити - Архівувати - Розархівувати - Закріплені - Інші - Архів - Архів порожній - Архівовані нотатки зникають зі списку й пошуку, але залишаються в сховищі. Архівуйте нотатку з її меню. - Не вдалося перемістити нотатку в архів - Не вдалося повернути нотатку з архіву - Колір - Без кольору - Червоний - Помаранчевий - Жовтий - Зелений - Бірюзовий - Синій - Фіолетовий - Рожевий - Експортувати як Markdown - Копіювати як Markdown - Скопійовано як Markdown - Читабельний експорт - Зберегти всі нотатки й завдання як читабельні файли .md в архіві .zip. Файл не зашифрований. - Експорт без шифрування? - Експортований файл не зашифрований. Будь-хто, хто його отримає, зможе прочитати ваші нотатки. Місце, куди ви його збережете, зокрема хмарна тека, міститиме читабельну копію, яку Encly не може ні захистити, ні відкликати. Далі потрібно буде підтвердити PIN-кодом. - Експорт Markdown збережено - Чи можна експортувати нотатки у форматі Markdown? - Так. У меню нотатки виберіть «Експортувати як Markdown», щоб отримати один файл .md, або відкрийте Налаштування → Резервна копія → «Експортувати як Markdown», щоб отримати .zip з усіма нотатками (архівні лежать у теці archive, кошик не входить) і файлом із завданнями. Ці файли не зашифровані: Encly спершу попередить, попросить PIN-код і збереже їх лише туди, куди ви вкажете. - Дії свайпом - Свайп по нотатках - Проведіть по картці нотатки, щоб виконати дію - Свайп праворуч - Свайп ліворуч - Закріпити або відкріпити - Наступний колір - Перемістити в кошик - Нічого - Перемістити в кошик? - Нотатку можна відновити з кошика. - Що роблять свайпи по нотатці? - За замовчуванням свайп нотатки праворуч закріплює або відкріплює її, а ліворуч — архівує. У Налаштування → Дії свайпом можна вибрати для кожного напрямку: закріпити, архівувати, наступний колір, перемістити в кошик (завжди з підтвердженням) або нічого, а також вимкнути свайпи. TalkBack пропонує ті самі дії в меню дій нотатки. - Текст у нотатках - Типове значення для всіх нотаток. Нотатка може задати своє у власному меню. - Використати типові налаштування тексту - Вихідний код - Encly має відкритий код: читайте його на GitHub - diff --git a/app/src/main/res/values-uk/strings_import.xml b/app/src/main/res/values-uk/strings_import.xml deleted file mode 100644 index dd0928bd..00000000 --- a/app/src/main/res/values-uk/strings_import.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - Імпорт з інших застосунків - Імпорт нотаток - З Google Keep, Standard Notes, Joplin, Notesnook або файлів Markdown - Перенесіть нотатки з іншого застосунку. Оберіть його експорт, а Encly сам розпізнає формат. Усе залишається на цьому телефоні, нічого не завантажується. - Обрати файли - Обрати папку - Підтримувані застосунки - Як експортувати - Не імпортується - Google Keep - Архів Google Takeout (.zip) як є, без розпакування, або окремі нотатки .json - У Google Takeout оберіть лише Keep, створіть експорт і завантажте .zip. Потім оберіть цей .zip тут. - Закріплення й архівація, кольори та вкладення. Тегом стає лише перша мітка. - Standard Notes - Розшифрована резервна копія (.txt або .json) або .zip розшифрованого завантаження - У Standard Notes завантажте розшифровану резервну копію, а не зашифровану. Потім оберіть файл або .zip тут. - Зашифровані копії відхиляються. Закріплення й архівація, файли та інші типи елементів. Зберігається лише перший тег. - Joplin - Архів JEX (.jex), папка RAW або експорт Markdown - У Joplin оберіть File → Export all і формат JEX або RAW. Потім оберіть файл або папку тут. - Вкладення та статус завдання (завдання стають звичайними нотатками). Нотатка отримує свій перший тег, а якщо його немає, то назву блокнота. - Notesnook - Експорт Markdown, Markdown з front matter або TXT (.zip) - У Notesnook експортуйте нотатки як Markdown з front matter, щоб зберегти дати й теги. Потім оберіть .zip тут. Зашифровані резервні копії імпортувати не можна. - Вкладення, закріплення й архівація, кольори. Тегом стає перший тег, а якщо його немає, то блокнот. - Markdown і текст - Файли .md і .txt або ціла папка, наприклад з Obsidian чи Markor - Оберіть файли або папку з ними. Назва файлу стає заголовком; простий front matter (заголовок, теги, дати) читається. - Зображення, вкладення, плагіни та форматування, крім заголовків, списків, чеклістів і цитат. Тегом стає перша папка. - Читаємо й розпізнаємо ваші файли… - Готово до імпорту - Розпізнано: %1$s · %2$s - Позначені нотатки буде додано до цього сховища. Нічого не замінюється, а те, що вже є, пропускається. - Нотаток зі списками завдань: %1$d - Тегів: %1$d - Нотаток, що підуть у кошик: %1$d - Уже є в цьому сховищі, пропущено: %1$d - Повторюється у вашому виборі, пропущено: %1$d - Куди: це сховище - Вкладень, що залишаться: %1$d - Закріплених нотаток, які більше не будуть закріплені: %1$d - Архівних нотаток, що стануть звичайними: %1$d - Кольорів нотаток не збережено: %1$d - Зайвих тегів відкинуто (нотатка має лише один тег): %1$d - Нотаток-завдань, імпортованих як звичайні нотатки: %1$d - Зашифрованих або непідтримуваних елементів пропущено: %1$d - Файлів, які не вдалося використати: %1$d - Архівів в архівах, не відкрито: %1$d - Імпортуємо… - Імпортовано - Додано: %1$d, пропущено як дублікати: %2$d, не імпортовано: %3$d. - Ваші початкові файли не змінено. Видаліть їх самі, коли перевірите нотатки тут. - З якого це застосунку? - Файли схожі на експорти з кількох застосунків. Оберіть той, який імпортувати; решту буде пропущено. - Нічого не імпортовано - Ця резервна копія Standard Notes зашифрована. Завантажте в Standard Notes розшифровану копію й оберіть її. - Це зашифрована резервна копія Notesnook. Експортуйте нотатки в Notesnook як Markdown і оберіть .zip. - Ці файли не є підтримуваним експортом. Перегляньте підтримувані застосунки або оберіть файли Markdown чи текстові. - Цей вибір більший, ніж Encly може імпортувати за раз (64 МБ нотаток, 2 ГБ загалом або 20 000 нотаток). Імпортуйте його частинами. - У цьому виборі не знайдено нотаток. - Не вдалося прочитати вибрані файли. - На цьому телефоні немає застосунку для вибору файлів, який може використати Encly. - Сховище закрилося під час імпорту. Розблокуйте Encly і спробуйте ще раз. - Щось пішло не так, нічого не імпортовано. Ваше сховище не змінилося. - Вибрано %1$d з %2$d - Вибрати всі - Зняти вибір - Усі - Лише нові - У кошику - Без назви - Чекліст - У кошику - Уже в сховищі - Імпортувати: %1$d - Обрати ще раз - diff --git a/app/src/main/res/values-uk/strings_security.xml b/app/src/main/res/values-uk/strings_security.xml deleted file mode 100644 index 01f893d4..00000000 --- a/app/src/main/res/values-uk/strings_security.xml +++ /dev/null @@ -1,132 +0,0 @@ - - - Введіть або вставте свої 12 слів - Слово %1$d з %2$d - Слово %1$d не зі списку слів фрази відновлення. Перевірте написання. - Ці 12 слів не утворюють правильної фрази відновлення. Перевірте кожне слово та їхній порядок. - У цього сховища вже є фраза відновлення - Додати до нотаток чи замінити їх? - У цій копії: %1$s, %2$s і %3$s. - Усе, що тут є, залишиться, а відсутнє додасться. Те, що вже є, буде пропущено. - Замінити все - Видалити всі нотатки, завдання й теги на цьому телефоні, а потім відновити копію. - Збережіть усі нотатки, завдання й теги в одному зашифрованому файлі та відновіть їх на будь-якому телефоні. - Експорт - Імпорт - Ваші нотатки зашифровано. Їх відкриває ваш PIN (і відбиток пальця, якщо ввімкнено). - Ваші нотатки зашифровано. Їх відкриває ваш PIN (і відбиток пальця, якщо ввімкнено), а якщо ви забудете PIN, то фраза відновлення. - Створена під час налаштування. Encly не зберігає слова й не може показати їх знову. Немає їх? Замініть фразу нижче. - Налаштовано - Після скидання можна відновити резервну копію з файлу за її 12 словами. - Змінити PIN - - %d нотатка - %d нотатки - %d нотаток - %d нотатки - - - %d завдання - %d завдання - %d завдань - %d завдання - - - %d тег - %d теги - %d тегів - %d тега - - - Ваш PIN більше не може розблокувати Encly на цьому телефоні: його апаратний ключ скинуто. Скористайтеся фразою відновлення (або відбитком пальця), а потім задайте новий PIN. - - Забагато спроб. Спробуйте через %d хвилину - Забагато спроб. Спробуйте через %d хвилини - Забагато спроб. Спробуйте через %d хвилин - Забагато спроб. Спробуйте через %d хвилини - - - Забагато спроб. Спробуйте через %d годину - Забагато спроб. Спробуйте через %d години - Забагато спроб. Спробуйте через %d годин - Забагато спроб. Спробуйте через %d години - - Замінити фразу відновлення - Отримайте 12 нових слів. Для резервних копій, зроблених раніше, і далі потрібні старі. - Замінити фразу відновлення? - Ви отримаєте 12 нових слів. Стара фраза більше не розблоковуватиме ваші нотатки на цьому телефоні. Уже зроблені резервні копії відкриваються лише старою фразою, тож зберігайте її, доки зберігаєте ці копії, або згодом зробіть нову копію. - Отримати нові слова - Стара фраза тут більше не працює - Після підтвердження лише ці слова розблоковуватимуть ваші нотатки, якщо ви забудете PIN. Для резервних копій, зроблених раніше, і далі потрібні старі слова. - Фразу відновлення замінено. Зробіть нову резервну копію, щоб використовувати її і для копій. - Приватність - Сувора приватність клавіатури - Просить клавіатуру не показувати підказки, не використовувати хмарне передбачення й не читати текст біля курсора. Вимикає автовиправлення, а деякі клавіатури ховають емодзі чи голосовий ввід. - Блокувати після виходу - Скільки Encly лишається відкритим, коли ви переходите в інший застосунок. Вимкнення екрана чи блокування телефона завжди блокує його одразу. - Одразу - Через 15 секунд - Через 30 секунд - Через 1 хвилину - Через 2 хвилини - Через 15 хвилин - Через 30 хвилин - Довший час залишає сховище відкритим у фоні. Людина з доступом до розблокованого телефона може побачити ваші нотатки. - Як Encly захищає нотатки - Випадковий 256-бітний ключ - Уся база зашифрована ключем, створеним на цьому телефоні. - PIN прив’язаний до телефона - PIN змішується з ключем у захищеному чипі телефона, тож його не підібрати на іншому пристрої. - Окремий замок для кожного входу - PIN, фраза відновлення й відбиток відкривають кожен свій зашифрований слот. Відбиток потрібен щоразу. - Небезпечна зона - Утримуйте 5 секунд, щоб стерти - - PIN для стирання - Другий PIN, що стирає нотатки, якщо ввести його на екрані блокування - Вимкнено після скидання ключа PIN. Задайте його знову, якщо користувалися ним. - Якщо вас змушують відкрити Encly, введіть PIN для стирання замість свого PIN. Encly непомітно зітре нотатки, завдання й мітки та відкриється як порожнє сховище. Працює лише на екрані блокування. - Після стирання Encly відкривається PIN для стирання: він стає PIN нового, порожнього сховища. Ваш старий PIN більше не працює. Тож той, хто бачив, як ви вводили PIN для стирання, зможе знову відкрити ним Encly й не помітить нічого дивного. - Уже експортовані файли резервних копій не зачіпаються. Вони й далі відкриваються фразою відновлення і є єдиним шляхом назад. - Розблокування біометрією лишається ввімкненим: вас можуть змусити прикласти палець. Вимкніть його, якщо це для вас ризик. - Вимкнути розблокування біометрією - Це захищає, коли вас змушують розблокувати телефон, але не від криміналістичної експертизи. Під час блокування після невдалих спроб PIN для стирання теж не діє. Encly ніколи не показує, чи його задано: новий замінює старий, а якщо змінити свій PIN на PIN для стирання, його буде вимкнено. - Задати PIN для стирання - Вимкнути PIN для стирання - Підтвердьте свій PIN, щоб налаштувати PIN для стирання - Створіть PIN для стирання - 6 цифр, відмінні від вашого PIN - Підтвердьте PIN для стирання - Введіть PIN для стирання ще раз - PIN для стирання має відрізнятися від вашого PIN - Не вдалося зберегти PIN для стирання. Спробуйте ще раз. - 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/manifest_strings.xml b/app/src/main/res/values/manifest_strings.xml new file mode 100644 index 00000000..2e722ea5 --- /dev/null +++ b/app/src/main/res/values/manifest_strings.xml @@ -0,0 +1,6 @@ + + + + Encly + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml deleted file mode 100644 index 86e64d38..00000000 --- a/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,673 +0,0 @@ - - - - - Encly - Encly - %1$d sp - English - Українська - Deutsch - Français - Español - Italiano - Polski - Português - Nederlands - - About - A local encrypted space for notes, ideas and tasks, with no cloud sync. - Data protection - Vault data is stored locally in an encrypted SQLCipher database - Frequently asked questions - Feedback - Report a bug or suggest an improvement on GitHub - Why it matters and how to chip in on Ko-fi - No browser found. Open the link manually: %1$s - No email app found - Privacy policy - How we store and protect your data - Rate the app - Leave a rating on the Google Play Store - Support and contact - Contact the developer - If you have questions or suggestions, you can email me. - - Continue - - Add - - All - - Required access key. Tap to change your PIN. - 6-digit PIN - Authorization - - Back - - The biometric protection change was not confirmed - Couldn\'t create the biometric key - Confirm your identity - Authentication - Confirm your biometrics - Key access - Confirm your biometrics to change protection - Security confirmation - Confirm your biometrics to access the encrypted key - Unlock Encly - Use PIN - Biometric authentication is unavailable - - More block tools - Bulleted list - Checklist - Heading - Heading %1$d - Link - List - Lock checklist - Unlock checklist - Move block down - Move block up - Numbered list - Separator - - Cancel - - - Do you really want to delete these notes permanently? This can\'t be undone. - Do you really want to empty the trash permanently? This can\'t be undone. - Empty the trash? - - Clear - - Close - - Collapse - - Create a new tag - - Today %1$s - Yesterday %1$s - - Delete - Delete block - Delete - Delete tag - This tag will be removed from all your notes. The notes themselves won\'t be deleted. - - Delete - Cancel - Do you really want to delete this note permanently? This can\'t be undone. - Delete note? - - Encly is free and open source, with no ads, no tracking and no accounts. Donations are its only income. - Support development - - Done - - Duplicate - - Dynamic color - - Edit - - Lock editing - - No notes yet - Tap New note to write your first one. - Deleted notes wait here until you restore them or delete them for good. - - Enter a link - - Couldn\'t open the database - - Expand - - Couldn\'t load the content - - Encly saves your changes and, after the delay chosen in Settings → Security (15 seconds by default), closes the encrypted database and wipes the session key. Turning off the screen or locking the phone does this at once. After that you unlock again. - What happens when I leave the app? - Use the biometrics switch in Settings → Security. Creating or removing the biometric slot requires strong biometric authentication. - How do I turn biometrics on or off? - Open Settings → Security, confirm your current PIN, then enter the new one twice. - How do I change my PIN? - Encly creates a random 256-bit database key. Your PIN protects a separate AES-GCM unlock slot, and biometrics use an auth-per-use Android Keystore key. - How is the database key protected? - On the lock screen, tap \"Recover access\", enter your 12 words and set a new PIN. Without a recovery phrase there is no other way in: nobody, not even the developer, can reset it. - What if I forget my PIN? - Open Settings → Language and pick one of the available languages, or \"System default\" to follow your device. The change applies right away. - How do I change the language? - Tap \"New note\" on the home screen. The toolbar above the keyboard adds headings, checklists, bulleted and numbered lists, quotes, links and separators. - How do I create a new note? - It is a spare key of 12 words. It unlocks the vault if you forget your PIN, and it is the only key to your backup files. You create it during setup, or later in Settings → Security if you skipped it. Keep it on paper, offline. - What is the recovery phrase for? - Open \"About\" and use the feedback option, or write to the developer. Never include your recovery phrase, PIN or private note text in a bug report. - How do I report a bug? - Automatically, as you type, and leaving the editor finishes the last save. If a save ever fails, the editor shows \"Not saved\" in red. - How are changes saved? - Notes, tasks, local encryption and access recovery, with no cloud sync. - Answers to the most common questions - No. Screen protection is always on: Encly blocks screenshots, screen recording and unprotected previews in the recent apps list. - Can I take screenshots of Encly? - Basics - Security and recovery - Settings - Troubleshooting - Notes, tags and tasks are stored only in Encly\'s local SQLCipher database. There is no cloud sync and no automatic backup: you can export an encrypted backup yourself in Settings → Backup, or a readable Markdown copy, which is not encrypted. - Where is my data stored? - Tags group your notes. You can create, rename, hide and delete them, drag them into any order, and filter the home screen by tag. - How do tags work? - Tasks have a description and a priority. You can filter them by priority and mark them as done. Encly never puts task text into system notifications. - What can tasks do? - In Settings → Appearance choose one of five themes, light, dark or system mode, one of three font styles and the text size. On Android 12 and later you can also take the accent color from your wallpaper. - How do I change the theme? - Encly deliberately never creates an empty database over a lost or unreadable vault. Don\'t confirm the full wipe if you still need your local data. - Encly says the vault is damaged - - Text size - Line spacing - Default - Compact - Comfortable - Editorial - Modern - Technical - - - Choose language - System default - - Add link - - List item - - - Back to PIN - Recover access - Enter your 12-word recovery phrase - Recovery - Enter your 6-digit PIN to continue - Unlock your notes - Unlocking… - Recover access with your recovery phrase - Wrong PIN - Wrong recovery phrase - - Hold to wipe everything - It looks like the encrypted data or the encryption keys were changed or damaged, so recovery is not possible.\n\nTo keep using the app, you need to reset it and wipe the damaged data. This permanently deletes all saved notes and settings. - Sorry, your data is damaged - This data is from an older version - Encly found notes saved by a version before 2.0. Encly 2.0 uses a new encrypted format and cannot open them, and there is no way to convert them.\n\nTo use the app, start fresh. This permanently deletes the old notes and settings on this phone. - Hold to start fresh - - FAQ - Settings - Support the developer - Tags - Tasks - Trash - - More options - - - No tag - - New note - Couldn\'t delete the note - Description… - Discard all changes - Couldn\'t discard changes - Couldn\'t create a copy - Couldn\'t read the note\'s content. Editing is turned off so the data isn\'t damaged. - - %1$s (Copy) - Couldn\'t restore the note - Couldn\'t save changes - Couldn\'t move the note to the trash - - Couldn\'t initialize the app - Couldn\'t create the recovery phrase - Couldn\'t create the encrypted vault - - Open menu - - %1$d%% - - Enter the PIN again to confirm - Confirm your 6-digit PIN - Confirm your current PIN before changing your access key - Enter your current PIN - Enter a new PIN to protect your notes - PIN changed - Create a 6-digit PIN - Current PIN is incorrect - Delete digit - PINs don\'t match. Please try again. - Couldn\'t save the PIN - Couldn\'t update the PIN. Please try again. - - - Tap to start editing… - - High - Low - Medium - Choose a priority - - Quote - Redo - - Restore - - Save - - - - - Clear search - - Fingerprint - Biometric unlock - Not available on this device - No fingerprint enrolled. Add one in system settings. - Encryption is on - Not set up. Without it, a forgotten PIN means losing everything. Tap to create one. - Erase all data - Delete every note, task and tag and the encryption keys on this device - Every note, task and tag and the encryption keys will be deleted from this device, and Encly will start over. This can\'t be undone: export a backup first if you want to keep anything. - Encryption and data protection - Security - - - Appearance - General - Language - Privacy - Show tasks - Show the tasks card on the home screen - Simple editing - Turn off dynamic blocks and edit notes as plain text - - Sort by: - By creation date (oldest first) - Created ↑ - By creation date (newest first) - Created ↓ - By last edited (oldest first) - Edited ↑ - By last edited (newest first) - Edited ↓ - Support the developer - - Couldn\'t save the tag. Please try again. - Couldn\'t delete the tag. Please try again. - Edit tag - Hidden tag - A tag with this name already exists - Couldn\'t save the tag order. Please try again. - Couldn\'t update the tag. Please try again. - - Tap again to delete - - New task - Couldn\'t clear completed tasks. Please try again. - Do you really want to delete all completed tasks? This can\'t be undone. - Clear completed tasks - Completed: %1$s - Couldn\'t save the task. Please try again. - Description - Edit task - No active tasks. - No tasks match the selected filters. - All tasks - Completed - New task - Progress - Couldn\'t change the task status. Please try again. - Couldn\'t update the task. Please try again. - - Text - - Theme - Dark - Light - System - - - Undo - - Untitled - - Switch to grid view - Switch to list view - - - Delete %d note? - Delete %d notes? - - - Too many attempts. Try again in %d second - Too many attempts. Try again in %d seconds - - - %1$d of %2$d task completed - %1$d of %2$d tasks completed - - Backup - Encrypted backup, import and Markdown - Backup - Encrypted with your recovery phrase - A backup holds all your notes, tasks and tags, encrypted with your 12-word recovery phrase. Encly never uploads it: you choose where the file is saved. On a new phone, the same 12 words restore it. - Last export: %1$s - No backup exported yet - Export backup - Import backup - Restore notes, tasks and tags from a backup file - Backup saved - Confirm it\'s you - Enter your PIN to continue - Recovery phrase - Backups are encrypted with a recovery phrase, so only its 12 words can open them. This vault has none yet. Create one now: it also lets you unlock this vault if you forget your PIN. - Create recovery phrase - Write these 12 words down on paper, in order, and keep them offline. Anyone who has them can open your backups. - I wrote them down - These words don\'t match. Check what you wrote down. - Recovery phrase saved - Confirm your recovery phrase - Enter this vault\'s 12-word recovery phrase once, so Encly can encrypt backups with it. - Enter recovery phrase - Enter the 12 words of the recovery phrase this backup was made with. - Decrypt - Import backup - Merge - Replace - Replace all data? - Every note, task and tag in this vault will be deleted and replaced with the backup. This can\'t be undone. - Imported: notes %1$d, tasks %2$d, tags %3$d. Skipped (already here): %4$d. - This file is not an Encly backup - This backup was made by a newer version of Encly. Update the app and try again. - The backup file is damaged or incomplete - The file is too large to be an Encly backup - Your vault is too large for one backup file (64 MB). Nothing was saved. - That file already exists. Choose a new name: an older backup is never overwritten. - No app on this device can open or save files. - These words don\'t open this backup, or the file was modified - Enter a valid 12-word recovery phrase - The vault was locked. Unlock it and try again. - Couldn\'t read or write the backup. Nothing was changed. - These words are not this vault\'s recovery phrase - Couldn\'t save the recovery phrase - Restore from a backup - Choose a backup file - The backup couldn\'t be restored, so setup isn\'t finished yet. Try again, or continue with an empty vault and import the file later in Settings → Backup. - Backup not restored - Try again - Continue without it - This note can\'t be read by this version of Encly. - Open link - Copy link - Edit link - Link copied - No app on this device can open this link. - Only web (http, https) and email links can be saved or opened. - Couldn\'t read your notes - Couldn\'t read your tags - Your notes are still stored encrypted on this device. Close Encly and open it again to retry. - You unlocked Encly with your recovery phrase. Set a new PIN to continue. - Delete task - Task deleted - Couldn\'t delete the task. Please try again. - Mode - Font - Paper - Forest - Ocean - Graphite - Midnight - Follows the system setting. Showing light. - Follows the system setting. Showing dark. - Always light. - Always dark. - Midnight is always dark. - Take the accent from your wallpaper. Android 12 and later. - Dynamic color is on: the accent comes from your wallpaper. - Playfair Display and Source Sans 3 - Poppins and Source Sans 3 - IBM Plex Sans throughout - Flat viewing checklist - Before the visit: check the water pressure, ask what heating cost last winter. - Personal - Preview - Theme, mode and font - Correct - Digits entered: %1$d of %2$d - Unlock with fingerprint - Step %1$d of %2$d - Aa - A notebook only you can open. - Notes and tasks, encrypted on this phone. Setup takes about a minute. - No account - No sign-up, no email, no password reset. - Works offline - Encly never connects to the internet. - Encrypted on the device - Your vault is locked with a key only you hold. - Get started - I have a backup - Choose a 6-digit PIN - It unlocks Encly and protects the key to your vault. There is no email to reset it. - Enter the PIN again - So we know it\'s the one you meant. - The PIN still works as a fallback - Twelve words bring everything back - Encly will give you a recovery phrase. Write it on paper; it is the only spare key. - Forgot your PIN? - The phrase unlocks your vault and lets you set a new PIN. - Opens your backups - Encrypted backup files can only be read with these words. - New phone - Backup file plus phrase restores every note, task and tag. - Nobody can recover it for you, not even us. Lose both the PIN and the phrase and the notes are gone. - Create my phrase - Skip: PIN only, no backups - Write these words down - On paper, in this order. Not in a screenshot, not in the cloud. - Hide words - Show words - I wrote them down - Check three words - Type them from your paper copy, not from memory. - Word #%1$d - Enter the word - That is not word #%1$d - Confirm - Show the words again - Your vault is ready - Everything you write from now on is encrypted before it is saved. - PIN set - Asked every time Encly opens - Fingerprint unlock on - Change it in Settings, Security - Recovery phrase checked - Keep the paper somewhere safe - No recovery phrase - Backups are off. You can create a phrase later in Settings, Security. - Backup restored - Your notes, tasks and tags are back - Tip: export an encrypted backup now and then from Settings, Backup. Your phrase is what opens it. - Open my notebook - Restore, then set a PIN - Choose your Encly backup file, then enter the 12 words it was made with. - 1 · Backup file - Tap to choose another file - 2 · Recovery phrase - These words also become this phone\'s recovery phrase. - Notes - Lock now - Search titles and text - View options - Nothing matches “%1$s” - Today - Yesterday - - Tasks · %d open - Tasks · %d open - - Tasks · none open - No tasks yet - Tap New task to add your first one. - All done - Done · %1$d - No notes tagged “%1$s” - Notes you give this tag show up here. - Show all notes - Trash is empty - Tap a note to read it. Long-press to select. - Delete forever - Empty trash - Search notes - Notes and tasks - Version, privacy policy and contact - Answers to common questions - Open-source licenses - The libraries and fonts Encly uses - Built on open source - Encly is made with these libraries and fonts. Tap one to read its license. - Libraries - Fonts - No tags yet - Tags group your notes. Create one above, then pick it for a note from its long-press menu. - Tap a tag to rename, hide or delete it. Drag the handle to reorder. - Add tag - Name - Show in the notes list - A hidden tag keeps its notes but gets no filter chip - Drag to reorder - Move up - Move down - - %d selected - %d selected - - Edit tags - Title - Not saved - Manage tags - Discard changes? - Everything you changed since you opened this note will be lost. This can\'t be undone. - Everything is already yours - Every feature is in the app for everyone. Nothing is locked behind a payment. - No ads, no tracking - Encly never sees your notes and earns nothing from data. Donations are the only thing that pays for it. - Where the money goes - Test devices (next up: a Mac mini for building and testing) and time for releases and fixes. - Card or PayPal, one-off or monthly - Opens ko-fi.com in your browser. Encly itself never handles payments. - What does a long press on a note do? - It opens the note\'s actions: change its tag, add a short description shown on the card, duplicate the note or move it to the trash. - How do I get a deleted note back? - Deleted notes go to Trash in the menu. There you can restore a note or delete it forever. Encly never empties the trash on its own. - What does \"Lock editing\" do? - It makes a note read-only so it can\'t be changed by accident. You can still select and copy the text. Turn it on or off from the editor\'s menu. - How do backups work? - In Settings → Backup you export one encrypted file that only your recovery phrase can open. To restore, pick the file, type or paste the 12 words, then merge it into this vault or replace everything. - Show keyboard - Hide keyboard - Erase all data? - Couldn\'t erase the data. Nothing was changed. - Erase - Sub-tasks - Sub-task - Add sub-task - Delete sub-task - All sub-tasks are done - Complete task - Show sub-tasks - Hide sub-tasks - Expanded - Collapsed - Sub-task deleted - Next step: %1$s - First step - - %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 - Pin - Unpin - Archive - Unarchive - Pinned - Others - Archive - Archive is empty - Archived notes leave your list and search but stay in the vault. Archive a note from its menu. - Couldn\'t move the note to the archive - Couldn\'t take the note out of the archive - Color - No color - Red - Orange - Yellow - Green - Teal - Blue - Purple - Pink - Export as Markdown - Copy as Markdown - Copied as Markdown - Readable export - Save all notes and tasks as readable .md files in a .zip. The file is not encrypted. - Export without encryption? - The exported file is not encrypted. Anyone who gets it can read your notes. Wherever you save it, including a cloud folder, will hold a readable copy that Encly can\'t protect or take back. You\'ll confirm with your PIN next. - Markdown export saved - Can I export my notes as Markdown? - Yes. In a note\'s menu choose \"Export as Markdown\" for one .md file, or use Settings → Backup → \"Export as Markdown\" for a .zip of all notes (archived ones in an archive folder, the trash left out) and a tasks file. These files are not encrypted: Encly warns you first, asks for your PIN and saves only where you choose. - Swipe actions - Swipe on notes - Swipe a note card to act on it - Swipe right - Swipe left - Pin or unpin - Next color - Move to trash - Nothing - Move to trash? - You can restore the note from the trash. - What do swipes on a note do? - By default, swiping a note right pins or unpins it and swiping left archives it. In Settings → Swipe actions you can choose pin, archive, next color, move to trash (always asks first) or nothing for each direction, or turn swipes off. TalkBack offers the same actions in the note\'s actions menu. - Text in notes - The default for every note. A note can set its own from its menu. - Use default text settings - Source code - Encly is open source: read the code on GitHub - diff --git a/app/src/main/res/values/strings_import.xml b/app/src/main/res/values/strings_import.xml deleted file mode 100644 index 991b207b..00000000 --- a/app/src/main/res/values/strings_import.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - Import from other apps - Import notes - From Google Keep, Standard Notes, Joplin, Notesnook or Markdown files - Bring in notes from another app. Choose its export and Encly recognises the format itself. Everything stays on this phone; nothing is uploaded. - Choose files - Choose folder - Supported apps - How to export - Not imported - - Google Keep - Google Takeout .zip (choose it as it is, no need to unzip) or loose .json notes - In Google Takeout select only Keep, create the export and download the .zip. Then choose that .zip here. - Pinned and archived state, colours and attachments. Only the first label becomes the tag. - Standard Notes - Decrypted backup (.txt or .json), or the .zip of a decrypted download - In Standard Notes download a decrypted backup, not an encrypted one. Then choose the file or the .zip here. - Encrypted backups are refused. Pinned and archived state, files and other item types. Only the first tag is kept. - Joplin - JEX archive (.jex), RAW folder or Markdown export - In Joplin choose File → Export all and pick JEX or RAW. Then choose the file or folder here. - Attachments, and to-do status (to-dos arrive as ordinary notes). A note gets its first tag, otherwise its notebook name. - Notesnook - Markdown, Markdown with front matter or TXT export (.zip) - In Notesnook export your notes as Markdown with front matter to keep dates and tags. Then choose the .zip here. Encrypted backups cannot be imported. - Attachments, pinned and archived state, colours. The first tag, otherwise the notebook, becomes the tag. - Markdown and text - .md and .txt files or a whole folder, for example from Obsidian or Markor - Choose the files, or the folder that holds them. The file name is the title; simple front matter (title, tags, dates) is read. - Images, attachments, plugins and formatting beyond headings, lists, checklists and quotes. The first folder becomes the tag. - - Reading and recognising your files… - Ready to import - Detected: %1$s · %2$s - The ticked notes are added to this vault. Nothing is replaced, and anything already here is skipped. - Notes with checklists: %1$d - Tags: %1$d - Notes that go to the trash: %1$d - Already in this vault, skipped: %1$d - Repeated in your selection, skipped: %1$d - Destination: this vault - Attachments left behind: %1$d - Pinned notes that will not stay pinned: %1$d - Archived notes that arrive as normal notes: %1$d - Note colours not kept: %1$d - Extra tags dropped (a note has one tag): %1$d - To-do notes imported as ordinary notes: %1$d - Encrypted or unsupported entries skipped: %1$d - Files that could not be used: %1$d - Archives inside archives, not opened: %1$d - Importing… - Imported - %1$d added, %2$d skipped as duplicates, %3$d not imported. - Your original files were not changed. Delete them yourself once you have checked your notes here. - Which app are these from? - The files look like they come from more than one app. Choose the one to import; the rest is skipped. - Nothing was imported - This Standard Notes backup is encrypted. Download a decrypted backup in Standard Notes and choose that instead. - This is an encrypted Notesnook backup. In Notesnook export your notes as Markdown instead and choose the .zip. - These files are not a supported export. Check the supported apps, or choose Markdown or text files. - This selection is larger than Encly can import at once (64 MB of notes, 2 GB in all, or 20,000 notes). Import it in smaller parts. - No notes were found in this selection. - The selected files could not be read. - This phone has no file picker app that Encly can use. - The vault closed during the import. Unlock Encly and try again. - Something went wrong and nothing was imported. Your vault is unchanged. - %1$d of %2$d selected - Select all - Select none - All - New only - In trash - Untitled - Checklist - In trash - Already in vault - Import %1$d - Choose again - diff --git a/app/src/main/res/values/strings_security.xml b/app/src/main/res/values/strings_security.xml deleted file mode 100644 index 1da0ef2f..00000000 --- a/app/src/main/res/values/strings_security.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - Type or paste your 12 words - Word %1$d of %2$d - Word %1$d is not a recovery phrase word. Check the spelling. - These 12 words don\'t form a valid recovery phrase. Check each word and their order. - This vault already has a recovery phrase - Add to your notes or replace them? - This backup holds %1$s, %2$s and %3$s. - Keep everything here and add what is missing. Items already here are skipped. - Replace everything - Delete every note, task and tag on this phone, then restore the backup. - Keep all your notes, tasks and tags in one encrypted file, and restore them on any phone. - Export - Import - Your notes are encrypted. Your PIN (and your fingerprint, if it is on) unlocks them. - Your notes are encrypted. Your PIN (and your fingerprint, if it is on) unlocks them, and your recovery phrase does if you forget the PIN. - Created during setup. Encly never stores the words and can\'t show them again. Don\'t have them? Replace the phrase below. - Set up - After the reset you can restore a backup file with its 12 words. - Change PIN - - %d note - %d notes - - - %d task - %d tasks - - - %d tag - %d tags - - - Your PIN can no longer unlock Encly on this phone: its hardware key was reset. Use your recovery phrase (or fingerprint), then set a new PIN. - - Too many attempts. Try again in %d minute - Too many attempts. Try again in %d minutes - - - Too many attempts. Try again in %d hour - Too many attempts. Try again in %d hours - - Replace recovery phrase - Get 12 new words. Backups made before keep needing the old ones. - Replace the recovery phrase? - You will get 12 new words. The old phrase stops unlocking your notes on this phone. Backups you already made still open only with the old phrase, so keep it as long as you keep them, or make a new backup afterwards. - Get new words - Your old phrase stops working here - Once you confirm, only these words unlock your notes if you forget the PIN. Backups made before still need the old words. - Recovery phrase replaced. Make a new backup to use it for backups too. - Privacy - Strict keyboard privacy - Asks the keyboard for no suggestions, no cloud prediction and no reading around the cursor. Turns off autocorrect, and some keyboards hide emoji or voice input. - Lock after leaving the app - How long Encly stays open when you switch to another app. Turning off the screen or locking the phone always locks it at once. - Immediately - After 15 seconds - 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. - A PIN bound to this phone - Your PIN is mixed with a key in the phone\'s secure chip, so it can\'t be guessed anywhere else. - Separate locks for each way in - PIN, recovery phrase and fingerprint each open their own encrypted slot. Fingerprint needs your finger every time. - Danger zone - Hold for 5 seconds to erase - - Wipe PIN - A second PIN that erases your notes when typed on the lock screen - Turned off when your PIN key was reset. Set it again if you used one. - If someone makes you open Encly, type the wipe PIN instead of your PIN. Encly erases your notes, tasks and tags without any sign and opens as an empty vault. It works only on the lock screen. - After an erase, Encly opens with the wipe PIN: it becomes the PIN of the new, empty vault. Your old PIN stops working, so whoever saw you type the wipe PIN can open Encly with it again and sees nothing unusual. - Backup files you already exported are not touched. They still open with your recovery phrase and are the only way back. - Biometric unlock stays on: someone can force your finger onto the sensor. Turn it off if that is a risk for you. - Turn off biometric unlock - It protects you when someone forces you to unlock, not against a forensic examination of the phone. During a lockout the wipe PIN can\'t be used either. Encly never shows whether a wipe PIN is set: a new one replaces the old one, and changing your PIN to the wipe PIN turns it off. - Set wipe PIN - Turn off wipe PIN - Confirm your PIN to set up the wipe PIN - Create a wipe PIN - 6 digits, different from your PIN - Confirm the wipe PIN - Enter the wipe PIN again - The wipe PIN must be different from your PIN - Couldn\'t save the wipe PIN. Please try again. - 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/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 239e7f69..c0c8020a 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -11,4 +11,5 @@ + diff --git a/app/src/test/java/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt b/app/src/test/java/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt deleted file mode 100644 index 20473954..00000000 --- a/app/src/test/java/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt +++ /dev/null @@ -1,282 +0,0 @@ -package com.pasich.encly.core.backup - -import org.junit.Assert.assertEquals -import org.junit.Test - -class BackupPayloadCodecTest { - - private val tag = BackupTag(uid = "t1", name = "Work", visible = true, position = 0) - private val payload = BackupPayload( - exportedAt = 1_700_000_000_000, - tags = listOf(tag), - notes = listOf( - BackupNote("n1", "Title", "[]", "desc", 2L, 1L, tagUid = "t1", isTrash = true), - ), - tasks = listOf( - BackupTask( - "k1", - "Task", - null, - false, - 3L, - null, - priority = 2, - categoryTagUid = "t1", - position = 5, - subtasks = emptyList(), - ), - ), - ) - private val subtasks = listOf( - BackupSubtask(uid = "s1", title = "Milk", isCompleted = true, position = 0), - BackupSubtask(uid = "s2", title = "Bread", isCompleted = false, position = 1), - ) - private val withSubtasks = payload.copy(tasks = payload.tasks.map { it.copy(subtasks = subtasks) }) - - @Test - fun encodeDecodeRoundTrips() { - assertEquals(payload, BackupPayloadCodec.decode(BackupPayloadCodec.encode(payload))) - } - - @Test - fun subtasksRoundTrip() { - assertEquals(withSubtasks, BackupPayloadCodec.decode(BackupPayloadCodec.encode(withSubtasks))) - } - - @Test - fun emptyVaultRoundTrips() { - val empty = BackupPayload(exportedAt = 0, tags = emptyList(), notes = emptyList(), tasks = emptyList()) - - assertEquals(empty, BackupPayloadCodec.decode(BackupPayloadCodec.encode(empty))) - } - - @Test - fun malformedJsonIsInvalid() { - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode("{not json".toByteArray()) } - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode("{}".toByteArray()) } - } - - @Test - fun newerSchemaIsUnsupportedNotCorrupted() { - val newer = BackupPayloadCodec.encode(payload.copy(schema = BackupPayload.SCHEMA_VERSION + 1)) - - assertError(BackupError.UNSUPPORTED_VERSION) { BackupPayloadCodec.decode(newer) } - } - - @Test - fun newerSchemaWithFieldsThisVersionDoesNotKnowIsUnsupported() { - // What a future app writes: a bumped schema plus a field this build has never heard of. - val newer = withExtraField(payload.copy(schema = BackupPayload.SCHEMA_VERSION + 1)) - - assertError(BackupError.UNSUPPORTED_VERSION) { BackupPayloadCodec.decode(newer) } - } - - @Test - fun unknownFieldInTheCurrentSchemaIsInvalid() { - // Every payload change must bump SCHEMA_VERSION, so this is a malformed file. - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(withExtraField(payload)) } - } - - @Test - fun olderUnknownSchemaIsInvalid() { - assertInvalid(payload.copy(schema = 0)) - } - - @Test - fun schema1BackupDecodesWithoutItsReminders() { - val decoded = BackupPayloadCodec.decode(schema1Json().toByteArray(Charsets.UTF_8)) - - assertEquals(payload, decoded) - assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) - } - - @Test - fun schema2BackupDecodesWithNoSubtasks() { - val decoded = BackupPayloadCodec.decode(schema2Json().toByteArray(Charsets.UTF_8)) - - assertEquals(payload, decoded) - assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) - } - - @Test - fun schema2BackupWithSubtasksIsInvalid() { - // Schema 2 never had sub-tasks: such a file is malformed, not a newer one. - val json = schema2Json().replace("\"position\":5}", "\"position\":5,\"subtasks\":[]}") - - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.toByteArray(Charsets.UTF_8)) } - } - - @Test - fun schema3BackupDecodesWithDefaultNoteOrganizing() { - val decoded = BackupPayloadCodec.decode(schema3Json().toByteArray(Charsets.UTF_8)) - - assertEquals(withSubtasks, decoded) - assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) - with(decoded.notes.single()) { - assertEquals(false, isPinned) - assertEquals(false, isArchived) - assertEquals("none", color) - } - } - - @Test - fun schema3BackupWithNoteOrganizingKeysIsInvalid() { - // Schema 3 never had these keys: such a file is malformed, not a newer one. - val json = schema3Json().replace("\"isTrash\":true}", "\"isTrash\":true,\"isPinned\":true}") - - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.toByteArray(Charsets.UTF_8)) } - } - - @Test - fun noteOrganizingRoundTrips() { - val organized = payload.copy( - notes = payload.notes.map { it.copy(isPinned = true, isArchived = true, color = "teal") }, - ) - - assertEquals(organized, BackupPayloadCodec.decode(BackupPayloadCodec.encode(organized))) - } - - @Test - fun aNotesOwnTextRoundTripsAndOnlyKnownValuesAreValid() { - val own = payload.copy(notes = payload.notes.map { it.copy(textSize = 24, lineSpacing = 2) }) - assertEquals(own, BackupPayloadCodec.decode(BackupPayloadCodec.encode(own))) - - assertInvalid(payload.copy(notes = payload.notes.map { it.copy(textSize = 9) })) - assertInvalid(payload.copy(notes = payload.notes.map { it.copy(textSize = 33) })) - assertInvalid(payload.copy(notes = payload.notes.map { it.copy(lineSpacing = 3) })) - assertInvalid(payload.copy(notes = payload.notes.map { it.copy(lineSpacing = -1) })) - } - - @Test - fun schema3BackupWithATextSizeKeyIsInvalid() { - val json = schema3Json().replace("\"isTrash\":true}", "\"isTrash\":true,\"textSize\":20}") - - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.toByteArray(Charsets.UTF_8)) } - } - - @Test - fun blankOrOverlongColorIsInvalid() { - assertInvalid(payload.copy(notes = payload.notes.map { it.copy(color = "") })) - assertInvalid(payload.copy(notes = payload.notes.map { it.copy(color = "x".repeat(33)) })) - } - - @Test - fun currentSchemaRequiresTheSubtasksList() { - val json = String(BackupPayloadCodec.encode(payload), Charsets.UTF_8).replace(",\"subtasks\":[]", "") - - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.toByteArray(Charsets.UTF_8)) } - } - - @Test - fun schema1BackupStillRejectsOtherUnknownFields() { - val json = schema1Json().replaceFirst("{", "{\"pinnedNotes\":[\"n1\"],") - - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.toByteArray(Charsets.UTF_8)) } - } - - @Test - fun reminderInTheCurrentSchemaIsInvalid() { - val json = String(BackupPayloadCodec.encode(payload), Charsets.UTF_8) - .replace("\"completedDate\":null,", "\"completedDate\":null,\"reminderDate\":4,") - - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.toByteArray(Charsets.UTF_8)) } - } - - /** - * The key names of a backup file are its format: backups written by the release build must - * be readable by any other build, so none of them may follow a (renamable) property name. - */ - @Test - fun encodedKeyNamesArePinned() { - val expected = """{"schema":4,"exportedAt":1700000000000,""" + - """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + - """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + - """"dateCreate":1,"tagUid":"t1","isTrash":true,"isPinned":false,"isArchived":false,""" + - """"color":"none","textSize":null,"lineSpacing":null}],""" + - """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + - """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5,""" + - """"subtasks":[{"uid":"s1","title":"Milk","isCompleted":true,"position":0},""" + - """{"uid":"s2","title":"Bread","isCompleted":false,"position":1}]}]}""" - - assertEquals(expected, String(BackupPayloadCodec.encode(withSubtasks), Charsets.UTF_8)) - } - - /** [payload] as the schema-1 app wrote it: every task carried a `reminderDate`. */ - private fun schema1Json(): String = """{"schema":1,"exportedAt":1700000000000,""" + - """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + - """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + - """"dateCreate":1,"tagUid":"t1","isTrash":true}],""" + - """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + - """"createdDate":3,"completedDate":null,"reminderDate":4,"priority":2,""" + - """"categoryTagUid":"t1","position":5}]}""" - - /** [payload] as the schema-2 app wrote it: tasks had no `subtasks`. */ - private fun schema2Json(): String = """{"schema":2,"exportedAt":1700000000000,""" + - """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + - """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + - """"dateCreate":1,"tagUid":"t1","isTrash":true}],""" + - """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + - """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5}]}""" - - /** [withSubtasks] as the schema-3 app wrote it: notes had no pin, archive or colour. */ - private fun schema3Json(): String = """{"schema":3,"exportedAt":1700000000000,""" + - """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + - """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + - """"dateCreate":1,"tagUid":"t1","isTrash":true}],""" + - """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + - """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5,""" + - """"subtasks":[{"uid":"s1","title":"Milk","isCompleted":true,"position":0},""" + - """{"uid":"s2","title":"Bread","isCompleted":false,"position":1}]}]}""" - - private fun withExtraField(value: BackupPayload): ByteArray { - val json = String(BackupPayloadCodec.encode(value), Charsets.UTF_8) - return json.replaceFirst("{", "{\"pinnedNotes\":[\"n1\"],").toByteArray(Charsets.UTF_8) - } - - @Test - fun linksMustPointAtTagsInTheBackup() { - assertInvalid(payload.copy(notes = payload.notes.map { it.copy(tagUid = "missing") })) - assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(categoryTagUid = "missing") })) - } - - @Test - fun uidsMustBeUniqueAndPresent() { - assertInvalid(payload.copy(tags = listOf(tag, tag.copy(name = "Other")))) - assertInvalid(payload.copy(notes = payload.notes + payload.notes)) - assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(uid = " ") })) - assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(uid = "x".repeat(65)) })) - } - - @Test - fun subtaskUidsMustBeUniqueAcrossTheVaultAndPresent() { - val second = payload.tasks.single().copy(uid = "k2", subtasks = listOf(subtasks.first())) - assertInvalid(withSubtasks.copy(tasks = withSubtasks.tasks + second)) - assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(subtasks = subtasks + subtasks) })) - assertInvalid( - payload.copy( - tasks = payload.tasks.map { - it.copy(subtasks = listOf(subtasks[0].copy(uid = ""))) - }, - ), - ) - assertInvalid( - payload.copy( - tasks = payload.tasks.map { - it.copy(subtasks = listOf(subtasks[0].copy(uid = "x".repeat(65)))) - }, - ), - ) - } - - @Test - fun priorityMustBeKnown() { - assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(priority = 3) })) - } - - private fun assertInvalid(bad: BackupPayload) { - // encode() does not validate, so this is what a hand-crafted (authentic) file would hold. - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.validate(bad) } - val json = BackupPayloadCodec.encode(bad) - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json) } - } -} diff --git a/app/src/test/java/com/pasich/encly/core/common/UiTextAndStringsTest.kt b/app/src/test/java/com/pasich/encly/core/common/UiTextAndStringsTest.kt index 502d5774..bb023667 100644 --- a/app/src/test/java/com/pasich/encly/core/common/UiTextAndStringsTest.kt +++ b/app/src/test/java/com/pasich/encly/core/common/UiTextAndStringsTest.kt @@ -1,48 +1,95 @@ package com.pasich.encly.core.common -import android.app.Application -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import androidx.test.core.app.ApplicationProvider -import com.pasich.encly.R +import androidx.compose.material3.Text +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.locale.AppLocales +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.date_today_at +import com.pasich.encly.testutil.TestLocale +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.stringResource import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner +import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.annotation.Config +import java.util.Locale -/** Text kept as a resource id, or resolved for storing, follows the language picked in the app. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [32]) // Android 12 and lower: the platform alone ignores the in-app language -class UiTextAndStringsTest { - private val context: Application get() = ApplicationProvider.getApplicationContext() +/** + * The in-app language reaches every way a string is read: the composition, stored text + * ([AppStrings]) and text a ViewModel hands to the UI ([UiText]). The Compose resources read the + * process's default locale, which [AppLocales.apply] sets, so the same must hold on Android 12 and + * lower (where the platform alone ignores the in-app language) and on Android 13 and up. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [32, 35]) +class UiTextAndStringsTest(private val language: AppLanguage, private val expectedCancel: String) { + @get:Rule + val rule = createComposeRule() @After - fun resetLocales() { - AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + fun resetLanguage() { + AppLocales.apply(AppLanguage.SYSTEM) + TestLocale.set(Locale.US.toLanguageTag()) } @Test - fun aResourceTextResolvesToItsString() { - assertEquals("Cancel", UiText.of(R.string.cancel).asString(context)) - assertEquals(UiText.Resource(R.string.cancel), UiText.of(R.string.cancel)) + fun theLanguagePickedInTheAppIsTheOneStoredTextUses() { + AppLocales.apply(language) + + assertEquals(expectedCancel, runBlocking { ComposeAppStrings().get(Res.string.cancel) }) } @Test - fun withoutAnAppLanguageStoredTextUsesTheSystemOne() { - assertEquals("Cancel", AppStrings(context).get(R.string.cancel)) + fun theLanguagePickedInTheAppIsTheOneUiTextResolvesIn() { + AppLocales.apply(language) + + assertEquals(expectedCancel, runBlocking { UiText.of(Res.string.cancel).resolve() }) } @Test - fun storedTextFollowsTheLanguagePickedInTheApp() { - AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("uk")) + fun theLanguagePickedInTheAppIsTheOneTheCompositionShows() { + AppLocales.apply(language) + rule.setContent { Text(stringResource(Res.string.cancel)) } - assertEquals("Скасувати", AppStrings(context).get(R.string.cancel)) + rule.onNodeWithText(expectedCancel).assertExists() + } + + @Test + fun pickingTheSystemLanguageGoesBackToTheDevicesOne() { + AppLocales.apply(language) + AppLocales.apply(AppLanguage.SYSTEM) + + assertEquals("Cancel", runBlocking { ComposeAppStrings().get(Res.string.cancel) }) + assertEquals(AppLanguage.SYSTEM, AppLocales.current()) } @Test fun storedTextTakesFormatArguments() { - assertEquals("Today 10:15", AppStrings(context).get(R.string.date_today_at, "10:15")) + AppLocales.apply(AppLanguage.ENGLISH) + + assertEquals("Today 10:15", runBlocking { ComposeAppStrings().get(Res.string.date_today_at, "10:15") }) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun languages(): List> = listOf( + arrayOf(AppLanguage.ENGLISH, "Cancel"), + arrayOf(AppLanguage.UKRAINIAN, "Скасувати"), + arrayOf(AppLanguage.GERMAN, "Abbrechen"), + arrayOf(AppLanguage.FRENCH, "Annuler"), + arrayOf(AppLanguage.SPANISH, "Cancelar"), + arrayOf(AppLanguage.ITALIAN, "Annulla"), + arrayOf(AppLanguage.POLISH, "Anuluj"), + arrayOf(AppLanguage.PORTUGUESE, "Cancelar"), + arrayOf(AppLanguage.DUTCH, "Annuleren"), + arrayOf(AppLanguage.ARABIC, "إلغاء"), + ) } } diff --git a/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt b/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt new file mode 100644 index 00000000..634a0767 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt @@ -0,0 +1,277 @@ +package com.pasich.encly.core.di + +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.ImportFromMyNotesActivity +import com.pasich.encly.MainActivity +import com.pasich.encly.MyApplication +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.security.AuthenticationManager +import com.pasich.encly.core.security.AutoLock +import com.pasich.encly.core.security.BiometricManager +import com.pasich.encly.core.security.ScreenshotAllowance +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SeedPhraseManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.core.security.VaultStore +import com.pasich.encly.data.backup.BackupManager +import com.pasich.encly.data.database.DatabaseProvider +import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.domain.repository.NotesRepository +import com.pasich.encly.domain.repository.SettingsRepository +import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase +import com.pasich.encly.presentation.editor.persistence.NoteCopyTitle +import com.pasich.encly.presentation.viewmodel.AppearanceViewModel +import com.pasich.encly.presentation.viewmodel.ArchiveViewModel +import com.pasich.encly.presentation.viewmodel.AuthSetupViewModel +import com.pasich.encly.presentation.viewmodel.BackupViewModel +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.EditorToolbarViewModel +import com.pasich.encly.presentation.viewmodel.ImportFromMyNotesViewModel +import com.pasich.encly.presentation.viewmodel.ImportNotesViewModel +import com.pasich.encly.presentation.viewmodel.LockNowViewModel +import com.pasich.encly.presentation.viewmodel.LockViewModel +import com.pasich.encly.presentation.viewmodel.LossRecoveryViewModel +import com.pasich.encly.presentation.viewmodel.MainListStateViewModel +import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel +import com.pasich.encly.presentation.viewmodel.NoteListViewModel +import com.pasich.encly.presentation.viewmodel.NotePreviewsViewModel +import com.pasich.encly.presentation.viewmodel.NoteSearchViewModel +import com.pasich.encly.presentation.viewmodel.NoteTabsViewModel +import com.pasich.encly.presentation.viewmodel.OnboardingViewModel +import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel +import com.pasich.encly.presentation.viewmodel.SettingsViewModel +import com.pasich.encly.presentation.viewmodel.StartupViewModel +import com.pasich.encly.presentation.viewmodel.StatisticViewModel +import com.pasich.encly.presentation.viewmodel.StorageUnreadableViewModel +import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.presentation.viewmodel.TasksViewModel +import com.pasich.encly.presentation.viewmodel.ThemeViewModel +import com.pasich.encly.presentation.viewmodel.TrashViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.createGraphFactory +import dev.zacsweers.metrox.viewmodel.ViewModelGraph +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.reflect.KClass + +/** + * Everything contributed to [AppScope] (the binding containers and the ViewModels), exposed for + * the tests. It resolves the same bindings as [AppGraph]; that graph itself is checked below + * through the application that owns it. + */ +@DependencyGraph(AppScope::class) +interface ContributionsGraph : ViewModelGraph { + val securityManager: SecurityManager + val secureDatabaseManager: SecureDatabaseManager + val databaseProvider: DatabaseProvider + val sessionLockManager: SessionLockManager + val vaultLockEvents: VaultLockEvents + val vaultStore: VaultStore + val authenticationManager: AuthenticationManager + val seedPhraseManager: SeedPhraseManager + val biometricManager: BiometricManager + val autoLock: AutoLock + val screenshotAllowance: ScreenshotAllowance + val backupManager: BackupManager + val notesRepository: NotesRepository + val settingsRepository: SettingsRepository + val appFlags: KeyValueStore + + @ApplicationScope + val applicationScope: CoroutineScope + + @IoDispatcher + val ioDispatcher: CoroutineDispatcher + + val noteCopyTitle: NoteCopyTitle + val trashStatusUseCase: UpdateNoteTrashStatusUseCase + + @DependencyGraph.Factory + fun interface Factory { + fun create(@Provides @ApplicationContext context: Context): ContributionsGraph + } +} + +/** The dependency graph: it builds, its singletons are single, and every ViewModel can be made. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AppGraphTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val graph by lazy { createGraphFactory().create(context) } + private val stores = mutableListOf() + + @After + fun clearViewModels() { + stores.forEach { it.clear() } + } + + @Test + fun theSecuritySingletonsAreOneInstanceEach() { + assertSame(graph.securityManager, graph.securityManager) + assertSame(graph.secureDatabaseManager, graph.secureDatabaseManager) + assertSame(graph.sessionLockManager, graph.sessionLockManager) + assertSame(graph.vaultStore, graph.vaultStore) + assertSame(graph.authenticationManager, graph.authenticationManager) + assertSame(graph.seedPhraseManager, graph.seedPhraseManager) + assertSame(graph.biometricManager, graph.biometricManager) + assertSame(graph.autoLock, graph.autoLock) + assertSame(graph.screenshotAllowance, graph.screenshotAllowance) + assertSame(graph.backupManager, graph.backupManager) + assertSame(graph.appFlags, graph.appFlags) + assertSame(graph.applicationScope, graph.applicationScope) + } + + @Test + fun aBoundInterfaceIsTheSingletonBehindIt() { + assertSame(graph.secureDatabaseManager, graph.databaseProvider) + // The editor and the lock timers listen to this one: it must be the real lock manager. + assertSame(graph.sessionLockManager, graph.vaultLockEvents) + } + + @Test + fun theLockManagerEndsTheSameScreenshotAllowanceTheSettingsTurnOn() { + // Not a default the manager made for itself: this is the instance the UI and the window use. + graph.screenshotAllowance.allow() + + graph.sessionLockManager.requireUnlock() + + assertFalse(graph.screenshotAllowance.allowed.value) + } + + @Test + fun unscopedBindingsAreMadeAnewEachTime() { + assertNotSame(graph.trashStatusUseCase, graph.trashStatusUseCase) + assertNotSame(graph.noteCopyTitle, graph.noteCopyTitle) + } + + @Test + fun theViewModelsAreTheTwentySevenOfTheApp() { + assertEquals(PLAIN_VIEW_MODELS, graph.viewModelProviders.keys) + assertEquals(ASSISTED_VIEW_MODELS, graph.assistedFactoryProviders.keys) + assertEquals(27, PLAIN_VIEW_MODELS.size + ASSISTED_VIEW_MODELS.size) + assertTrue(graph.manualAssistedFactoryProviders.isEmpty()) + } + + @Test + fun everyViewModelCanBeCreatedThroughTheFactory() { + val extras = MutableCreationExtras(activityExtras()) + (PLAIN_VIEW_MODELS + ASSISTED_VIEW_MODELS).forEach { type -> + val store = ViewModelStore().also(stores::add) + val created = ViewModelProvider.create(store, graph.metroViewModelFactory, extras)[type] + assertNotNull(type.simpleName, created) + assertTrue(type.simpleName, type.isInstance(created)) + } + } + + @Test + fun aViewModelIsNewForEveryStore() { + val first = ViewModelStore().also(stores::add) + val second = ViewModelStore().also(stores::add) + val extras = activityExtras() + + val a = ViewModelProvider.create(first, graph.metroViewModelFactory, extras)[SettingsViewModel::class] + val b = ViewModelProvider.create(second, graph.metroViewModelFactory, extras)[SettingsViewModel::class] + + assertNotSame(a, b) + } + + @Test + fun anAssistedViewModelGetsTheNavigationArgumentsAsItsSavedState() { + val args = Bundle().apply { putBoolean("isReadTrashOnly", true) } + val extras = MutableCreationExtras(activityExtras()).apply { set(DEFAULT_ARGS_KEY, args) } + val store = ViewModelStore().also(stores::add) + + val editor = ViewModelProvider.create(store, graph.metroViewModelFactory, extras)[EditNoteViewModel::class] + + assertTrue(editor.isReadTrashOnly) + assertTrue(editor.isNewNote) + } + + @Test + fun theApplicationGraphGivesTheActivitiesAndTheApplicationTheSameSingletons() { + val appGraph = ApplicationProvider.getApplicationContext().appGraph + val main = MainActivity().also { appGraph.inject(it) } + val otherMain = MainActivity().also { appGraph.inject(it) } + val handoff = ImportFromMyNotesActivity().also { appGraph.inject(it) } + val otherHandoff = ImportFromMyNotesActivity().also { appGraph.inject(it) } + val application = MyApplication().also { appGraph.inject(it) } + + assertSame(main.securityManager, otherMain.securityManager) + assertSame(main.securityManager, handoff.securityManager) + assertSame(main.sessionLockManager, otherMain.sessionLockManager) + assertSame(main.sessionLockManager, application.sessionLockManager) + assertSame(main.settingsRepository, handoff.settingsRepository) + assertSame(main.keyboardPrivacy, handoff.keyboardPrivacy) + assertSame(handoff.viewModelFactory, otherHandoff.viewModelFactory) + } + + @Test + fun theApplicationGraphIsTheOneTheApplicationOwns() { + val application = ApplicationProvider.getApplicationContext() + + assertSame(application.appGraph, application.appGraph) + } + + /** What a real Activity hands a ViewModel factory: the saved-state and store owners. */ + private fun activityExtras() = Robolectric.buildActivity(ComponentActivity::class.java).create().get() + .defaultViewModelCreationExtras + + private companion object { + val PLAIN_VIEW_MODELS: Set> = setOf( + AppearanceViewModel::class, + ArchiveViewModel::class, + AuthSetupViewModel::class, + BackupViewModel::class, + ImportNotesViewModel::class, + LockNowViewModel::class, + LockViewModel::class, + LossRecoveryViewModel::class, + MainListStateViewModel::class, + MarkdownExportViewModel::class, + NoteListViewModel::class, + NotePreviewsViewModel::class, + EditorToolbarViewModel::class, + NoteSearchViewModel::class, + NoteTabsViewModel::class, + OnboardingViewModel::class, + SecuritySettingsViewModel::class, + SettingsViewModel::class, + StartupViewModel::class, + StatisticViewModel::class, + StorageUnreadableViewModel::class, + TagListViewModel::class, + ThemeViewModel::class, + TrashViewModel::class, + ) + + /** The ViewModels that take a SavedStateHandle. */ + val ASSISTED_VIEW_MODELS: Set> = setOf( + EditNoteViewModel::class, + ImportFromMyNotesViewModel::class, + TasksViewModel::class, + ) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/di/NavigationViewModelsTest.kt b/app/src/test/java/com/pasich/encly/core/di/NavigationViewModelsTest.kt new file mode 100644 index 00000000..20f9375b --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/di/NavigationViewModelsTest.kt @@ -0,0 +1,120 @@ +package com.pasich.encly.core.di + +import android.content.Context +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import dev.zacsweers.metro.createGraphFactory +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * ViewModels asked for inside a navigation destination, the way every screen does: the graph's + * factory builds them, and an assisted one gets the destination's arguments as its saved state. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class NavigationViewModelsTest { + @get:Rule + val rule = createComposeRule() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Test + fun aDestinationViewModelReadsItsNavigationArguments() { + val graph = createGraphFactory().create(context) + var editor: EditNoteViewModel? = null + lateinit var navigate: (String) -> Unit + + rule.setContent { + val nav = rememberNavController() + navigate = nav::navigate + CompositionLocalProvider(LocalMetroViewModelFactory provides graph.metroViewModelFactory) { + NavHost(navController = nav, startDestination = "start") { + composable("start") { Text("start") } + composable(EDIT_NOTE, arguments = EDIT_NOTE_ARGUMENTS) { + editor = metroViewModel() + Text("editor") + } + } + } + } + rule.runOnIdle { navigate("${NavRoutes.EditNoteRoute.name}/-1?isReadTrashOnly=true") } + rule.waitForIdle() + + val opened = checkNotNull(editor) + assertTrue(opened.isReadTrashOnly) + assertTrue(opened.isNewNote) + } + + @Test + fun twoDestinationsDoNotShareAViewModel() { + val graph = createGraphFactory().create(context) + val seen = mutableListOf() + lateinit var navigate: (String) -> Unit + + rule.setContent { + val nav = rememberNavController() + navigate = nav::navigate + CompositionLocalProvider(LocalMetroViewModelFactory provides graph.metroViewModelFactory) { + NavHost(navController = nav, startDestination = "start") { + composable("start") { Text("start") } + composable(EDIT_NOTE, arguments = EDIT_NOTE_ARGUMENTS) { + val editor = metroViewModel() + if (seen.none { it === editor }) seen += editor + Text("editor") + } + } + } + } + rule.runOnIdle { navigate("${NavRoutes.EditNoteRoute.name}/-1") } + rule.waitForIdle() + rule.runOnIdle { navigate("${NavRoutes.EditNoteRoute.name}/-1?isReadTrashOnly=true") } + rule.waitForIdle() + + assertEquals(2, seen.size) + assertNotSame(seen[0], seen[1]) + assertFalse(seen[0].isReadTrashOnly) + assertTrue(seen[1].isReadTrashOnly) + } +} + +/** The editor's route as AppNavHost registers it. */ +private val EDIT_NOTE = "${NavRoutes.EditNoteRoute.name}/{idNote}" + + "?isReadTrashOnly={isReadTrashOnly}©Source={copySource}&addTag={addTag}" + +private val EDIT_NOTE_ARGUMENTS = listOf( + navArgument("idNote") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("isReadTrashOnly") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("copySource") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("addTag") { + type = NavType.LongType + defaultValue = 0L + }, +) diff --git a/app/src/test/java/com/pasich/encly/core/locale/AppLanguageTest.kt b/app/src/test/java/com/pasich/encly/core/locale/AppLanguageTest.kt deleted file mode 100644 index 27f9e099..00000000 --- a/app/src/test/java/com/pasich/encly/core/locale/AppLanguageTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.pasich.encly.core.locale - -import org.junit.Assert.assertEquals -import org.junit.Test - -class AppLanguageTest { - - @Test - fun noTagMeansSystemDefault() { - assertEquals(AppLanguage.SYSTEM, AppLanguage.fromTag(null)) - assertEquals(AppLanguage.SYSTEM, AppLanguage.fromTag("")) - } - - @Test - fun matchesByPrimaryLanguageSubtag() { - assertEquals(AppLanguage.UKRAINIAN, AppLanguage.fromTag("uk")) - assertEquals(AppLanguage.UKRAINIAN, AppLanguage.fromTag("uk-UA")) - assertEquals(AppLanguage.PORTUGUESE, AppLanguage.fromTag("pt-BR")) - assertEquals(AppLanguage.GERMAN, AppLanguage.fromTag("de_AT")) - assertEquals(AppLanguage.DUTCH, AppLanguage.fromTag("NL")) - } - - @Test - fun unsupportedLanguageFallsBackToSystem() { - assertEquals(AppLanguage.SYSTEM, AppLanguage.fromTag("ja")) - } - - @Test - fun everyLanguageRoundTripsThroughItsTag() { - AppLanguage.entries.forEach { language -> - assertEquals(language, AppLanguage.fromTag(language.tag)) - } - } - - @Test - fun systemDefaultIsListedFirstThenNineLanguages() { - assertEquals(AppLanguage.SYSTEM, AppLanguage.entries.first()) - assertEquals(9, AppLanguage.entries.count { it.tag != null }) - } -} diff --git a/app/src/test/java/com/pasich/encly/core/locale/InAppLanguageRelaunchTest.kt b/app/src/test/java/com/pasich/encly/core/locale/InAppLanguageRelaunchTest.kt new file mode 100644 index 00000000..85a50b50 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/locale/InAppLanguageRelaunchTest.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.core.locale + +import android.Manifest +import android.app.Application +import android.os.LocaleList +import android.os.Looper +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.MainActivity +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.testutil.TestLocale +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.Locale + +/** + * Pick a language, kill the app, open it again: the language stored by AppCompat is applied to a + * new process, whose default locale is the device's. The strings read that default locale, so the + * launch itself has to put the stored language there before anything is composed. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [32, 35]) +class InAppLanguageRelaunchTest { + @Before + fun grantWhatTheManifestDeclares() { + // Granted at install on a device; Robolectric grants nothing by itself. + shadowOf(ApplicationProvider.getApplicationContext()) + .grantPermissions(Manifest.permission.HIDE_OVERLAY_WINDOWS) + } + + @After + fun resetLanguage() { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + TestLocale.set(Locale.US.toLanguageTag()) + } + + @Test + fun aRelaunchedActivityShowsTheStoredLanguage() { + // The language is picked in a running activity (AppCompat only stores it then), which + // recreates itself, as it does in the app. + val picking = Robolectric.buildActivity(MainActivity::class.java).setup() + try { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("uk")) + shadowOf(Looper.getMainLooper()).idle() + + // A new process comes up in the device's language and opens the activity again. + TestLocale.set("en-US") + assertEquals("Cancel", runBlocking { getString(Res.string.cancel) }) + val relaunched = Robolectric.buildActivity(MainActivity::class.java).create() + try { + assertEquals("uk", Locale.getDefault().language) + assertEquals("uk", LocaleList.getDefault()[0].language) + assertEquals("Скасувати", runBlocking { getString(Res.string.cancel) }) + } finally { + relaunched.destroy() + } + } finally { + picking.destroy() + } + } + + @Test + fun withoutAStoredLanguageTheDeviceOneStays() { + TestLocale.set("en-US") + + val activity = Robolectric.buildActivity(MainActivity::class.java).create() + try { + assertEquals("Cancel", runBlocking { getString(Res.string.cancel) }) + } finally { + activity.destroy() + } + } +} diff --git a/app/src/test/java/com/pasich/encly/core/platform/HandlerMainThreadTimerTest.kt b/app/src/test/java/com/pasich/encly/core/platform/HandlerMainThreadTimerTest.kt new file mode 100644 index 00000000..879599ca --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/platform/HandlerMainThreadTimerTest.kt @@ -0,0 +1,50 @@ +package com.pasich.encly.core.platform + +import android.os.Looper +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit + +/** The Android [MainThreadTimer]: runs the action once after the delay on the main looper, and cancel stops it. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HandlerMainThreadTimerTest { + private val timer = HandlerMainThreadTimer() + private var runs = 0 + + private fun pass(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(ms, TimeUnit.MILLISECONDS) + + @Test + fun theActionRunsOnceWhenTheDelayIsOver() { + timer.schedule(1_000L) { runs++ } + + pass(999L) + assertEquals(0, runs) + pass(2L) + assertEquals(1, runs) + pass(5_000L) + assertEquals(1, runs) + } + + @Test + fun cancellingStopsIt() { + val cancel = timer.schedule(1_000L) { runs++ } + + cancel() + pass(5_000L) + + assertEquals(0, runs) + } + + @Test + fun theNoTimerNeverRuns() { + MainThreadTimer.None.schedule(0L) { runs++ }() + + pass(1_000L) + assertEquals(0, runs) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/platform/SharedPreferencesKeyValueStoreTest.kt b/app/src/test/java/com/pasich/encly/core/platform/SharedPreferencesKeyValueStoreTest.kt new file mode 100644 index 00000000..76b1d559 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/platform/SharedPreferencesKeyValueStoreTest.kt @@ -0,0 +1,90 @@ +package com.pasich.encly.core.platform + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +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.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The Android [KeyValueStore] on real SharedPreferences: both write forms persist, and `commit` says so. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SharedPreferencesKeyValueStoreTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + private val prefs get() = context.getSharedPreferences("kv-test", Context.MODE_PRIVATE) + private val store get() = SharedPreferencesKeyValueStore(prefs) + + @After + fun clean() { + prefs.edit().clear().commit() + } + + @Test + fun valuesOfEveryTypeAreReadBackAndDefaultsApplyToMissingKeys() { + store.commit { + putString("s", "text") + putBoolean("b", true) + putLong("l", 42L) + } + + assertEquals("text", store.getString("s", null)) + assertTrue(store.getBoolean("b", false)) + assertEquals(42L, store.getLong("l", 0L)) + assertNull(store.getString("missing", null)) + assertEquals("fallback", store.getString("missing", "fallback")) + assertFalse(store.getBoolean("missing", false)) + assertEquals(7L, store.getLong("missing", 7L)) + assertTrue(store.contains("s")) + assertFalse(store.contains("missing")) + } + + @Test + fun editIsVisibleAtOnceAndReachesTheSharedPreferences() { + store.edit { putBoolean("flag", true) } + + assertTrue(store.getBoolean("flag", false)) + assertTrue(prefs.getBoolean("flag", false)) + } + + @Test + fun commitReportsThePersistedWrite() { + assertTrue(store.commit { putString("k", "v") }) + assertEquals("v", prefs.getString("k", null)) + } + + @Test + fun removeDropsOneKeyAndClearDropsAll() { + store.commit { + putString("a", "1") + putString("b", "2") + } + + store.commit { remove("a") } + assertFalse(store.contains("a")) + assertTrue(store.contains("b")) + + store.commit { clear() } + assertFalse(store.contains("b")) + } + + @Test + fun aClearInTheSameBatchComesBeforeItsPuts() { + store.commit { putString("old", "x") } + + store.commit { + putString("new", "y") + clear() + } + + // SharedPreferences applies a clear before the puts of its batch; the store keeps that. + assertFalse(store.contains("old")) + assertEquals("y", store.getString("new", null)) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/ActivityBiometricHostTest.kt b/app/src/test/java/com/pasich/encly/core/security/ActivityBiometricHostTest.kt new file mode 100644 index 00000000..10b1f9f0 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/ActivityBiometricHostTest.kt @@ -0,0 +1,27 @@ +package com.pasich.encly.core.security + +import androidx.fragment.app.FragmentActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.mockito.Mockito.mock + +/** The host is how a screen asking again after a recreation is told apart from the one that asked before. */ +class ActivityBiometricHostTest { + private val first = mock(FragmentActivity::class.java) + private val second = mock(FragmentActivity::class.java) + + @Test + fun hostsOfTheSameActivityAreEqual() { + assertEquals(ActivityBiometricHost(first), ActivityBiometricHost(first)) + assertNotEquals(ActivityBiometricHost(first), ActivityBiometricHost(second)) + } + + @Test + fun theActivityComesBackOutAndOnlyForThisAppsHosts() { + assertSame(first, ActivityBiometricHost(first).activityOrNull()) + assertNull(object : BiometricHost {}.activityOrNull()) + } +} 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 deleted file mode 100644 index ca4c4a5b..00000000 --- a/app/src/test/java/com/pasich/encly/core/security/AutoLockTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.pasich.encly.core.security - -import com.pasich.encly.testutil.InMemorySharedPreferences -import org.junit.Assert.assertEquals -import org.junit.Test - -class AutoLockTest { - @Test - fun theDefaultIsFifteenSeconds() { - val autoLock = AutoLock(InMemorySharedPreferences()) - - assertEquals(AutoLockDelay.SECONDS_15, autoLock.delay.value) - assertEquals(15_000L, autoLock.delayMillis) - } - - @Test - fun aChosenDelayIsKept() { - val prefs = InMemorySharedPreferences() - AutoLock(prefs).setDelay(AutoLockDelay.MINUTES_2) - - assertEquals(AutoLockDelay.MINUTES_2, AutoLock(prefs).delay.value) - } - - @Test - fun longerDelaysPersistWithoutChangingTheDefault() { - val prefs = InMemorySharedPreferences() - AutoLock(prefs).setDelay(AutoLockDelay.MINUTES_15) - assertEquals(900_000L, AutoLock(prefs).delayMillis) - - AutoLock(prefs).setDelay(AutoLockDelay.MINUTES_30) - assertEquals(1_800_000L, AutoLock(prefs).delayMillis) - assertEquals(AutoLockDelay.SECONDS_15, AutoLock(InMemorySharedPreferences()).delay.value) - } -} diff --git a/app/src/test/java/com/pasich/encly/core/security/BiometricManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/BiometricManagerTest.kt index 2bbb09f8..2eb1042d 100644 --- a/app/src/test/java/com/pasich/encly/core/security/BiometricManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/BiometricManagerTest.kt @@ -28,11 +28,15 @@ import android.hardware.biometrics.BiometricManager as PlatformBiometricManager class BiometricManagerTest { private val context: Application get() = ApplicationProvider.getApplicationContext() private val store = tempVaultStore() - private lateinit var manager: BiometricManager + private val texts = BiometricTextSource().apply { + val prompt = BiometricManager.PromptConfig("title", "subtitle") + set(BiometricTexts("unavailable", "cancel", prompt, prompt, prompt, prompt)) + } + private lateinit var manager: KeystoreBiometricManager @Before fun setUp() { - manager = BiometricManager(context, store) + manager = KeystoreBiometricManager(context, store, texts) } private fun sensor(available: Boolean) { @@ -40,7 +44,8 @@ class BiometricManagerTest { Shadow.extract(platform).setCanAuthenticate(available) } - private fun activity(): FragmentActivity = Robolectric.buildActivity(FragmentActivity::class.java).get() + private fun activity(): BiometricHost = + ActivityBiometricHost(Robolectric.buildActivity(FragmentActivity::class.java).get()) @Test fun withoutASensorBiometricsAreUnavailable() { diff --git a/app/src/test/java/com/pasich/encly/core/security/PendingDekTest.kt b/app/src/test/java/com/pasich/encly/core/security/PendingDekTest.kt new file mode 100644 index 00000000..2b284ae9 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/PendingDekTest.kt @@ -0,0 +1,90 @@ +package com.pasich.encly.core.security + +import androidx.fragment.app.FragmentActivity +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The enrolment's copy of the DEK: gone on every way out of the prompt, also when its answer never comes. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class PendingDekTest { + private fun key() = ByteArray(32) { 9 } + + @Test + fun theCopyIsUsableUntilItIsWiped() { + val source = key() + val pending = PendingDek(source) + + assertArrayEquals(key(), pending.use { it.copyOf() }) + pending.wipe() + + assertNull(pending.use { it.copyOf() }) + } + + @Test + fun theCopyIsIndependentOfTheCallersArray() { + val source = key() + val pending = PendingDek(source) + SensitiveDataCleaner.clear(source) + + assertArrayEquals(key(), pending.use { it.copyOf() }) + } + + @Test + fun wipingClearsTheBytesThemselves() { + var held: ByteArray? = null + val pending = PendingDek(key()) + pending.use { held = it } + + pending.wipe() + + assertArrayEquals(ByteArray(32), held) + } + + @Test + fun aStoppedActivityKeepsTheCopyForALateAnswer() { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).create().start().resume() + val pending = PendingDek(key()) + pending.wipeWhenDestroyed(controller.get().lifecycle) + + // Some devices show the prompt over a stopped activity and answer after the stop. + controller.pause().stop() + + assertArrayEquals(key(), pending.use { it.copyOf() }) + } + + @Test + fun anActivityDestroyedBeforeTheAnswerWipesTheCopy() { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).create() + val pending = PendingDek(key()) + pending.wipeWhenDestroyed(controller.get().lifecycle) + + controller.destroy() + + assertNull(pending.use { it.copyOf() }) + } + + @Test + fun theAnswerWipesTheCopyAndStopsWatchingTheActivity() { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).create().start().resume() + val lifecycle = controller.get().lifecycle + val before = lifecycle.observerCount + val pending = PendingDek(key()) + pending.wipeWhenDestroyed(lifecycle) + assertEquals(before + 1, lifecycle.observerCount) + + pending.wipe() + + assertEquals(before, lifecycle.observerCount) + assertNull(pending.use { it.copyOf() }) + } + + private val androidx.lifecycle.Lifecycle.observerCount: Int + get() = (this as androidx.lifecycle.LifecycleRegistry).observerCount +} diff --git a/app/src/test/java/com/pasich/encly/core/security/RecoveryWordsTest.kt b/app/src/test/java/com/pasich/encly/core/security/RecoveryWordsTest.kt deleted file mode 100644 index 958edd9c..00000000 --- a/app/src/test/java/com/pasich/encly/core/security/RecoveryWordsTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.pasich.encly.core.security - -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode -import cash.z.ecc.android.bip39.Mnemonics.WordCount -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class RecoveryWordsTest { - @Test - fun knowsTheWordlist() { - assertTrue(RecoveryWords.isWord("abandon")) - assertTrue(RecoveryWords.isWord("zoo")) - assertFalse(RecoveryWords.isWord("aban")) - assertFalse(RecoveryWords.isWord("Abandon")) - } - - @Test - fun prefixes() { - assertTrue(RecoveryWords.isPrefix("")) - assertTrue(RecoveryWords.isPrefix("ab")) - assertTrue(RecoveryWords.isPrefix("zoo")) - assertFalse(RecoveryWords.isPrefix("zz")) - assertFalse(RecoveryWords.isPrefix("abandonx")) - } - - @Test - fun checksum() { - assertTrue(RecoveryWords.isValidPhrase(MnemonicCode(WordCount.COUNT_12).chars)) - assertFalse(RecoveryWords.isValidPhrase(List(12) { "abandon" }.joinToString(" ").toCharArray())) - } -} diff --git a/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt b/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt deleted file mode 100644 index 12249c8e..00000000 --- a/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.pasich.encly.core.security - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import com.pasich.encly.core.security.cipher.SQLCipherUtils -import com.pasich.encly.core.security.cipher.SQLCipherUtils.State -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File - -/** The database file is classified by its header alone: missing, plaintext SQLite, or anything else. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) -class SQLCipherUtilsTest { - private val context: Application get() = ApplicationProvider.getApplicationContext() - - private fun dbFile(name: String): File = context.getDatabasePath(name).also { it.parentFile?.mkdirs() } - - @Test - fun aMissingFileDoesNotExist() { - assertEquals(State.DOES_NOT_EXIST, SQLCipherUtils.getDatabaseState(context, "no-such.db")) - } - - @Test - fun aFileWithTheRealSqliteHeaderIsPlaintext() { - dbFile("plain.db").writeBytes("SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) + ByteArray(100)) - - assertEquals(State.UNENCRYPTED, SQLCipherUtils.getDatabaseState(context, "plain.db")) - } - - @Test - fun aFileWithAnyOtherHeaderIsTreatedAsEncrypted() { - dbFile("cipher.db").writeBytes(ByteArray(4096) { (it * 31).toByte() }) - - assertEquals(State.ENCRYPTED, SQLCipherUtils.getDatabaseState(context, "cipher.db")) - } - - @Test - fun aFileShorterThanTheMagicIsNotPlaintext() { - dbFile("short.db").writeBytes("SQLite".toByteArray()) - - assertEquals(State.ENCRYPTED, SQLCipherUtils.getDatabaseState(context, "short.db")) - } - - @Test - fun anUnreadablePathIsTreatedAsEncryptedNotAsPlaintext() { - // A directory exists but cannot be read as a file: failing closed means "encrypted". - dbFile("dir.db").also { it.mkdirs() } - - assertEquals(State.ENCRYPTED, SQLCipherUtils.getDatabaseState(context, "dir.db")) - } -} diff --git a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt deleted file mode 100644 index b2f899f6..00000000 --- a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt +++ /dev/null @@ -1,375 +0,0 @@ -package com.pasich.encly.core.security - -import com.pasich.encly.data.database.SecureDatabaseManager -import com.pasich.encly.testutil.FakeLockoutClock -import com.pasich.encly.testutil.FakePinFactor -import com.pasich.encly.testutil.InMemorySharedPreferences -import com.pasich.encly.testutil.MockActivity -import com.pasich.encly.testutil.anyByteArray -import com.pasich.encly.testutil.tempVaultFile -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.ArgumentMatchers.anyBoolean -import org.mockito.Mockito -import org.mockito.Mockito.clearInvocations -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import java.io.File - -/** - * SecurityManager over real slot stores and a mocked database: where first-run setup, startup - * routing and session locking send the user, and what a locked or half-set-up vault refuses. - */ -class SecurityManagerLifecycleTest { - private lateinit var file: File - private lateinit var store: VaultStore - private lateinit var prefs: InMemorySharedPreferences - private lateinit var seed: SeedPhraseManager - private lateinit var auth: AuthenticationManager - private lateinit var database: SecureDatabaseManager - private lateinit var biometric: BiometricManager - private var slotPresent = false - private var enrollResult = true - private var enrollCalls = 0 - private var disableCalls = 0 - private var prompt: BiometricManager.BiometricCallback? = null - private lateinit var manager: SecurityManager - - @Before - fun setUp() { - file = tempVaultFile() - store = VaultStore(file) - prefs = InMemorySharedPreferences() - seed = SeedPhraseManager(store) - auth = AuthenticationManager(store, FakePinFactor(), FakeLockoutClock()) - database = mock(SecureDatabaseManager::class.java) - biometric = mock(BiometricManager::class.java) { call -> - when (call.method.name) { - "hasSlot" -> slotPresent - - "disable" -> { - disableCalls++ - null - } - - "enroll" -> { - enrollCalls++ - @Suppress("UNCHECKED_CAST") - (call.getArgument(2) as (Boolean) -> Unit)(enrollResult) - null - } - - "authenticate" -> { - prompt = call.getArgument(2) - null - } - - else -> Mockito.RETURNS_DEFAULTS.answer(call) - } - } - manager = SecurityManager(prefs, store, seed, database, auth, biometric) {} - `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) - } - - /** First-run setup up to and including the committed onboarding, with a recovery phrase. */ - private fun committedVault(withRecoveryPhrase: Boolean = true): CharArray { - val phrase = seed.generateMnemonic().chars - assertTrue(manager.initializeNewVault(if (withRecoveryPhrase) phrase.copyOf() else null)) - assertTrue(manager.configurePin(PIN.toCharArray())) - assertTrue(manager.finishInitialSetup()) - manager.lock() - `when`(database.hasEncryptedDatabase()).thenReturn(true) - return phrase - } - - // --- startup routing ------------------------------------------------------------------ - - @Test - fun aDamagedStateFileRoutesToTheDamagedVaultScreen() { - file.writeBytes(ByteArray(50) { 1 }) - - assertEquals(InitialStatus.LOSS_CRYPTO, manager.resolveInitialStatus()) - assertEquals(InitialStatus.LOSS_CRYPTO, manager.securityStatus) - } - - @Test - fun anEncryptedDatabaseWithoutVaultMetadataIsALegacyVault() { - `when`(database.hasEncryptedDatabase()).thenReturn(true) - - assertEquals(InitialStatus.LEGACY_VAULT, manager.resolveInitialStatus()) - } - - @Test - fun aCommittedVaultWithoutItsDatabaseRoutesToDatabaseLoss() { - committedVault() - `when`(database.hasEncryptedDatabase()).thenReturn(false) - - assertEquals(InitialStatus.LOSS_DATABASE, manager.resolveInitialStatus()) - } - - @Test - fun aCommittedVaultAsksForTheLockScreen() { - committedVault() - - assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) - assertTrue(manager.isLockable()) - } - - @Test - fun aVaultWithoutAPinSlotAndWithoutARecoveryPhraseHasNoWayIn() { - committedVault(withRecoveryPhrase = false) - auth.wipe() - - assertEquals(InitialStatus.LOSS_CRYPTO, manager.resolveInitialStatus()) - } - - @Test - fun aVaultWithoutAPinSlotStillAsksForTheRecoveryPhrase() { - committedVault() - auth.wipe() - - assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) - } - - @Test - fun anOpenVaultStaysOpenWhenTheActivityAsksAgain() { - committedVault() - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) - `when`(database.isDatabaseUnlocked()).thenReturn(true) - - assertEquals(InitialStatus.MAIN, manager.resolveInitialStatus()) - } - - // --- first-run setup -------------------------------------------------------------------- - - @Test - fun aFreshVaultIsNotLockableBeforeOnboardingIsCommitted() { - assertTrue(manager.initializeNewVault(null)) - assertTrue(manager.configurePin(PIN.toCharArray())) - - assertFalse(manager.isLockable()) - assertTrue(manager.isOnboardingShow()) - } - - @Test - fun onboardingCanNotBeCommittedWithoutAPinSlot() { - assertTrue(manager.initializeNewVault(null)) - - assertFalse(manager.setOnboardingShown()) - assertFalse(manager.finishInitialSetup()) - assertTrue(manager.isOnboardingShow()) - } - - @Test - fun theInitialVaultIsNotOpenedWithoutAPinSlot() { - assertTrue(manager.initializeNewVault(null)) - - assertFalse(manager.openInitialVault()) - verify(database, never()).unlockDatabase(anyByteArray(), anyBoolean()) - } - - @Test - fun aCommittedVaultCanNotBeRecreatedOverItsData() { - committedVault() - - assertFalse(manager.initializeNewVault(null)) - assertTrue(manager.hasRecoverySeed()) - } - - @Test - fun anInvalidRecoveryPhraseDoesNotStartAVault() { - assertFalse(manager.initializeNewVault("not a real phrase".toCharArray())) - assertFalse(manager.isValidRecoveryPhrase("not a real phrase".toCharArray())) - } - - @Test - fun committingSetupDropsTheBootstrapKeySoNothingCanBeEnrolledFromIt() { - committedVault() - var enrolled: Boolean? = null - - manager.enrollBiometric(MockActivity().activity) { enrolled = it } - - assertEquals(false, enrolled) - assertEquals(0, enrollCalls) - } - - // --- unlocking and locking ------------------------------------------------------------ - - @Test - fun aDatabaseThatDoesNotOpenFailsThePinUnlockWithoutOpeningASession() { - committedVault() - `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(false) - - assertEquals(VaultUnlockResult.DB_ERROR, manager.unlockWithPin(PIN.toCharArray())) - assertNull(manager.copyBackupRootKey()) - assertFalse(manager.canResetPinWithoutCurrent()) - } - - @Test - fun aWrongPinIsAnInvalidCredential() { - committedVault() - - assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithPin(WRONG_PIN.toCharArray())) - } - - @Test - fun aWrongRecoveryPhraseIsAnInvalidCredential() { - committedVault() - val other = seed.generateMnemonic().chars - - assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithSeed(other)) - assertFalse(manager.verifySeed(other)) - } - - @Test - fun theRecoveryPhraseOpensTheVaultAndLetsThePinBeResetOnce() { - val phrase = committedVault() - assertTrue(manager.verifySeed(phrase.copyOf())) - - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) - assertTrue(manager.canResetPinWithoutCurrent()) - - assertEquals(PinChange.SET, manager.changePin(NEW_PIN.toCharArray())) - assertFalse("a new PIN ends the recovery privilege", manager.canResetPinWithoutCurrent()) - } - - @Test - fun aPinUnlockDoesNotGrantTheForgottenPinPrivilege() { - committedVault() - - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) - - assertFalse(manager.canResetPinWithoutCurrent()) - } - - @Test - fun lockingEndsTheSessionAndWithdrawsTheKeysItHeld() { - val phrase = committedVault() - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) - assertTrue(manager.hasBackupKey()) - assertNotNull("the open session can read the backup root", manager.copyBackupRootKey()) - - clearInvocations(database) - - manager.lock() - - assertEquals(InitialStatus.AUTH, manager.securityStatus) - assertNull(manager.copyBackupRootKey()) - assertFalse(manager.addRecoverySeed(seed.generateMnemonic().chars)) - assertFalse(manager.replaceRecoverySeed(seed.generateMnemonic().chars)) - assertFalse(manager.createBackupKey(phrase.copyOf())) - assertFalse(manager.canResetPinWithoutCurrent()) - verify(database).reset() - } - - @Test - fun aKeyOfTheWrongSizeNeverOpensTheDatabase() { - assertFalse(manager.unlockWithRawKey(ByteArray(31))) - assertFalse(manager.unlockWithRawKey(ByteArray(33), allowCreate = true)) - - verify(database, never()).unlockDatabase(anyByteArray(), anyBoolean()) - } - - @Test - fun theLockoutSurfacesThroughTheManager() { - committedVault() - repeat(LOCKOUT_AFTER) { manager.unlockWithPin(WRONG_PIN.toCharArray()) } - - assertTrue(manager.pinLockoutRemainingMillis() > 0) - assertEquals(VaultUnlockResult.LOCKED_OUT, manager.unlockWithPin(PIN.toCharArray())) - } - - @Test - fun verifyingThePinCountsAndAcceptsOnlyTheRealOne() { - committedVault() - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) - - assertTrue(manager.verifyPin(PIN.toCharArray())) - assertFalse(manager.verifyPin(WRONG_PIN.toCharArray())) - } - - // --- biometric plumbing ----------------------------------------------------------------- - - @Test - fun enrollingRecordsTheResultOfTheBiometricSlot() { - committedVault() - manager.unlockWithPin(PIN.toCharArray()) - slotPresent = true - var result: Boolean? = null - - manager.enrollBiometric(MockActivity().activity) { result = it } - - assertEquals(true, result) - assertTrue(manager.isBiometricEnabled()) - assertTrue(manager.getSettingsAuth().isBiometricEnabled) - } - - @Test - fun aFailedEnrollmentLeavesBiometricsOff() { - committedVault() - manager.unlockWithPin(PIN.toCharArray()) - slotPresent = true - enrollResult = false - var result: Boolean? = null - - manager.enrollBiometric(MockActivity().activity) { result = it } - - assertEquals(false, result) - assertFalse(manager.isBiometricEnabled()) - } - - @Test - fun disablingBiometricsClearsTheSlotAndTheFlag() { - committedVault() - manager.unlockWithPin(PIN.toCharArray()) - auth.markBiometricEnabled(true) - slotPresent = true - assertTrue(manager.isBiometricEnabled()) - disableCalls = 0 - - manager.disableBiometric() - - assertEquals(1, disableCalls) - assertFalse(manager.isBiometricEnabled()) - } - - @Test - fun theFlagAloneIsNotEnoughWithoutTheKeystoreSlot() { - committedVault() - auth.markBiometricEnabled(true) - slotPresent = false - - assertFalse(manager.isBiometricEnabled()) - } - - @Test - fun theFingerprintConfirmationMapsEveryOutcomeToYesOrNo() { - committedVault() - manager.unlockWithPin(PIN.toCharArray()) - val results = mutableListOf() - - manager.confirmBiometric(MockActivity().activity) { results += it } - val callback = checkNotNull(prompt) - callback.onFailed() - assertEquals("a single bad scan is not an answer", emptyList(), results) - callback.onSuccess() - callback.onError(7, "locked") - callback.onCancelled() - - assertEquals(listOf(true, false, false), results) - } - - private companion object { - const val PIN = "135790" - const val NEW_PIN = "246813" - const val WRONG_PIN = "000111" - const val LOCKOUT_AFTER = 5 - } -} diff --git a/app/src/test/java/com/pasich/encly/core/security/SecurityPlatformTest.kt b/app/src/test/java/com/pasich/encly/core/security/SecurityPlatformTest.kt index 87cd87e4..8c3e22ea 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SecurityPlatformTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SecurityPlatformTest.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.provider.Settings import androidx.test.core.app.ApplicationProvider -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -101,7 +101,7 @@ class SecurityPlatformTest { @Test fun strictKeyboardPrivacyIsOffByDefaultAndPersistsWhenSet() { - val prefs = InMemorySharedPreferences() + val prefs = InMemoryKeyValueStore() val privacy = KeyboardPrivacy(prefs) assertFalse(privacy.strict.value) diff --git a/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt deleted file mode 100644 index e3960a99..00000000 --- a/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt +++ /dev/null @@ -1,438 +0,0 @@ -package com.pasich.encly.core.security - -import androidx.lifecycle.LifecycleOwner -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` - -class SessionLockManagerTest { - - private val owner = mock(LifecycleOwner::class.java) - private lateinit var security: SecurityManager - private lateinit var manager: SessionLockManager - - @Before - fun setUp() { - security = mock(SecurityManager::class.java) - `when`(security.isLockable()).thenReturn(true) - manager = SessionLockManager(security) - manager.onStart(owner) - } - - @Test - fun backgroundingAnOpenVaultLocksIt() { - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.onStop(owner) - - verify(security).lock() - assertTrue(manager.locked.value) - } - - @Test - fun backgroundingWithoutAnOpenVaultDoesNothing() { - manager.onStop(owner) - - verify(security, never()).lock() - assertFalse(manager.locked.value) - } - - @Test - fun backgroundingBeforeAnyLockIsConfiguredDoesNothing() { - `when`(security.isLockable()).thenReturn(false) - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.onStop(owner) - - verify(security, never()).lock() - } - - @Test - fun lockNowClosesAnOpenVault() { - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.lockNow() - - verify(security).lock() - assertTrue(manager.locked.value) - } - - @Test - fun lockNowBeforeOnboardingIsCommittedDoesNothing() { - `when`(security.isLockable()).thenReturn(false) - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.lockNow() - - verify(security, never()).lock() - assertFalse(manager.locked.value) - } - - @Test - fun eachPublishedUnlockStartsANewSessionGenerationAndALockDoesNot() { - `when`(security.isDatabaseUnlocked()).thenReturn(true) - val first = manager.sessionGeneration - - manager.lockNow() - assertEquals(first, manager.sessionGeneration) - assertTrue(manager.onUnlocked()) - assertEquals(first + 1, manager.sessionGeneration) - - // An unlock that lands in the background is closed again, never published. - manager.onStop(owner) - assertFalse(manager.onUnlocked()) - assertEquals(first + 1, manager.sessionGeneration) - } - - @Test - fun unlockInTheForegroundIsPublished() { - `when`(security.isDatabaseUnlocked()).thenReturn(true) - manager.onStop(owner) - manager.onStart(owner) - - assertTrue(manager.onUnlocked()) - - assertFalse(manager.locked.value) - } - - @Test - fun unlockThatFinishesAfterBackgroundingIsClosedAgain() { - // PBKDF2 still running: ON_STOP sees no open database and does nothing... - manager.onStop(owner) - verify(security, never()).lock() - - // ...then the vault opens while the app is in the background. - val published = manager.onUnlocked() - - assertFalse(published) - verify(security).lock() - assertTrue(manager.locked.value) - } - - @Test - fun unlockRacingOnStopStaysLocked() { - // The database opened just before ON_STOP: ON_STOP closes it, and the late - // onUnlocked() must not reopen the UI. - `when`(security.isDatabaseUnlocked()).thenReturn(true) - manager.onStop(owner) - - assertFalse(manager.onUnlocked()) - - verify(security, times(2)).lock() - assertTrue(manager.locked.value) - } - - @Test - fun unlockBeforeTheProcessEverStartedIsNotPublished() { - val fresh = SessionLockManager(security) - - assertFalse(fresh.onUnlocked()) - assertTrue(fresh.locked.value) - } - - // --- system file picker (backup export/import) -------------------------------------- - - private var now = 0L - private var pendingTimer: (() -> Unit)? = null - - private fun withFakeTime() { - manager.clock = { now } - manager.timer = { _, action -> - pendingTimer = action - val cancel: () -> Unit = { pendingTimer = null } - cancel - } - } - - @Test - fun openingTheFilePickerKeepsTheVaultOpen() { - withFakeTime() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - now += 1_000 - manager.onStop(owner) - now += 30_000 - manager.onStart(owner) - - verify(security, never()).lock() - assertFalse(manager.locked.value) - } - - @Test - fun theAllowanceIsSingleUseAndShortLived() { - withFakeTime() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - now += 6_000 // the picker never opened in time - manager.onStop(owner) - - verify(security).lock() - assertTrue(manager.locked.value) - } - - @Test - fun stayingAwayFromThePickerTooLongLocksOnReturn() { - withFakeTime() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - manager.onStop(owner) - now += 6 * 60_000 - manager.onStart(owner) - - verify(security).lock() - assertTrue(manager.locked.value) - } - - @Test - fun leavingThePickerForAnotherAppLocksAfterTheTimeout() { - withFakeTime() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - manager.onStop(owner) - pendingTimer!!.invoke() - - verify(security).lock() - assertTrue(manager.locked.value) - } - - // --- picker grace: capped, monotonic, ended by screen-off / keyguard / return ------- - - private val watcher = FakeDeviceLock() - - private fun withWatcher() { - manager = SessionLockManager(security, watcher) - manager.onStart(owner) - withFakeTime() - } - - @Test - fun thePickerGraceIsCappedAtAMinute() { - withFakeTime() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - manager.onStop(owner) - now += 61_000 - manager.onStart(owner) - - verify(security).lock() - assertTrue(manager.locked.value) - } - - @Test - fun deepSleepCountsAgainstTheGraceEvenIfTheTimerNeverFired() { - // Handler timers stop in deep sleep; elapsedRealtime does not. The return re-checks. - withFakeTime() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - manager.onStop(owner) - now += 3 * 60 * 60_000L // three hours, screen off, timer never ran - manager.onStart(owner) - - verify(security).lock() - } - - @Test - fun theScreenTurningOffDuringThePickerLocksAtOnce() { - withWatcher() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - manager.onStop(owner) - watcher.screenOff() - - verify(security).lock() - assertTrue(manager.locked.value) - assertFalse("the receiver is gone once the grace ended", watcher.watching) - } - - @Test - fun aKeyguardShownMeanwhileLocksOnReturn() { - withWatcher() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - manager.onStop(owner) - now += 10_000 - watcher.locked = true - manager.onStart(owner) - - verify(security).lock() - } - - @Test - fun aQuickReturnFromThePickerKeepsTheVaultAndStopsWatching() { - withWatcher() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - manager.onStop(owner) - assertTrue(watcher.watching) - now += 10_000 - manager.onStart(owner) - - verify(security, never()).lock() - assertFalse(watcher.watching) - assertNull(pendingTimer) - } - - @Test - fun thePickerReturningEndsTheAllowance() { - withFakeTime() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.allowSystemPicker() - manager.endSystemPicker() // e.g. the picker could not open - manager.onStop(owner) // Home, within the launch window - - verify(security).lock() - } - - @Test - fun startupWithACommittedVaultStartsLocked() { - manager.requireUnlock() - - assertTrue(manager.locked.value) - assertTrue(manager.onUnlocked()) - assertFalse(manager.locked.value) - } - - @Test - fun anAbandonedUnlockIsClosedAgain() { - `when`(security.isDatabaseUnlocked()).thenReturn(true) - - manager.onUnlockAbandoned() - - verify(security).lock() - assertTrue(manager.locked.value) - } - - // --- auto-lock delay ------------------------------------------------------------------ - - private fun withDelay(millis: Long) { - manager = SessionLockManager( - security, - watcher, - autoLock = object : AutoLockPolicy { - override val delayMillis: Long = millis - }, - ) - manager.onStart(owner) - withFakeTime() - `when`(security.isDatabaseUnlocked()).thenReturn(true) - } - - @Test - fun comingBackWithinTheAutoLockDelayKeepsTheVaultOpen() { - withDelay(15_000) - - manager.onStop(owner) - now += 10_000 - manager.onStart(owner) - - verify(security, never()).lock() - assertFalse(manager.locked.value) - assertFalse(watcher.watching) - } - - @Test - fun theAutoLockDelayRunningOutLocksInTheBackground() { - withDelay(15_000) - - manager.onStop(owner) - pendingTimer!!.invoke() - - verify(security).lock() - assertTrue(manager.locked.value) - } - - @Test - fun comingBackAfterTheAutoLockDelayLocks() { - withDelay(30_000) - - manager.onStop(owner) - now += 31_000 // deep sleep: the timer never fired - manager.onStart(owner) - - verify(security).lock() - 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) - - manager.onStop(owner) - watcher.screenOff() - - verify(security).lock() - assertTrue(manager.locked.value) - } - - @Test - fun anImmediateAutoLockLocksOnLeaving() { - withDelay(0) - - manager.onStop(owner) - - verify(security).lock() - assertTrue(manager.locked.value) - } - - private class FakeDeviceLock : DeviceLockWatcher { - var locked = false - var watching = false - private var onScreenOff: (() -> Unit)? = null - - override fun isDeviceLocked(): Boolean = locked - - override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit { - watching = true - this.onScreenOff = onScreenOff - return { - watching = false - this.onScreenOff = null - } - } - - fun screenOff() { - onScreenOff?.invoke() - } - } -} diff --git a/app/src/test/java/com/pasich/encly/core/security/SessionLockTimerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SessionLockTimerTest.kt index d4fa49d9..358bfceb 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SessionLockTimerTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SessionLockTimerTest.kt @@ -2,6 +2,7 @@ package com.pasich.encly.core.security import android.os.Looper import androidx.lifecycle.LifecycleOwner +import com.pasich.encly.core.platform.HandlerMainThreadTimer import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -29,6 +30,7 @@ class SessionLockTimerTest { autoLock = object : AutoLockPolicy { override val delayMillis = DELAY_MS }, + mainThread = HandlerMainThreadTimer(), ) private fun pass(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(ms, TimeUnit.MILLISECONDS) diff --git a/app/src/test/java/com/pasich/encly/core/security/VaultStoreTest.kt b/app/src/test/java/com/pasich/encly/core/security/VaultStoreTest.kt deleted file mode 100644 index 17917491..00000000 --- a/app/src/test/java/com/pasich/encly/core/security/VaultStoreTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.pasich.encly.core.security - -import com.pasich.encly.testutil.tempVaultFile -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File - -/** The slot file: atomic replacement, damage detection, and the way out of damage. */ -class VaultStoreTest { - private val file = tempVaultFile() - - @Test - fun valuesSurviveAReload() { - val store = VaultStore(file) - assertTrue( - store.edit { - putBytes("a", byteArrayOf(1, 2, 3)) - putInt("i", 42) - putLong("l", Long.MAX_VALUE) - putBoolean("b", true) - }, - ) - - val reloaded = VaultStore(file) - - assertArrayEquals(byteArrayOf(1, 2, 3), reloaded.getBytes("a")) - assertEquals(42, reloaded.getInt("i", 0)) - assertEquals(Long.MAX_VALUE, reloaded.getLong("l", 0)) - assertTrue(reloaded.getBoolean("b", false)) - assertFalse(reloaded.isCorrupt()) - } - - @Test - fun aMissingFileIsAnEmptyStoreNotADamagedOne() { - val store = VaultStore(file) - - assertFalse(store.isCorrupt()) - assertNull(store.getBytes("a")) - assertEquals(7, store.getInt("i", 7)) - } - - @Test - fun valuesAreCopiedInAndOut() { - val store = VaultStore(file) - val value = byteArrayOf(1, 2, 3) - store.edit { putBytes("a", value) } - value.fill(0) - store.getBytes("a")!!.fill(0) - - assertArrayEquals(byteArrayOf(1, 2, 3), store.getBytes("a")) - } - - @Test - fun oneEditIsOneWrite() { - val store = VaultStore(file) - store.edit { - putInt("x", 1) - putInt("y", 1) - } - store.edit { - putInt("x", 2) - remove("y") - putInt("z", 3) - } - - val reloaded = VaultStore(file) - assertEquals(2, reloaded.getInt("x", 0)) - assertFalse(reloaded.contains("y")) - assertEquals(3, reloaded.getInt("z", 0)) - } - - @Test - fun removePrefixDropsOnlyThatGroup() { - val store = VaultStore(file) - store.edit { - putInt("pin.a", 1) - putInt("pin.b", 1) - putInt("recovery.a", 1) - } - store.edit { removePrefix("pin.") } - - assertFalse(store.contains("pin.a")) - assertFalse(store.contains("pin.b")) - assertTrue(store.contains("recovery.a")) - } - - @Test - fun aCrashBeforeTheRenameKeepsTheOldStateIntact() { - val store = VaultStore(file) - store.edit { putInt("x", 1) } - store.beforeRename = { throw CrashBeforeRename() } - - runCatching { store.edit { putInt("x", 2) } } - - // What the next process start sees: the old file, whole; the leftover temp is ignored. - val restarted = VaultStore(file) - assertFalse(restarted.isCorrupt()) - assertEquals(1, restarted.getInt("x", 0)) - assertFalse("the temp file is cleaned up", File(file.path + ".tmp").exists()) - } - - @Test - fun aHalfWrittenTempFileIsNeverRead() { - VaultStore(file).edit { putInt("x", 1) } - File(file.path + ".tmp").writeBytes(byteArrayOf(9, 9, 9)) - - val store = VaultStore(file) - - assertEquals(1, store.getInt("x", 0)) - assertFalse(store.isCorrupt()) - } - - @Test - fun aFailedWriteChangesNothing() { - val store = VaultStore(file) - store.edit { putInt("x", 1) } - val dir = file.parentFile!! - dir.setWritable(false) - try { - assertFalse(store.edit { putInt("x", 2) }) - } finally { - dir.setWritable(true) - } - - assertEquals(1, store.getInt("x", 0)) - assertEquals(1, VaultStore(file).getInt("x", 0)) - } - - @Test - fun aDamagedFileReadsAsEmptyAndRefusesWrites() { - VaultStore(file).edit { putBytes("pin.slot", ByteArray(64) { 5 }) } - val bytes = file.readBytes() - bytes[bytes.size / 2] = (bytes[bytes.size / 2].toInt() xor 0x40).toByte() - file.writeBytes(bytes) - - val store = VaultStore(file) - - assertTrue(store.isCorrupt()) - assertNull(store.getBytes("pin.slot")) - assertFalse(store.edit { putInt("x", 1) }) - assertTrue("the damaged file is kept until the user wipes", file.exists()) - } - - @Test - fun aTruncatedOrForeignFileIsDamaged() { - VaultStore(file).edit { putInt("x", 1) } - file.writeBytes(file.readBytes().copyOf(20)) - assertTrue(VaultStore(file).isCorrupt()) - - file.writeText("") - assertTrue(VaultStore(file).isCorrupt()) - } - - @Test - fun clearIsTheWayOutOfDamage() { - file.writeBytes(ByteArray(100) { 1 }) - val store = VaultStore(file) - assertTrue(store.isCorrupt()) - - assertTrue(store.clear()) - - assertFalse(store.isCorrupt()) - assertFalse(file.exists()) - assertTrue(store.edit { putInt("x", 1) }) - assertEquals(1, VaultStore(file).getInt("x", 0)) - } - - private class CrashBeforeRename : RuntimeException() -} diff --git a/app/src/test/java/com/pasich/encly/data/backup/BackupDocumentsTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupDocumentsTest.kt deleted file mode 100644 index 3f738018..00000000 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupDocumentsTest.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.pasich.encly.data.backup - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import com.pasich.encly.core.backup.BackupCipher -import com.pasich.encly.core.backup.BackupError -import com.pasich.encly.core.backup.BackupFormat -import com.pasich.encly.core.backup.BackupSecret -import com.pasich.encly.core.backup.assertError -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.doThrow -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.io.InputStream -import java.nio.ByteBuffer - -class BackupDocumentsTest { - - private val backup = BackupCipher.seal(ByteArray(200_000) { it.toByte() }, BackupSecret.RecoveryRoot(ByteArray(32))) - - // --- reading -------------------------------------------------------------------------- - - @Test - fun readsABackupSizedFromItsHeader() { - val input = CountingInputStream(backup) - - assertArrayEquals(backup, BackupDocuments.readCapped(input, limit = backup.size)) - } - - @Test - fun rejectsAFileDeclaringMoreThanTheLimitWithoutReadingIt() { - val input = CountingInputStream(backup) - - assertError(BackupError.TOO_LARGE) { BackupDocuments.readCapped(input, limit = backup.size - 1) } - assertEquals(BackupFormat.HEADER_LENGTH, input.consumed) - } - - @Test - fun rejectsAnUnsignedLengthAboveIntMax() { - val header = backup.copyOf(BackupFormat.HEADER_LENGTH) - ByteBuffer.wrap(header).putInt(LENGTH_OFFSET, -1) // 0xFFFFFFFF - - assertError(BackupError.TOO_LARGE) { BackupDocuments.readCapped(ByteArrayInputStream(header)) } - } - - @Test - fun rejectsDataPastTheDeclaredEnd() { - assertError(BackupError.CORRUPTED) { - BackupDocuments.readCapped(ByteArrayInputStream(backup + byteArrayOf(0))) - } - } - - @Test - fun aShortFileIsLeftForParseToReportTruncated() { - val short = BackupDocuments.readCapped(ByteArrayInputStream(backup.copyOf(backup.size - 1))) - - assertEquals(backup.size - 1, short.size) - assertError(BackupError.TRUNCATED) { BackupFormat.parse(short) } - } - - @Test - fun otherFilesAreRejectedAfterTheirFirstBytes() { - val input = CountingInputStream(ByteArray(1_000_000)) - - assertError(BackupError.NOT_A_BACKUP) { BackupDocuments.readCapped(input) } - assertEquals(BackupFormat.HEADER_LENGTH, input.consumed) - } - - // --- writing -------------------------------------------------------------------------- - - private lateinit var resolver: ContentResolver - private lateinit var documents: BackupDocuments - private val uri = mock(Uri::class.java) - private var stored = ByteArray(0) - - @Before - fun setUp() { - resolver = mock(ContentResolver::class.java) - val context = mock(Context::class.java) - `when`(context.contentResolver).thenReturn(resolver) - documents = BackupDocuments(context) - `when`(resolver.openInputStream(uri)).thenAnswer { ByteArrayInputStream(stored) } - `when`(resolver.openOutputStream(uri, "wt")).thenAnswer { StoringOutputStream() } - } - - @Test - fun writesToANewDocumentAndChecksItBack() { - documents.write(uri, backup) - - assertArrayEquals(backup, stored) - } - - @Test - fun neverOverwritesAnExistingBackup() { - val previous = backup.copyOf() - stored = previous - - assertError(BackupError.TARGET_EXISTS) { documents.write(uri, byteArrayOf(1, 2, 3)) } - - verify(resolver, never()).openOutputStream(uri, "wt") - assertArrayEquals(previous, stored) - } - - @Test - fun aWriteThatDoesNotReadBackIsAnError() { - `when`(resolver.openOutputStream(uri, "wt")).thenAnswer { TruncatingOutputStream() } - - assertError(BackupError.IO) { documents.write(uri, backup) } - } - - @Test - fun aFailedWriteIsAnIoError() { - `when`(resolver.openOutputStream(uri, "wt")).thenAnswer { throw IOException("No space left") } - - assertError(BackupError.IO) { documents.write(uri, backup) } - } - - @Test - fun providerRuntimeExceptionsBecomeIoErrorsNotCrashes() { - listOf( - IllegalArgumentException("Unknown URI"), - IllegalStateException("provider died"), - UnsupportedOperationException("mode wt"), - NullPointerException(), - ).forEach { failure -> - doThrow(failure).`when`(resolver).openInputStream(uri) - - assertError(BackupError.IO) { documents.read(uri) } - assertError(BackupError.IO) { documents.write(uri, backup) } - } - } - - @Test - fun aMissingStreamIsAnIoError() { - `when`(resolver.openInputStream(uri)).thenReturn(null) - - assertError(BackupError.IO) { documents.read(uri) } - } - - private inner class StoringOutputStream : ByteArrayOutputStream() { - override fun close() { - super.close() - stored = toByteArray() - } - } - - /** A provider that silently loses the last byte. */ - private inner class TruncatingOutputStream : ByteArrayOutputStream() { - override fun close() { - super.close() - stored = toByteArray().copyOf(size() - 1) - } - } - - private class CountingInputStream(bytes: ByteArray) : InputStream() { - private val input = ByteArrayInputStream(bytes) - var consumed = 0 - private set - - override fun read(): Int = input.read().also { if (it >= 0) consumed++ } - - override fun read(b: ByteArray, off: Int, len: Int): Int = input.read(b, off, len).also { - if (it > - 0 - ) { - consumed += it - } - } - } - - private companion object { - const val LENGTH_OFFSET = 57 - } -} 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 deleted file mode 100644 index 24f4fad1..00000000 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -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 -import com.pasich.encly.testutil.InMemoryVaultDataStore -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import java.io.ByteArrayInputStream - -class BackupManagerTest { - - private lateinit var prefs: InMemorySharedPreferences - private lateinit var manager: BackupManager - - @Before - fun setUp() = runTest { - val security = mock(SecurityManager::class.java) - // A fresh copy per call: createBackup wipes the key it was handed. - `when`(security.copyBackupRootKey()).thenAnswer { ByteArray(ROOT_LENGTH) { 5 } } - val store = InMemoryVaultDataStore().apply { - insertNote(Note(title = "Plan", value = "[]", uid = "n-plan")) - } - prefs = InMemorySharedPreferences() - manager = BackupManager(security, store, prefs).apply { clock = { 1L } } - } - - @Test - fun aBackupExactlyAtTheImportLimitIsWrittenAndReadsBack() = runTest { - val size = manager.createBackup().size - manager.maxFileBytes = size - - val file = manager.createBackup() - - assertEquals(size, file.size) - val read = BackupDocuments.readCapped(ByteArrayInputStream(file), limit = size) - assertEquals(size, BackupFormat.parse(read).file.size) - } - - @Test - fun aVaultTooLargeToImportIsRefusedInsteadOfExported() = runTest { - val size = manager.createBackup().size - manager.maxFileBytes = size - 1 - - try { - manager.createBackup() - fail("expected TOO_LARGE") - } catch (e: BackupException) { - assertEquals(BackupError.TOO_LARGE, e.error) - } - // Nothing is recorded as exported. - assertNull(manager.lastExportAt()) - } - - @Test - fun theDefaultExportLimitIsTheImportLimit() { - assertEquals(BackupFormat.MAX_FILE_BYTES, manager.maxFileBytes) - } - - @Test - fun exportRefusesAStoreWhoseSessionClosedAfterSnapshot() = runTest { - val security = mock(SecurityManager::class.java) - `when`(security.copyBackupRootKey()).thenAnswer { ByteArray(ROOT_LENGTH) { 5 } } - val memory = InMemoryVaultDataStore() - var active = true - val bound = object : VaultDataStore by memory { - override suspend fun snapshot(): VaultSnapshot = memory.snapshot().also { active = false } - - override fun assertCurrentSession() { - if (!active) error("session closed") - } - } - val store = object : VaultDataStore by memory { - override fun bindToCurrentSession(): VaultDataStore = bound - } - - try { - BackupManager(security, store, InMemorySharedPreferences()).createBackup() - fail("expected a closed-session failure") - } catch (e: BackupException) { - assertEquals(BackupError.IO, e.error) - } - } - - @Test - fun theDecoyVaultRefusesEveryBackupStepAsAStorageError() = runTest { - val security = mock(SecurityManager::class.java) - `when`(security.isDecoySession()).thenReturn(true) - val decoy = BackupManager(security, InMemoryVaultDataStore(), InMemorySharedPreferences()) - val empty = BackupPayload(exportedAt = 0, tags = emptyList(), notes = emptyList(), tasks = emptyList()) - - val errors = listOf( - runCatching { decoy.decrypt(ByteArray(1), "words".toCharArray()) }, - runCatching { decoy.import(empty, ImportMode.MERGE) }, - runCatching { decoy.createBackup() }, - ).map { (it.exceptionOrNull() as BackupException).error } - - assertEquals(List(3) { BackupError.IO }, errors) - } - - private companion object { - const val ROOT_LENGTH = 32 - } -} diff --git a/app/src/test/java/com/pasich/encly/data/database/NotesDaoTest.kt b/app/src/test/java/com/pasich/encly/data/database/NotesDaoTest.kt deleted file mode 100644 index 845e8153..00000000 --- a/app/src/test/java/com/pasich/encly/data/database/NotesDaoTest.kt +++ /dev/null @@ -1,206 +0,0 @@ -package com.pasich.encly.data.database - -import android.app.Application -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag -import com.pasich.encly.data.model.Tag -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -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.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** - * Where a note shows up, in the real Room schema (plain SQLite under Robolectric, not SQLCipher): - * the main list, the archive and the trash never overlap, and the new columns default to - * "not pinned, not archived, no colour". - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35], application = Application::class) -class NotesDaoTest { - private lateinit var db: AppDatabase - private val dao get() = db.notesDao() - - @Before - fun setUp() { - db = VaultSchema.install( - Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) - .allowMainThreadQueries(), - ).build() - } - - @After - fun tearDown() { - db.close() - } - - private suspend fun insert(title: String, tagId: Long? = null, block: Note.() -> Unit = {}): Long = - dao.insertNote(Note(title = title, tagId = tagId).apply(block)) - - private suspend fun titles(flow: Flow>) = flow.first().map { it.note.title }.sorted() - - @Test - fun aNewNoteIsNotPinnedNotArchivedAndHasNoColour() = runBlocking { - val id = insert("plain") - - val stored = checkNotNull(dao.getNoteById(id)) - - assertEquals(false, stored.isPinned) - assertEquals(false, stored.isArchived) - assertEquals("none", stored.color) - } - - @Test - fun theMainListLeavesOutArchivedAndTrashedNotes() = runBlocking { - insert("list") - insert("pinned") { isPinned = true } - insert("archived") { isArchived = true } - insert("trashed") { isTrash = true } - - assertEquals(listOf("list", "pinned"), titles(dao.getAllNotesWithTags())) - assertEquals(listOf("list", "pinned"), dao.getAllNotes().first().map { it.title }.sorted()) - } - - @Test - fun aTagFilterLeavesOutArchivedNotes() = runBlocking { - insert("kept", tagId = TAG) - insert("archived", tagId = TAG) { isArchived = true } - insert("other", tagId = OTHER_TAG) - - assertEquals(listOf("kept"), titles(dao.getNotesByTagId(TAG))) - } - - @Test - fun theArchiveHoldsArchivedNotesThatAreNotInTheTrash() = runBlocking { - insert("list") - insert("archived") { isArchived = true } - insert("archived and trashed") { - isArchived = true - isTrash = true - } - - assertEquals(listOf("archived"), titles(dao.getArchivedNotesWithTags())) - } - - @Test - fun aTrashedArchivedNoteIsInTheTrashAndComesBackToTheArchive() = runBlocking { - val id = insert("both") { isArchived = true } - - dao.updateNote(checkNotNull(dao.getNoteById(id)).copy(isTrash = true)) - assertEquals(listOf("both"), dao.getTrashNotes().first().map { it.title }) - assertTrue(titles(dao.getArchivedNotesWithTags()).isEmpty()) - - dao.updateNote(checkNotNull(dao.getNoteById(id)).copy(isTrash = false)) - assertTrue(dao.getTrashNotes().first().isEmpty()) - assertEquals(listOf("both"), titles(dao.getArchivedNotesWithTags())) - assertTrue(titles(dao.getAllNotesWithTags()).isEmpty()) - } - - @Test - fun theBackupSnapshotKeepsPinArchiveAndColour() = runBlocking { - insert("styled") { - isPinned = true - color = "teal" - } - - val stored = db.backupDao().allNotes().single() - - assertEquals(true, stored.isPinned) - assertEquals("teal", stored.color) - } - - @Test - fun aColourWrittenAfterATagChangeKeepsTheNewTag() = runBlocking { - db.tagsDao().addTags(listOf(Tag(id = TAG, nameTag = "X"), Tag(id = OTHER_TAG, nameTag = "Y"))) - val id = insert("note", tagId = TAG) - // What a stale sheet holds: the note as read in tag X's view. - val stale = checkNotNull(dao.getNoteById(id)) - - dao.updateTagId(id, OTHER_TAG) - dao.updateColor(stale.id, "teal") - - val stored = checkNotNull(dao.getNoteById(id)) - assertEquals(OTHER_TAG, stored.tagId) - assertEquals("teal", stored.color) - } - - @Test - fun everySingleColumnWriteLeavesTheOtherColumnsAlone() = runBlocking { - val id = insert("note", tagId = TAG) { - description = "d" - color = "red" - isPinned = true - } - val before = checkNotNull(dao.getNoteById(id)) - - dao.updateDescription(id, "new") - dao.updateColor(id, "blue") - assertEquals(before.copy(description = "new", color = "blue"), dao.getNoteById(id)) - dao.updateTagId(id, null) - assertEquals(before.copy(description = "new", color = "blue", tagId = null), dao.getNoteById(id)) - } - - @Test - fun archivingUnpinsInTheSameWriteAndUnarchivingKeepsThePinState() = runBlocking { - val id = insert("note") { isPinned = true } - - dao.updateArchived(id, true) - val archived = checkNotNull(dao.getNoteById(id)) - assertEquals(true, archived.isArchived) - assertEquals(false, archived.isPinned) - - dao.updateArchived(id, false) - assertEquals(false, checkNotNull(dao.getNoteById(id)).isPinned) - } - - @Test - fun anArchivedNoteCannotBePinnedButCanBeUnpinned() = runBlocking { - val id = insert("note") { isArchived = true } - - dao.updatePinned(id, true) - assertEquals(false, checkNotNull(dao.getNoteById(id)).isPinned) - - dao.updateArchived(id, false) - dao.updatePinned(id, true) - assertEquals(true, checkNotNull(dao.getNoteById(id)).isPinned) - dao.updatePinned(id, false) - assertEquals(false, checkNotNull(dao.getNoteById(id)).isPinned) - } - - @Test - fun aTagsArchivedNotesAreReadByTag() = runBlocking { - insert("live", tagId = TAG) - insert("old", tagId = TAG) { isArchived = true } - insert("trashed", tagId = TAG) { - isArchived = true - isTrash = true - } - insert("other", tagId = OTHER_TAG) { isArchived = true } - - assertEquals(listOf("old"), titles(dao.getArchivedNotesByTagId(TAG))) - } - - @Test - fun aNoteCanBeObservedByIdWithItsTag() = runBlocking { - db.tagsDao().addTags(listOf(Tag(id = TAG, nameTag = "X"))) - val id = insert("note", tagId = TAG) { isArchived = true } - - val live = checkNotNull(dao.observeNoteWithTag(id).first()) - - assertEquals("X", live.tag?.nameTag) - assertEquals(null, dao.observeNoteWithTag(999).first()) - } - - private companion object { - const val TAG = 1L - const val OTHER_TAG = 2L - } -} diff --git a/app/src/test/java/com/pasich/encly/data/database/VaultMigrationTest.kt b/app/src/test/java/com/pasich/encly/data/database/VaultMigrationTest.kt deleted file mode 100644 index c66e2f5e..00000000 --- a/app/src/test/java/com/pasich/encly/data/database/VaultMigrationTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.pasich.encly.data.database - -import android.app.Application -import android.content.Context -import androidx.room.Room -import androidx.sqlite.db.SupportSQLiteDatabase -import androidx.sqlite.db.SupportSQLiteOpenHelper -import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory -import androidx.test.core.app.ApplicationProvider -import com.google.gson.JsonParser -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File - -/** - * Upgrades a real version-4 vault file (the exported schema 4.json, with its triggers) through - * [VaultSchema], the way an existing install does, and lets Room validate the result against the - * current entities. Plain SQLite under Robolectric, not SQLCipher: it checks the schema, not - * encryption. The instrumented `AppDatabaseSchemaTest` covers the same on a device. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35], application = Application::class) -class VaultMigrationTest { - private val context: Context get() = ApplicationProvider.getApplicationContext() - private val name = "migration-test.db" - - @Before - fun setUp() { - context.deleteDatabase(name) - } - - @After - fun tearDown() { - context.deleteDatabase(name) - } - - /** - * The database of [version] as Room created it from its exported [schemaPath], with one note - * written the way that version wrote it ([noteSql] inserts it). - */ - private fun createVersion(version: Int, schemaPath: String, noteSql: String) { - val schema = JsonParser.parseString(File(schemaPath).readText()).asJsonObject.getAsJsonObject("database") - val statements = buildList { - schema.getAsJsonArray("entities").forEach { entity -> - val table = entity.asJsonObject.get("tableName").asString - add(entity.asJsonObject.get("createSql").asString.replace("\${TABLE_NAME}", table)) - entity.asJsonObject.getAsJsonArray("indices").forEach { index -> - add(index.asJsonObject.get("createSql").asString.replace("\${TABLE_NAME}", table)) - } - } - schema.getAsJsonArray("setupQueries").forEach { add(it.asString) } - } - val helper = FrameworkSQLiteOpenHelperFactory().create( - SupportSQLiteOpenHelper.Configuration.builder(context) - .name(name) - .callback( - object : SupportSQLiteOpenHelper.Callback(version) { - override fun onCreate(db: SupportSQLiteDatabase) { - statements.forEach(db::execSQL) - VaultSchema.createTriggers(db) - db.execSQL(noteSql) - } - - override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit - }, - ) - .build(), - ) - helper.writableDatabase.close() - helper.close() - } - - @Test - fun aVersion5VaultAsItWasPushedMigratesToTheCurrentVersion() = runBlocking { - createVersion( - 5, - SCHEMA_5, - "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid, " + - "isPinned, isArchived, color) " + - "VALUES (5, 'Old note', '[]', 'd', 20, 10, NULL, 1, 'old-uid', 1, 0, 'red')", - ) - - val db = VaultSchema.install(Room.databaseBuilder(context, AppDatabase::class.java, name)).build() - try { - val old = checkNotNull(db.notesDao().getNoteById(5)) - assertEquals("Old note", old.title) - assertEquals(true, old.isPinned) - assertEquals("red", old.color) - assertNull(old.textSize) - assertNull(old.lineSpacing) - assertEquals(DB_VERSION, db.openHelper.readableDatabase.version) - } finally { - db.close() - } - } - - @Test - fun aVersion4VaultOpensAtTheCurrentVersionAndKeepsItsNotes() = runBlocking { - createVersion( - 4, - SCHEMA_4, - "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + - "VALUES (5, 'Old note', '[]', 'd', 20, 10, NULL, 1, 'old-uid')", - ) - - val db = VaultSchema.install(Room.databaseBuilder(context, AppDatabase::class.java, name)).build() - try { - // Room validates the migrated tables against the entities when the first query opens it. - val old = checkNotNull(db.notesDao().getNoteById(5)) - assertEquals("Old note", old.title) - assertEquals(true, old.isTrash) - assertEquals("old-uid", old.uid) - assertEquals(false, old.isPinned) - assertEquals(false, old.isArchived) - assertEquals("none", old.color) - assertNull(old.textSize) - assertNull(old.lineSpacing) - assertEquals(DB_VERSION, db.openHelper.readableDatabase.version) - - // The uid trigger survived the upgrade, and the new columns default for a new note. - val id = db.notesDao().insertNote(com.pasich.encly.data.model.Note(title = "New")) - val fresh = checkNotNull(db.notesDao().getNoteById(id)) - assertEquals(32, fresh.uid.length) - assertEquals("none", fresh.color) - // The migrated old note is in the trash, so only the new one is in the list. - assertEquals(listOf("New"), db.notesDao().getAllNotes().first().map { it.title }) - assertEquals(listOf("Old note"), db.notesDao().getTrashNotes().first().map { it.title }) - } finally { - db.close() - } - } - - private companion object { - const val SCHEMA_5 = "schemas/com.pasich.encly.data.database.AppDatabase/5.json" - const val SCHEMA_4 = "schemas/com.pasich.encly.data.database.AppDatabase/4.json" - } -} diff --git a/app/src/test/java/com/pasich/encly/data/export/MarkdownDocumentsTest.kt b/app/src/test/java/com/pasich/encly/data/export/MarkdownDocumentsTest.kt deleted file mode 100644 index 4edf9e76..00000000 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownDocumentsTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.pasich.encly.data.export - -import android.app.Application -import android.content.ContentProvider -import android.content.ContentValues -import android.content.Context -import android.database.Cursor -import android.net.Uri -import android.os.Bundle -import android.os.ParcelFileDescriptor -import android.provider.DocumentsContract -import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File - -/** - * A failed export removes the empty document the save dialog created, and nothing else: a - * document that holds data (an existing file a provider handed back) is never deleted. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35], application = Application::class) -class MarkdownDocumentsTest { - private val context: Context get() = ApplicationProvider.getApplicationContext() - private val uri: Uri = DocumentsContract.buildDocumentUri(AUTHORITY, "doc") - - class RecordingProvider : ContentProvider() { - override fun onCreate() = true - override fun query(u: Uri, p: Array?, s: String?, a: Array?, o: String?): Cursor? = null - override fun getType(u: Uri): String? = null - override fun insert(u: Uri, v: ContentValues?): Uri? = null - override fun delete(u: Uri, s: String?, a: Array?) = 0 - override fun update(u: Uri, v: ContentValues?, s: String?, a: Array?) = 0 - - override fun openFile(u: Uri, mode: String): ParcelFileDescriptor { - val file = File.createTempFile("doc", null).apply { writeBytes(content) } - return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) - } - - override fun call(method: String, arg: String?, extras: Bundle?): Bundle? { - calls += method - return Bundle() - } - } - - private fun provider(documentContent: ByteArray) { - calls.clear() - content = documentContent - Robolectric.buildContentProvider(RecordingProvider::class.java).create(AUTHORITY) - } - - @Test - fun anEmptyDocumentIsDeleted() { - provider(ByteArray(0)) - - MarkdownDocuments(context).discard(uri) - - assertEquals(listOf("android:deleteDocument"), calls) - } - - @Test - fun aDocumentWithDataIsNeverDeleted() { - provider("keep me".toByteArray()) - - MarkdownDocuments(context).discard(uri) - - assertEquals(emptyList(), calls) - } - - private companion object { - const val AUTHORITY = "com.pasich.encly.test.documents" - val calls = mutableListOf() - var content = ByteArray(0) - } -} diff --git a/app/src/test/java/com/pasich/encly/data/export/MarkdownExportTest.kt b/app/src/test/java/com/pasich/encly/data/export/MarkdownExportTest.kt deleted file mode 100644 index 0344aeba..00000000 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownExportTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.pasich.encly.data.export - -import com.pasich.encly.core.serialization.BlockConverter -import com.pasich.encly.data.backup.VaultSnapshot -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.Tag -import com.pasich.encly.data.model.Task -import com.pasich.encly.domain.model.ItemListBlock -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.assertTrue -import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.util.zip.ZipInputStream - -class MarkdownExportTest { - - private val labels = MarkdownLabels( - untitled = "Untitled", - unreadable = "Unreadable", - tasks = "Tasks", - priorities = mapOf(0 to "Low", 1 to "Medium", 2 to "High"), - ) - - private fun body(vararg blocks: Block) = BlockConverter.blocksToJson(blocks.toList()) - private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) - - private fun note(id: Long, title: String, vararg blocks: Block, block: Note.() -> Unit = {}) = - Note(id = id, title = title, value = body(*blocks), date = 1_700_000_000_000, dateCreate = 1_690_000_000_000) - .apply(block) - - /** The zip's entries by name, in order. */ - private fun unzip(bytes: ByteArray): Map { - val entries = LinkedHashMap() - ZipInputStream(ByteArrayInputStream(bytes)).use { zip -> - generateSequence { zip.nextEntry }.forEach { entry -> - assertTrue("duplicate entry ${entry.name}", entry.name !in entries) - entries[entry.name] = String(zip.readBytes(), Charsets.UTF_8) - } - } - return entries - } - - private fun exportAll(snapshot: VaultSnapshot): Map { - val out = ByteArrayOutputStream() - MarkdownExport.writeAll(snapshot, labels, out) - return unzip(out.toByteArray()) - } - - @Test - fun theZipHoldsOneFilePerNoteAndATasksFile() { - val files = exportAll( - VaultSnapshot( - notes = listOf(note(1, "Groceries", text("milk")), note(2, "Ideas", text("one"))), - tags = emptyList(), - tasks = emptyList(), - ), - ) - - assertEquals(setOf("tasks.md", "Groceries.md", "Ideas.md"), files.keys) - assertTrue(files.getValue("Groceries.md").endsWith("# Groceries\n\nmilk\n")) - } - - @Test - fun archivedNotesGoToTheArchiveFolderAndTrashedOnesAreLeftOut() { - val files = exportAll( - VaultSnapshot( - notes = listOf( - note(1, "Live", text("a")), - note(2, "Old", text("b")) { isArchived = true }, - note(3, "Gone", text("c")) { isTrash = true }, - note(4, "Old and gone", text("d")) { - isArchived = true - isTrash = true - }, - ), - tags = emptyList(), - tasks = emptyList(), - ), - ) - - assertEquals(setOf("tasks.md", "Live.md", "archive/Old.md"), files.keys) - assertFalse(files.values.any { it.contains("Gone") }) - } - - @Test - fun notesWithTheSameTitleOrAReservedNameGetUniqueFiles() { - val files = exportAll( - VaultSnapshot( - notes = listOf( - note(1, "Plan", text("1")), - note(2, "plan", text("2")), - note(3, "tasks", text("3")), - note(4, "", text("4")), - note(5, "../../etc/passwd", text("5")), - note(6, "Plan", text("6")) { isArchived = true }, - ), - tags = emptyList(), - tasks = emptyList(), - ), - ) - - assertEquals( - setOf( - "tasks.md", - "Plan.md", - "plan (2).md", - "tasks (2).md", - "Untitled.md", - "etcpasswd.md", - "archive/Plan.md", - ), - files.keys, - ) - assertTrue(files.keys.none { it.startsWith("/") || ".." in it }) - } - - @Test - fun theTagsAreInTheFrontMatterAndHiddenTagsAreIncluded() { - val files = exportAll( - VaultSnapshot( - notes = listOf(note(1, "Secret plan", text("x")) { tagId = 7 }), - tags = listOf(Tag(id = 7, nameTag = "Private", isVisible = false)), - tasks = emptyList(), - ), - ) - - assertTrue(files.getValue("Secret plan.md").contains("tags: [\"Private\"]\n")) - } - - @Test - fun theTasksFileHasCheckboxesSubtasksAndPriorities() { - val tasks = listOf( - Task(id = 1, title = "Buy milk", description = "Two litres", priority = 2, categoryId = 7, position = 1), - Task(id = 2, title = "Call mom", isCompleted = true, priority = 0, position = 0), - Task(id = 3, title = "Pay rent", priority = 1, position = 2), - ) - val subtasks = listOf( - Subtask(id = 1, taskId = 1, title = "Oat", position = 0), - Subtask(id = 2, taskId = 1, title = "Soy", isCompleted = true, position = 1), - ) - val files = exportAll( - VaultSnapshot( - notes = emptyList(), - tags = listOf(Tag(id = 7, nameTag = "Home")), - tasks = tasks, - subtasks = subtasks, - ), - ) - - assertEquals( - "# Tasks\n\n" + - "- [ ] Buy milk (High, Home)\n" + - " > Two litres\n" + - " - [ ] Oat\n" + - " - [x] Soy\n" + - "- [ ] Pay rent (Medium)\n" + - "- [x] Call mom (Low)\n", - files.getValue("tasks.md"), - ) - } - - @Test - fun anEmptyVaultStillWritesAValidZip() { - val files = exportAll(VaultSnapshot(emptyList(), emptyList(), emptyList())) - - assertEquals(setOf("tasks.md"), files.keys) - assertEquals("# Tasks\n\n", files.getValue("tasks.md")) - } - - @Test - fun aSingleNoteIsOneMarkdownDocument() { - val snapshot = VaultSnapshot( - notes = listOf( - note(1, "One", text("first")), - note(2, "Two", text("second")), - note(3, "Trashed", text("x")) { isTrash = true }, - ), - tags = emptyList(), - tasks = emptyList(), - ) - val out = ByteArrayOutputStream() - - assertTrue(MarkdownExport.writeNote(snapshot, 2, labels, out)) - val markdown = out.toString(Charsets.UTF_8) - - assertTrue(markdown.startsWith("---\ntitle: \"Two\"\n")) - assertTrue(markdown.endsWith("# Two\n\nsecond\n")) - assertFalse(markdown.contains("first")) - assertFalse(MarkdownExport.writeNote(snapshot, 3, labels, ByteArrayOutputStream())) - assertFalse(MarkdownExport.writeNote(snapshot, 99, labels, ByteArrayOutputStream())) - } - - @Test - fun aManyNoteVaultKeepsEveryFileUnique() { - val notes = (1L..200L).map { note(it, "Same title", text("n$it")) } - val files = exportAll(VaultSnapshot(notes, emptyList(), emptyList())) - - assertEquals(201, files.size) - assertNotNull(files["Same title (200).md"]) - } - - @Test - fun theBlocksOfEveryTypeAreInTheFile() { - val items = MutableStateFlow(listOf(ItemListBlock("x", isCheck = true))) - val markdown = MarkdownNotes.render( - note(1, "All", text("t"), Block.ListBlock(items, BlockType.LIST_CHECK), Block.SeparatorBlock()), - null, - labels, - ) - - assertTrue(markdown.endsWith("# All\n\nt\n\n- [x] x\n\n---\n")) - } -} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt deleted file mode 100644 index f231cf9b..00000000 --- a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.pasich.encly.data.handoff - -import com.pasich.encly.data.handoff.HandoffFixtures.handoff -import com.pasich.encly.data.handoff.HandoffFixtures.handoffZip -import com.pasich.encly.data.handoff.HandoffFixtures.note -import com.pasich.encly.data.handoff.HandoffFixtures.read -import com.pasich.encly.data.handoff.HandoffFixtures.tag -import com.pasich.encly.data.handoff.HandoffFixtures.task -import com.pasich.encly.data.handoff.HandoffFixtures.zip -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.fail -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.InputStream - -class MyNotesHandoffReaderTest { - - @get:Rule - val temp = TemporaryFolder() - - private val dir: File get() = temp.root - - @Test - fun readsAValidHandoff() { - val parsed = read( - dir, - handoff( - tags = listOf(tag("t1", "Work")), - notes = listOf(note("n1", tag = "Work", isPinned = true, attachments = 2)), - tasks = listOf(task("k1", "Call")), - ), - ) - assertEquals(1, parsed.schema) - assertEquals("Work", parsed.tags.single().name) - assertEquals("Work", parsed.notes.single().tag) - assertEquals(2, parsed.notes.single().attachments) - assertEquals("Call", parsed.tasks.single().description) - } - - @Test - fun unknownKeysAreIgnoredAndOmittedNullsAreNull() { - val noteWithoutNulls = JsonObject(note("n1") - "valueJson" - "tag" + ("color" to JsonPrimitive("red"))) - val json = JsonObject(handoff(notes = listOf(noteWithoutNulls)) + ("future" to JsonPrimitive(true))) - val parsed = read(dir, json) - assertNull(parsed.notes.single().valueJson) - assertNull(parsed.notes.single().tag) - } - - @Test - fun aNewerSchemaAsksForAnUpdate() { - assertError(HandoffError.UNSUPPORTED_SCHEMA) { read(dir, handoff(schema = 2)) } - } - - @Test - fun aNewerSchemaIsReportedEvenIfItsShapeChanged() { - val v2 = buildJsonObject { - put("format", MyNotesHandoff.FORMAT) - put("schema", 2) - put("notes", "a different shape") - } - assertError(HandoffError.UNSUPPORTED_SCHEMA) { read(dir, v2) } - } - - @Test - fun wrongFormatOrSchemaIsInvalid() { - assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(format = "encly-backup")) } - assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(schema = 0)) } - assertError(HandoffError.INVALID_PAYLOAD) { read(dir, JsonObject(handoff() - "schema")) } - } - - @Test - fun malformedJsonIsInvalid() { - val broken = zip(dir, MyNotesHandoffReader.ENTRY_NAME to "{\"format\": \"mynotes-handoff\", ".toByteArray()) - assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(broken) } - } - - @Test - fun aMissingRequiredFieldOrWrongTypeIsInvalid() { - val untitled = JsonObject(note("n1") - "title") - assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(untitled))) } - val stringFlag = JsonObject(note("n1") + ("isTrash" to JsonPrimitive("yes"))) - assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(stringFlag))) } - } - - @Test - fun duplicateOrBlankIdsAndUnknownCategoriesAreInvalid() { - assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note("n1"), note("n1")))) } - assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note(" ")))) } - assertError(HandoffError.INVALID_PAYLOAD) { - read(dir, handoff(tasks = listOf(task("k1", "Call", categoryId = "missing")))) - } - assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note("n1", attachments = -1)))) } - } - - @Test - fun notAZipIsInvalid() { - val file = File(dir, "plain.zip").apply { writeText("not a zip at all") } - assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(file) } - } - - @Test - fun theZipMustHoldExactlyHandoffJson() { - val json = handoff().toString().toByteArray() - assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(zip(dir, "other.json" to json)) } - assertError(HandoffError.INVALID_PAYLOAD) { - MyNotesHandoffReader.read(zip(dir, MyNotesHandoffReader.ENTRY_NAME to json, "extra.bin" to byteArrayOf(1))) - } - assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(zip(dir)) } - assertError(HandoffError.INVALID_PAYLOAD) { - MyNotesHandoffReader.read(zip(dir, "../${MyNotesHandoffReader.ENTRY_NAME}" to json)) - } - } - - @Test - fun deeplyNestedUnknownKeysDoNotOverflow() { - val deep = "[".repeat(200_000) + "]".repeat(200_000) - val json = handoff().toString().replaceFirst("{", "{\"future\":$deep,") - val file = zip(dir, MyNotesHandoffReader.ENTRY_NAME to json.toByteArray()) - assertEquals(1, MyNotesHandoffReader.read(file).schema) - } - - @Test - fun aZipBombStopsAtTheUncompressedLimit() { - // A few KB compressed, 8 MB of spaces uncompressed: valid JSON padding, so only the limit stops it. - val padded = handoff().toString().replaceFirst("{", "{" + " ".repeat(8 * 1024 * 1024)) - val bomb = zip(dir, MyNotesHandoffReader.ENTRY_NAME to padded.toByteArray()) - val limits = HandoffLimits(maxJsonBytes = 1024L * 1024) - assertEquals(true, bomb.length() < limits.maxJsonBytes) - assertError(HandoffError.TOO_LARGE) { MyNotesHandoffReader.read(bomb, limits) } - } - - @Test - fun anOversizedZipIsRefusedBeforeReading() { - val file = handoffZip(dir, handoff(notes = listOf(note("n1")))) - assertError(HandoffError.TOO_LARGE) { MyNotesHandoffReader.read(file, HandoffLimits(maxZipBytes = 10)) } - } - - @Test - fun tooManyRecordsOrTooLongTextIsTooLarge() { - val three = handoff(notes = listOf(note("a"), note("b"), note("c"))) - assertError(HandoffError.TOO_LARGE) { - MyNotesHandoffReader.read(handoffZip(dir, three), HandoffLimits(maxRecords = 2)) - } - val long = handoff(notes = listOf(note("a", value = "x".repeat(101)))) - assertError(HandoffError.TOO_LARGE) { - MyNotesHandoffReader.read(handoffZip(dir, long), HandoffLimits(maxTextChars = 100)) - } - val longName = handoff(tags = listOf(tag("t", "n".repeat(11)))) - assertError(HandoffError.TOO_LARGE) { - MyNotesHandoffReader.read(handoffZip(dir, longName), HandoffLimits(maxNameChars = 10)) - } - } - - @Test - fun copyStopsAtTheZipLimit() { - val endless = object : InputStream() { - override fun read(): Int = 0 - } - assertError(HandoffError.TOO_LARGE) { - MyNotesHandoffReader.copyLimited(endless, ByteArrayOutputStream(), HandoffLimits(maxZipBytes = 4096)) - } - val out = ByteArrayOutputStream() - MyNotesHandoffReader.copyLimited(ByteArrayInputStream(ByteArray(4096)), out, HandoffLimits(maxZipBytes = 4096)) - assertEquals(4096, out.size()) - } -} - -internal fun assertError(expected: HandoffError, block: () -> Unit) { - try { - block() - fail("expected $expected") - } catch (e: HandoffException) { - assertEquals(expected, e.error) - } -} 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 deleted file mode 100644 index 8c658856..00000000 --- a/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.pasich.encly.data.importer - -import com.pasich.encly.data.importer.ImportFixtures.describe -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -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")) - } - - // --- 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 aNumberedListNeedsAtMostThreeDigitsInAPasteAndInAnImport() { - assertEquals(listOf("text:2024. Report"), pasted("2024. Report")) - assertEquals(listOf("number:[ ] one"), pasted("12. one")) - // Encly numbers items itself, so an import cannot keep a start number: a long number is text. - assertEquals(listOf("text:2024. Report"), blocks("2024. Report")) - assertEquals(listOf("number:[ ] one|[ ] two"), blocks("1. one\n2) two")) - } - - @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)) - } - - @Test - fun angleBracketTextThatIsNotHtmlIsKept() { - assertEquals( - listOf("text:Map and Dear , ad"), - blocks("Map and Dear , ad"), - ) - } - - @Test - fun realInlineTagsAreRemovedAndTheirTextStays() { - assertEquals( - listOf("text:x l\ny z"), - blocks("x
l
y z "), - ) - } - - @Test - fun aLongRunOfBracketsDoesNotHangTheWikiLinkMatch() { - val started = System.nanoTime() - MarkdownBlockMapper.toBlocks("[".repeat(200_000)) - MarkdownBlockMapper.toBlocks("[[a|".repeat(50_000)) - assertTrue("took ${(System.nanoTime() - started) / 1_000_000} ms", System.nanoTime() - started < 1_000_000_000L) - } - - @Test - fun aWikiLinkWithBracketsAroundItStillResolves() { - assertEquals(listOf("text:see alias and Name"), blocks("see [[Note|alias]] and [[Name]]")) - } - - @Test - fun anImportDropsABlankItemButAPasteKeepsIt() { - assertEquals(listOf("bullet:[ ] a|[ ] b"), blocks("- a\n- \n- b")) - assertEquals(listOf("bullet:[ ] a|[ ] |[ ] b"), pasted("- a\n- \n- b")) - } -} 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 deleted file mode 100644 index 4042e531..00000000 --- a/app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt +++ /dev/null @@ -1,160 +0,0 @@ -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() } - } - - @Test - fun anUnknownSignatureBetweenEntriesIsAnIoErrorNotTheEnd() { - val bytes = ImportFixtures.zipText("a.txt" to "a", "b.txt" to "b") - val second = bytes.indices.drop(1).first { at -> - at + 3 < bytes.size && bytes[at] == 0x50.toByte() && bytes[at + 1] == 0x4b.toByte() && - bytes[at + 2] == 3.toByte() && bytes[at + 3] == 4.toByte() - } - bytes[second + 2] = 9 - val reader = ZipStreamReader(bytes.inputStream()) - assertEquals("a.txt", reader.next()?.name) - assertThrows(IOException::class.java) { reader.next() } - } - - @Test - fun onlyTheCentralDirectoryAnEndRecordOrACleanEndFinishesAnArchive() { - fun ends(third: Int, fourth: Int): Boolean { - val bytes = byteArrayOf(0x50, 0x4b, third.toByte(), fourth.toByte()) + ByteArray(18) - return ZipStreamReader(bytes.inputStream()).next() == null - } - assertEquals(true, ends(0x01, 0x02)) // central directory header - assertEquals(true, ends(0x05, 0x06)) // end of central directory - assertEquals(true, ends(0x06, 0x06)) // ZIP64 end of central directory - assertEquals(true, ends(0x06, 0x08)) // archive extra data - assertNull(ZipStreamReader(ByteArray(0).inputStream()).next()) - } - - @Test - fun aNameThatIsNotUtf8IsReadAsCp437SoDifferentNamesStayDifferent() { - val out = ByteArrayOutputStream() - // Latin-1 name bytes and no UTF-8 flag: 0x82 and 0x83 are not valid UTF-8 on their own. - ZipOutputStream(out, Charsets.ISO_8859_1).use { zip -> - for (name in listOf("\u0082.md", "\u0083.md")) { - zip.putNextEntry(ZipEntry(name)) - zip.write(1) - zip.closeEntry() - } - } - assertEquals(listOf("\u00e9.md", "\u00e2.md"), readAll(out.toByteArray()).map { it.first }) - } - - @Test - fun aUtf8NameIsReadAsUtf8() { - val names = readAll(ImportFixtures.zipText("Привіт/нотатка.md" to "x")).map { it.first } - assertEquals(listOf("Привіт/нотатка.md"), names) - } -} 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 deleted file mode 100644 index 538e41d6..00000000 --- a/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -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 -import com.pasich.encly.data.database.dao.TasksDao -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.Tag -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -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` -import java.io.IOException - -/** Repositories report failures as a Result or a failing flow: never a sentinel 0/false. */ -class RepositoryImplTest { - private val notesDao = mock(NotesDao::class.java) - private val tagsDao = mock(TagsDao::class.java) - private val tasksDao = mock(TasksDao::class.java) - private val database = mock(AppDatabase::class.java).also { - `when`(it.notesDao()).thenReturn(notesDao) - `when`(it.tagsDao()).thenReturn(tagsDao) - `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 - } - } - - @Test - fun updatingZeroRowsIsAFailure() = runBlocking { - val note = Note(id = NOTE_ID) - `when`(notesDao.updateNote(note)).thenReturn(0) - - assertTrue(NotesRepositoryImpl(provider).updateNote(note).isFailure) - } - - @Test - fun updatingARowSucceeds() = runBlocking { - val note = Note(id = NOTE_ID) - `when`(notesDao.updateNote(note)).thenReturn(1) - - assertTrue(NotesRepositoryImpl(provider).updateNote(note).isSuccess) - } - - @Test - fun aStorageExceptionBecomesAFailureNotACrash() = runBlocking { - val note = Note(id = NOTE_ID) - `when`(notesDao.insertNote(note)).thenAnswer { throw IOException("disk") } - - assertTrue(NotesRepositoryImpl(provider).insertNote(note).exceptionOrNull() is IOException) - } - - @Test - fun aLockedVaultFailsTheWriteInsteadOfThrowing() = runBlocking { - locked = true - - 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) - val cancellation = CancellationException("cancelled") - runBlocking { `when`(notesDao.updateNote(note)).thenAnswer { throw cancellation } } - - val thrown = runCatching { runBlocking { NotesRepositoryImpl(provider).updateNote(note) } }.exceptionOrNull() - - assertSame(cancellation, thrown) - } - - @Test - fun aLockedVaultFailsInsideTheFlowWhereTheScreenHandlesIt() = runBlocking { - locked = true - // Requesting the flow must not throw; collecting it fails. - val flow = NotesRepositoryImpl(provider).getTrashNotes() - - assertTrue(runCatching { flow.first() }.exceptionOrNull() is IllegalStateException) - } - - @Test - fun deletingATagAlsoUntagsItsNotesInOneTransaction(): Unit = runBlocking { - val tag = Tag(id = TAG_ID, nameTag = "Work") - `when`(tagsDao.deleteTagDetachingNotes(tag)).thenReturn(1) - - assertTrue(TagsRepositoryImpl(provider).deleteTag(tag).isSuccess) - - verify(tagsDao).deleteTagDetachingNotes(tag) - verify(tagsDao, never()).deleteTag(tag) - } - - @Test - fun reorderingFailsUnlessEveryTagIsUpdated() = runBlocking { - val tags = listOf(Tag(id = 1, nameTag = "a"), Tag(id = 2, nameTag = "b")) - `when`(tagsDao.updateTags(tags)).thenReturn(1) - - assertTrue(TagsRepositoryImpl(provider).updateTags(tags).isFailure) - assertTrue(TagsRepositoryImpl(provider).updateTags(emptyList()).isSuccess) - } - - @Test - fun tagsComeInTheirDisplayOrder() = runBlocking { - `when`(tagsDao.getTags()).thenReturn( - flowOf(listOf(Tag(id = 1, nameTag = "b", position = 2), Tag(id = 2, nameTag = "a", position = 0))), - ) - - assertEquals(listOf(2L, 1L), TagsRepositoryImpl(provider).getTags().first().map { it.id }) - } - - private companion object { - const val NOTE_ID = 5L - const val TAG_ID = 8L - const val TASK_ID = 3L - } -} diff --git a/app/src/test/java/com/pasich/encly/domain/usecase/note/ObserveNotesUseCaseTest.kt b/app/src/test/java/com/pasich/encly/domain/usecase/note/ObserveNotesUseCaseTest.kt deleted file mode 100644 index 74af05f1..00000000 --- a/app/src/test/java/com/pasich/encly/domain/usecase/note/ObserveNotesUseCaseTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.pasich.encly.domain.usecase.note - -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.domain.enums.NoteSortOption -import com.pasich.encly.domain.model.NoteListItem -import com.pasich.encly.domain.repository.NotesRepository -import com.pasich.encly.testutil.TestNotesRepository -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.IOException - -@OptIn(ExperimentalCoroutinesApi::class) -class ObserveNotesUseCaseTest { - - @Test - fun aDatabaseReadFailureFailsTheFlowInsteadOfEmittingNoNotes() = runTest { - val failing = object : NotesRepository by TestNotesRepository() { - override fun getAllNotesWithTag(): Flow> = flow { throw IOException("vault closed") } - } - - val emitted = mutableListOf>() - val thrown = runCatching { - ObserveNotesUseCase(failing, StandardTestDispatcher(testScheduler))( - tagId = null, - sortOption = NoteSortOption.UPDATED_DESC, - ).toList(emitted) - }.exceptionOrNull() - - // The screen turns the failure into an error state (asLoadState); never an empty list. - assertTrue(thrown is IOException) - assertTrue(emitted.isEmpty()) - } - - @Test - fun allNotesLeaveOutHiddenTagsAndFollowTheSortOption() = runTest { - val hidden = Tag(id = 1, nameTag = "Private", isVisible = false) - val repository = TestNotesRepository().apply { - allNotesWithTags.value = listOf( - noteWithTag(id = 1, date = 10), - noteWithTag(id = 2, date = 30), - noteWithTag(id = 3, date = 20, tag = hidden), - ) - } - - val newestFirst = loaded(repository, NoteSortOption.UPDATED_DESC) - val oldestFirst = loaded(repository, NoteSortOption.UPDATED_ASC) - - assertEquals(listOf(2L, 1L), newestFirst.map { it.note.id }) - assertEquals(listOf(1L, 2L), oldestFirst.map { it.note.id }) - } - - @Test - fun pinnedNotesComeFirstWhateverTheSortOption() = runTest { - val repository = TestNotesRepository().apply { - allNotesWithTags.value = listOf( - noteWithTag(id = 1, date = 10), - noteWithTag(id = 2, date = 30), - noteWithTag(id = 3, date = 20, pinned = true), - noteWithTag(id = 4, date = 5, pinned = true), - ) - } - - assertEquals(listOf(3L, 4L, 2L, 1L), loaded(repository, NoteSortOption.UPDATED_DESC).map { it.note.id }) - assertEquals(listOf(4L, 3L, 1L, 2L), loaded(repository, NoteSortOption.UPDATED_ASC).map { it.note.id }) - } - - @Test - fun theArchiveIsReadFromItsOwnQueryAndLeavesOutHiddenTags() = runTest { - val hidden = Tag(id = 1, nameTag = "Private", isVisible = false) - val repository = TestNotesRepository().apply { - allNotesWithTags.value = listOf(noteWithTag(id = 1, date = 1)) - archivedNotesWithTags.value = listOf( - noteWithTag(id = 2, date = 10), - noteWithTag(id = 3, date = 20), - noteWithTag(id = 4, date = 30, tag = hidden), - ) - } - - val archived = ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))( - tagId = null, - sortOption = NoteSortOption.UPDATED_DESC, - archived = true, - ).first() - - assertEquals(listOf(3L, 2L), archived.map { it.note.id }) - } - - private fun archivedWithTag(id: Long, tag: Tag) = NoteWithTag( - Note(id = id, title = "a$id", date = id, tagId = tag.id, isArchived = true), - tag, - ) - - @Test - fun aHiddenTagsViewListsItsArchivedNotesAfterTheOthers() = runTest { - val hidden = Tag(id = 5, nameTag = "Private", isVisible = false) - val repository = TestNotesRepository().apply { - tagFlow(5).value = - listOf(noteWithTag(id = 1, date = 10, tag = hidden), noteWithTag(id = 2, date = 1, tag = hidden)) - archivedNotesWithTags.value = - listOf(archivedWithTag(3, hidden), archivedWithTag(4, Tag(id = 6, isVisible = true))) - } - - val items = ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))( - tagId = 5, - sortOption = NoteSortOption.UPDATED_DESC, - ).first() - - assertEquals(listOf(1L, 2L, 3L), items.map { it.note.id }) - } - - @Test - fun aVisibleTagsViewLeavesItsArchivedNotesToTheArchive() = runTest { - val visible = Tag(id = 5, nameTag = "Work", isVisible = true) - val repository = TestNotesRepository().apply { - tagFlow(5).value = listOf(noteWithTag(id = 1, date = 10, tag = visible)) - archivedNotesWithTags.value = listOf(archivedWithTag(3, visible)) - } - - val items = ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))( - tagId = 5, - sortOption = NoteSortOption.UPDATED_DESC, - ).first() - - assertEquals(listOf(1L), items.map { it.note.id }) - } - - @Test - fun oneUnreadableNoteDoesNotTakeTheListDown() = runTest { - val repository = TestNotesRepository().apply { - allNotesWithTags.value = listOf( - noteWithTag(id = 1, date = 2, value = """[{"blockType":"TEXT","text":"fine"}]"""), - noteWithTag(id = 2, date = 1, value = """[{"blockType":"FUTURE"}]"""), - ) - } - - val items = loaded(repository, NoteSortOption.UPDATED_DESC) - - assertEquals("fine", items[0].preview) - assertNull(items[1].preview) - } - - private suspend fun TestScope.loaded(repository: NotesRepository, sortOption: NoteSortOption): List = - ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))(null, sortOption).first() - - private fun noteWithTag(id: Long, date: Long, tag: Tag? = null, value: String = "", pinned: Boolean = false) = - NoteWithTag( - note = Note( - id = id, - title = "n$id", - value = value, - date = date, - dateCreate = date, - tagId = tag?.id, - isPinned = pinned, - ), - tag = tag, - ) -} 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 4187fff9..79abcacb 100644 --- a/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt +++ b/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt @@ -13,13 +13,21 @@ import javax.xml.parsers.DocumentBuilderFactory * of the default (English) resources, with the same format arguments, the plural quantities * its language needs, and be listed in res/xml/locales_config.xml and [AppLanguage]. * - * Lint's MissingTranslation (an error in this build) checks the first rule too; this test - * also covers format arguments, plural categories, unescaped apostrophes and the locale lists. + * The strings are the translations of `:core:resources` (Android format, converted to Compose + * resources by the build), so Android lint's MissingTranslation does not see them; this test is the + * only check of the keys, format arguments, plural categories, unescaped apostrophes and the locale + * lists. */ class TranslationCompletenessTest { - private val resDir = listOf(File("src/main/res"), File("app/src/main/res")) - .first { it.isDirectory } + private val root = listOf(File(".."), File(".")) + .first { File(it, "version.properties").isFile } + + /** The strings in Android's format: what Weblate translates; the build converts them to Compose resources. */ + private val resDir = File(root, "core/resources/src/commonMain/strings") + + /** What Android itself reads: the launcher label, and the languages of the system's per-app settings. */ + private val androidResDir = File(root, "app/src/main/res") /** Folders such as values-uk or values-pt-rBR; not values-night, values-v31, ... */ private val localeDirs: Map = resDir.listFiles().orEmpty() @@ -82,6 +90,13 @@ class TranslationCompletenessTest { } } + @Test + fun theLauncherLabelStaysInTheAndroidResources() { + val label = readElements(File(androidResDir, "values/manifest_strings.xml")) + .single { it.getAttribute("name") == "app_name" } + assertEquals("Encly", label.textContent) + } + @Test fun pluralsDefineTheCategoriesOfTheirLanguage() { val all = localeDirs.mapValues { parse(it.value) } + ("en" to defaults) @@ -116,7 +131,7 @@ class TranslationCompletenessTest { @Test fun localeConfigListsExactlyTheShippedLocales() { val config = DocumentBuilderFactory.newInstance().newDocumentBuilder() - .parse(File(resDir, "xml/locales_config.xml")) + .parse(File(androidResDir, "xml/locales_config.xml")) val nodes = config.getElementsByTagName("locale") val declared = (0 until nodes.length) .map { (nodes.item(it) as Element).getAttribute("android:name") } @@ -134,7 +149,7 @@ class TranslationCompletenessTest { @Test fun buildLocaleFiltersKeepExactlyTheShippedLocales() { - val buildFile = File(resDir, "../../../build.gradle.kts") + val buildFile = File(root, "app/build.gradle.kts") val filters = LOCALE_FILTERS.find(buildFile.readText()) ?.groupValues?.get(1) ?.let { list -> Regex("\"([^\"]+)\"").findAll(list).map { it.groupValues[1] }.toSet() } @@ -218,6 +233,7 @@ class TranslationCompletenessTest { "pt" to setOf("one", "many", "other"), "uk" to setOf("one", "few", "many", "other"), "pl" to setOf("one", "few", "many", "other"), + "ar" to setOf("zero", "one", "two", "few", "many", "other"), ) } } diff --git a/app/src/test/java/com/pasich/encly/platform/AndroidDocumentPickersTest.kt b/app/src/test/java/com/pasich/encly/platform/AndroidDocumentPickersTest.kt new file mode 100644 index 00000000..cd29926e --- /dev/null +++ b/app/src/test/java/com/pasich/encly/platform/AndroidDocumentPickersTest.kt @@ -0,0 +1,138 @@ +package com.pasich.encly.platform + +import android.app.Application +import android.content.ActivityNotFoundException +import android.net.Uri +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.core.app.ActivityOptionsCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The document dialogs hand back [DocumentRef]s, never URIs, and a device with no documents app is reported as + * [PickerUnavailableException], which the screens turn into a message. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class AndroidDocumentPickersTest { + @get:Rule + val rule = createComposeRule() + + /** The answer the next dialog gives; null makes it throw ActivityNotFoundException instead. */ + private var answer: Any? = null + private var unavailable = false + + private val registry = object : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + if (unavailable) throw ActivityNotFoundException() + @Suppress("UNCHECKED_CAST") + dispatchResult(requestCode, answer as O) + } + } + + private val owner = object : ActivityResultRegistryOwner { + override val activityResultRegistry: ActivityResultRegistry = registry + } + + private fun uri(text: String): Uri = Uri.parse(text) + + @Test + fun aCreatedDocumentIsReturnedAsARef() { + var picked: DocumentRef? = null + var launcher: PickerLauncher? = null + setContent { launcher = AndroidDocumentPickers.rememberCreateDocument("text/plain") { picked = it } } + + answer = uri("content://docs/new.txt") + rule.runOnIdle { checkNotNull(launcher).launch("new.txt") } + rule.waitForIdle() + + assertEquals(DocumentRef("content://docs/new.txt"), picked) + } + + @Test + fun anOpenedDocumentIsReturnedAndACancelIsNull() { + var picked: DocumentRef? = DocumentRef("before") + var launcher: PickerLauncher>? = null + setContent { launcher = AndroidDocumentPickers.rememberOpenDocument { picked = it } } + + answer = uri("content://docs/a.md") + rule.runOnIdle { checkNotNull(launcher).launch(arrayOf("*/*")) } + rule.waitForIdle() + assertEquals(DocumentRef("content://docs/a.md"), picked) + + answer = null + rule.runOnIdle { checkNotNull(launcher).launch(arrayOf("*/*")) } + rule.waitForIdle() + assertEquals(null, picked) + } + + @Test + fun severalOpenedDocumentsAreReturnedInOrder() { + var picked: List = emptyList() + var launcher: PickerLauncher>? = null + setContent { launcher = AndroidDocumentPickers.rememberOpenDocuments { picked = it } } + + answer = listOf(uri("content://docs/a.md"), uri("content://docs/b.md")) + rule.runOnIdle { checkNotNull(launcher).launch(arrayOf("*/*")) } + rule.waitForIdle() + + assertEquals(listOf(DocumentRef("content://docs/a.md"), DocumentRef("content://docs/b.md")), picked) + } + + @Test + fun aChosenFolderIsReturnedAsARef() { + var picked: DocumentRef? = null + var launcher: PickerLauncher? = null + setContent { launcher = AndroidDocumentPickers.rememberOpenFolder { picked = it } } + + answer = uri("content://docs/tree/root") + rule.runOnIdle { checkNotNull(launcher).launch(Unit) } + rule.waitForIdle() + + assertEquals(DocumentRef("content://docs/tree/root"), picked) + } + + @Test + fun noDocumentsAppIsPickerUnavailableForEveryDialog() { + var create: PickerLauncher? = null + var open: PickerLauncher>? = null + var several: PickerLauncher>? = null + var folder: PickerLauncher? = null + setContent { + create = AndroidDocumentPickers.rememberCreateDocument("text/plain") {} + open = AndroidDocumentPickers.rememberOpenDocument {} + several = AndroidDocumentPickers.rememberOpenDocuments {} + folder = AndroidDocumentPickers.rememberOpenFolder {} + } + unavailable = true + + rule.runOnIdle { + assertThrows(PickerUnavailableException::class.java) { checkNotNull(create).launch("a.txt") } + assertThrows(PickerUnavailableException::class.java) { checkNotNull(open).launch(arrayOf("*/*")) } + assertThrows(PickerUnavailableException::class.java) { checkNotNull(several).launch(arrayOf("*/*")) } + assertThrows(PickerUnavailableException::class.java) { checkNotNull(folder).launch(Unit) } + } + } + + private fun setContent(content: @androidx.compose.runtime.Composable () -> Unit) { + rule.setContent { + CompositionLocalProvider(LocalActivityResultRegistryOwner provides owner) { content() } + } + rule.waitForIdle() + } +} diff --git a/app/src/test/java/com/pasich/encly/platform/AndroidDocumentsTest.kt b/app/src/test/java/com/pasich/encly/platform/AndroidDocumentsTest.kt new file mode 100644 index 00000000..173a0034 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/platform/AndroidDocumentsTest.kt @@ -0,0 +1,168 @@ +package com.pasich.encly.platform + +import android.app.Application +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Bundle +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import androidx.test.core.app.ApplicationProvider +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.readByteArray +import kotlinx.io.writeString +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 org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * The Android side of the document access: [AndroidDocuments] over a content provider. The logic above it + * (backup, export, import) knows no `SecurityException`: a provider that refuses a document reaches it as an + * [IOException], and a missing stream as null. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class AndroidDocumentsTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val documents get() = AndroidDocuments(context) + + class TestProvider : ContentProvider() { + override fun onCreate() = true + + override fun query(u: Uri, p: Array?, s: String?, a: Array?, o: String?): Cursor? = when { + u.lastPathSegment == "unnamed" -> null + + u.lastPathSegment == "refused-name" -> throw SecurityException("not granted") + + p?.contains(DocumentsContract.Document.COLUMN_DOCUMENT_ID) == true -> MatrixCursor(p).apply { + addRow(arrayOf("a", "a.md", "text/markdown")) + addRow(arrayOf("dir", "folder", DocumentsContract.Document.MIME_TYPE_DIR)) + addRow(arrayOf("c", "c.md", "text/markdown")) + } + + p?.contains(OpenableColumns.DISPLAY_NAME) == true -> MatrixCursor(p).apply { addRow(arrayOf("Plan.md")) } + + else -> null + } + + override fun getType(u: Uri): String? = null + override fun insert(u: Uri, v: ContentValues?): Uri? = null + override fun delete(u: Uri, s: String?, a: Array?) = 0 + override fun update(u: Uri, v: ContentValues?, s: String?, a: Array?) = 0 + + override fun openFile(u: Uri, mode: String): ParcelFileDescriptor { + if (u.toString().contains("denied")) throw SecurityException("not granted") + if (u.toString().contains("missing")) throw java.io.FileNotFoundException("gone") + val file = if (mode.startsWith("w")) { + written.also { it.writeBytes(ByteArray(0)) } + } else { + File.createTempFile("doc", null).apply { writeBytes("hello".toByteArray()) } + } + val flags = if (mode.startsWith("w")) { + ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_TRUNCATE + } else { + ParcelFileDescriptor.MODE_READ_ONLY + } + return ParcelFileDescriptor.open(file, flags) + } + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle? { + calls += method + return Bundle() + } + } + + private fun provider() { + calls.clear() + Robolectric.buildContentProvider(TestProvider::class.java).create(AUTHORITY) + } + + private fun ref(name: String) = DocumentRef("content://$AUTHORITY/$name") + + private fun RawSource.text(): String = Buffer().also { buffer -> use { it.readAtMostTo(buffer, Long.MAX_VALUE) } } + .readByteArray().decodeToString() + + @Test + fun aGrantedDocumentIsReadThroughTheStream() { + provider() + + assertEquals("hello", checkNotNull(documents.openRead(ref("granted"))).text()) + } + + @Test + fun aRefusedDocumentIsAnIoErrorWithoutTheProvidersMessage() { + provider() + + val failure = assertThrows(IOException::class.java) { documents.openRead(ref("denied")) } + assertTrue(failure.message.orEmpty().none { it == '/' }) + assertThrows(IOException::class.java) { documents.openWrite(ref("denied")) } + } + + @Test + fun aDocumentWrittenToIsReplacedByWhatWasWritten() { + provider() + + documents.openWrite(ref("granted"))!!.let { sink -> + val buffer = Buffer().apply { writeString("saved") } + sink.write(buffer, buffer.size) + sink.close() + } + + assertEquals("saved", written.readText()) + } + + @Test + fun theDisplayNameIsTheProvidersAndNullWhenItHasNoneOrRefuses() { + provider() + + assertEquals("Plan.md", documents.displayName(ref("named"))) + assertNull(documents.displayName(ref("unnamed"))) + assertNull(documents.displayName(ref("refused-name"))) + } + + @Test + fun deletingAsksTheProviderToDeleteTheDocument() { + provider() + + documents.delete(DocumentRef(DocumentsContract.buildDocumentUri(AUTHORITY, "doc").toString())) + + assertEquals(listOf("android:deleteDocument"), calls) + } + + @Test + fun aFolderIsListedAndItsFilesOpenedByTheirIds() { + provider() + val tree = documents.openTree( + DocumentRef(DocumentsContract.buildTreeDocumentUri(AUTHORITY, "root").toString()), + 2, + ) + + val children = tree.children(tree.rootId) + + // Capped at the entry limit. + assertEquals(listOf("a.md", "folder"), children.map { it.name }) + assertEquals(listOf(false, true), children.map { it.isDirectory }) + assertEquals("hello", checkNotNull(tree.open("a")).text()) + } + + private companion object { + const val AUTHORITY = "com.pasich.encly.test.documents" + val calls = mutableListOf() + val written: File = File.createTempFile("written", null) + + fun File.readText() = readBytes().decodeToString() + } +} diff --git a/app/src/test/java/com/pasich/encly/platform/AndroidHostProvideTest.kt b/app/src/test/java/com/pasich/encly/platform/AndroidHostProvideTest.kt new file mode 100644 index 00000000..ae3c310b --- /dev/null +++ b/app/src/test/java/com/pasich/encly/platform/AndroidHostProvideTest.kt @@ -0,0 +1,208 @@ +package com.pasich.encly.platform + +import android.app.Application +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.R +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.security.ActivityBiometricHost +import com.pasich.encly.core.security.ScreenshotAllowance +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * [AndroidHostServices.Provide] gives the shared UI, under one Activity, every service it asks for through a + * `Local...` value: the Activity's window, links and biometric host, and the app-wide toasts, clipboard, language + * controller, build facts, document dialogs and ViewModel factory. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class AndroidHostProvideTest { + @get:Rule + val rule = createEmptyComposeRule() + + private var scenario: ActivityScenario? = null + private val factory = mock(MetroViewModelFactory::class.java) + private val allowance = ScreenshotAllowance() + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + shadowOf(context.packageManager).addOrUpdateActivity( + ActivityInfo().apply { + name = ProvideHostActivity::class.java.name + packageName = context.packageName + }, + ) + ProvideHostActivity.captured = null + ProvideHostActivity.services = AndroidHostServices( + toaster = FakeToaster, + clipboard = FakeClipboard, + locales = FakeLocales, + info = AppInfo.Default, + viewModelFactory = factory, + screenshots = allowance, + ) + } + + @After + fun tearDown() { + scenario?.close() + ProvideHostActivity.services = null + } + + @Test + fun everyLocalIsTheServiceOfThisActivityOrTheApp() { + val launched = ActivityScenario.launch(ProvideHostActivity::class.java).also { scenario = it } + rule.waitForIdle() + var activity: ProvideHostActivity? = null + launched.onActivity { activity = it } + + val seen = checkNotNull(ProvideHostActivity.captured) + assertTrue(seen.window is AndroidAppWindow) + assertTrue(seen.links is AndroidExternalLinks) + assertSame(FakeToaster, seen.toaster) + assertSame(FakeClipboard, seen.clipboard) + assertSame(FakeLocales, seen.locales) + assertSame(AppInfo.Default, seen.info) + assertSame(AndroidDocumentPickers, seen.pickers) + assertSame(factory, seen.factory) + val host = checkNotNull(activity) + assertEquals(ScreenBiometricHost(ActivityBiometricHost(host), host), seen.biometric) + assertSame(allowance, seen.screenshots) + } + + @Test + fun theWindowIsCapturableOnlyWhileScreenshotsAreAllowedAndNoSecretIsShowing() { + val launched = ActivityScenario.launch(ProvideHostActivity::class.java).also { scenario = it } + rule.waitForIdle() + var activity: ProvideHostActivity? = null + launched.onActivity { activity = it } + val window = checkNotNull(activity).window + fun secure() = window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0 + + // Protected from the first frame, and while nothing is allowed. + assertTrue(secure()) + + rule.runOnIdle { allowance.allow() } + assertFalse(secure()) + + // A PIN or recovery-words screen protects the window again, and gives it back when it goes. + val release = rule.runOnIdle { allowance.protect() } + assertTrue(secure()) + rule.runOnIdle { release() } + assertFalse(secure()) + + // The theme re-asserts the protection on every frame; it must not undo the allowance. + rule.runOnIdle { checkNotNull(ProvideHostActivity.captured).window.enforceSecure() } + assertFalse(secure()) + + // Every lock ends it. + rule.runOnIdle { allowance.revoke() } + assertTrue(secure()) + } + + @Test + fun anAllowedWindowIsProtectedAgainOnceTheActivityLeavesTheForeground() { + val launched = ActivityScenario.launch(ProvideHostActivity::class.java).also { scenario = it } + rule.waitForIdle() + var activity: ProvideHostActivity? = null + launched.onActivity { activity = it } + val window = checkNotNull(activity).window + fun secure() = window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0 + rule.runOnIdle { allowance.allow() } + assertFalse(secure()) + + launched.moveToState(Lifecycle.State.STARTED) + assertTrue(secure()) + + launched.moveToState(Lifecycle.State.RESUMED) + assertFalse(secure()) + } +} + +/** What the content under [AndroidHostServices.Provide] read from the composition. */ +class SeenServices( + val window: AppWindow, + val links: ExternalLinks, + val toaster: Toaster, + val clipboard: SensitiveClipboard, + val locales: AppLocaleController, + val info: AppInfo, + val pickers: DocumentPickers, + val biometric: ScreenBiometricHost?, + val factory: MetroViewModelFactory?, + val screenshots: ScreenshotAllowance?, +) + +class ProvideHostActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(R.style.Theme_Encly) + super.onCreate(savedInstanceState) + val host = checkNotNull(services) + setContent { + host.Provide(this, strictKeyboard = false) { Probe() } + } + } + + @Composable + private fun Probe() { + captured = SeenServices( + window = LocalAppWindow.current, + links = LocalExternalLinks.current, + toaster = LocalToaster.current, + clipboard = LocalSensitiveClipboard.current, + locales = LocalAppLocales.current, + info = LocalAppInfo.current, + pickers = LocalDocumentPickers.current, + biometric = LocalBiometricHost.current, + factory = LocalMetroViewModelFactory.current, + screenshots = LocalScreenshotAllowance.current, + ) + } + + companion object { + var services: AndroidHostServices? = null + var captured: SeenServices? = null + } +} + +private object FakeToaster : Toaster { + override fun show(text: org.jetbrains.compose.resources.StringResource, duration: ToastDuration, vararg args: Any) = + Unit +} + +private object FakeClipboard : SensitiveClipboard { + override val systemConfirmsCopy = true + + override fun copy(label: String, text: String) = true + + override fun clear() = Unit +} + +private object FakeLocales : AppLocaleController { + override fun current() = AppLanguage.SYSTEM + + override fun apply(language: AppLanguage) = Unit +} diff --git a/app/src/test/java/com/pasich/encly/platform/AndroidHostServicesTest.kt b/app/src/test/java/com/pasich/encly/platform/AndroidHostServicesTest.kt new file mode 100644 index 00000000..20696a39 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/platform/AndroidHostServicesTest.kt @@ -0,0 +1,243 @@ +package com.pasich.encly.platform + +import android.app.Activity +import android.app.Application +import android.content.ActivityNotFoundException +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.drawable.ColorDrawable +import android.os.Looper +import android.view.WindowManager +import android.widget.Toast +import androidx.core.view.WindowCompat +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.BuildConfig +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowToast + +/** The Android implementations of the platform interfaces `:shared` declares (all but the document ones). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class AndroidHostServicesTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @After + fun resetLanguage() { + AndroidAppLocaleController().apply(AppLanguage.SYSTEM) + } + + // --- links --------------------------------------------------------------------------- + + @Test + fun aUrlIsOpenedInTheDefaultAppOrThroughTheChooser() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val links = AndroidExternalLinks(activity) + + assertTrue(links.openUrl("https://example.org/page")) + val view = shadowOf(activity).nextStartedActivity + assertEquals(Intent.ACTION_VIEW, view.action) + assertEquals("https://example.org/page", view.data.toString()) + + assertTrue(links.openUrl("https://example.org/page", chooser = true)) + assertEquals(Intent.ACTION_CHOOSER, shadowOf(activity).nextStartedActivity.action) + } + + @Test + fun noAppToOpenTheUrlIsFalseNotACrash() { + val host = mock(Context::class.java) + doThrow(ActivityNotFoundException()).`when`(host).startActivity(any(Intent::class.java)) + + assertFalse(AndroidExternalLinks(host).openUrl("https://example.org")) + assertFalse(AndroidExternalLinks(host).writeMail("dev@example.org")) + } + + @Test + fun aSystemRefusalIsFalseNotACrash() { + val host = mock(Context::class.java) + doThrow(SecurityException("not allowed")).`when`(host).startActivity(any(Intent::class.java)) + + assertFalse(AndroidExternalLinks(host).openUrl("https://example.org", chooser = true)) + } + + @Test + fun anEmailIsStartedAsASendToIntent() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + assertTrue(AndroidExternalLinks(activity).writeMail("dev@example.org")) + + val intent = shadowOf(activity).nextStartedActivity + assertEquals(Intent.ACTION_SENDTO, intent.action) + assertEquals("mailto:dev@example.org", intent.data.toString()) + } + + // --- toasts -------------------------------------------------------------------------- + + @Test + fun aToastShowsTheResourceInTheInAppLanguageForTheChosenTime() { + AndroidToaster(context).show(Res.string.cancel, ToastDuration.LONG) + waitForToast() + + assertEquals("Cancel", ShadowToast.getTextOfLatestToast()) + assertEquals(Toast.LENGTH_LONG, ShadowToast.getLatestToast().duration) + + ShadowToast.reset() + AndroidToaster(context).show(Res.string.cancel, ToastDuration.SHORT) + waitForToast() + + assertEquals(Toast.LENGTH_SHORT, ShadowToast.getLatestToast().duration) + } + + /** The text is a Compose resource, read off the main thread: let it arrive. */ + private fun waitForToast() { + val deadline = System.currentTimeMillis() + TOAST_WAIT_MS + while (ShadowToast.getLatestToast() == null && System.currentTimeMillis() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(POLL_MS) + } + assertNotNull("no toast was shown", ShadowToast.getLatestToast()) + } + + // --- clipboard ----------------------------------------------------------------------- + + @Test + fun theClipboardCopiesSensitiveAndClearsOnRequest() { + val clipboard = AndroidSensitiveClipboard(context) + val manager = context.getSystemService(ClipboardManager::class.java) + + assertTrue(clipboard.copy("label", "secret")) + + val clip = checkNotNull(manager.primaryClip) + assertEquals("secret", clip.getItemAt(0).text.toString()) + assertTrue(clip.description.extras.getBoolean(ClipDescription.EXTRA_IS_SENSITIVE)) + + clipboard.clear() + assertNull(manager.primaryClip) + } + + @Test + @Config(sdk = [35]) + fun newerAndroidConfirmsACopyItself() { + assertTrue(AndroidSensitiveClipboard(context).systemConfirmsCopy) + } + + @Test + @Config(sdk = [32]) + fun olderAndroidShowsNoConfirmationOfItsOwn() { + assertFalse(AndroidSensitiveClipboard(context).systemConfirmsCopy) + } + + // --- the rest of the services ------------------------------------------------------------ + + @Test + @Config(sdk = [32]) + fun theInAppLanguageIsSetAndReadBackThroughAppCompat() { + val locales = AndroidAppLocaleController() + + locales.apply(AppLanguage.GERMAN) + assertEquals(AppLanguage.GERMAN, locales.current()) + + locales.apply(AppLanguage.SYSTEM) + assertEquals(AppLanguage.SYSTEM, locales.current()) + } + + @Test + fun theBuildFactsComeFromTheBuild() { + val info = AndroidAppInfo(context) + + assertTrue(info.appName.startsWith("Encly")) + assertEquals(BuildConfig.VERSION_NAME, info.versionName) + assertEquals(BuildConfig.DONATIONS_ENABLED, info.donationsEnabled) + assertEquals(BuildConfig.STORE_RATING_ENABLED, info.storeRatingEnabled) + assertEquals("market://details?id=${context.packageName}", info.storePageLinks.first()) + assertTrue(info.storePageLinks.last().startsWith("https://play.google.com/")) + } + + @Test + fun theCacheDirectoryIsTheAppsCache() { + assertEquals(context.cacheDir.path, AndroidCacheDirectory(context).path.toString()) + } + + @Test + @Config(sdk = [31]) + fun dynamicColoursNeedAndroid12() { + assertTrue(AndroidDeviceCapabilities().supportsDynamicColors()) + } + + @Test + @Config(sdk = [30]) + fun noDynamicColoursBeforeAndroid12() { + assertFalse(AndroidDeviceCapabilities().supportsDynamicColors()) + } + + // --- the window ---------------------------------------------------------------------- + + @Test + fun theWindowIsMadeSecureLightBarredAndPaintedOnRequest() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val window = AndroidAppWindow(activity, activity.window.decorView) + + window.enforceSecure() + window.setLightStatusBar(light = true) + window.setBackgroundColor(0xFF112233.toInt()) + window.hideKeyboard() + window.showKeyboard() + + assertTrue(activity.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0) + val controller = WindowCompat.getInsetsController(activity.window, activity.window.decorView) + assertTrue(controller.isAppearanceLightStatusBars) + assertEquals(0xFF112233.toInt(), (activity.window.decorView.background as ColorDrawable).color) + assertFalse(window.isChangingConfigurations) + } + + @Test + fun theWindowIsOpenToCaptureOnlyWhileAllowedAndInFront() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val window = AndroidAppWindow(activity, activity.window.decorView) + fun secure() = activity.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0 + + window.enforceSecure() + assertTrue(secure()) + + // Not in front yet (not resumed): still protected. + window.setCaptureAllowed(true) + assertTrue(secure()) + window.setInForeground(true) + assertFalse(secure()) + // The theme asserts the protection each frame; an allowance stays. + window.enforceSecure() + assertFalse(secure()) + + // Out of the foreground the recents snapshot must find it protected again. + window.setInForeground(false) + assertTrue(secure()) + window.setInForeground(true) + assertFalse(secure()) + + window.setCaptureAllowed(false) + assertTrue(secure()) + } + + private companion object { + const val TOAST_WAIT_MS = 5_000L + const val POLL_MS = 20L + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/components/KeyboardPrivacyTest.kt b/app/src/test/java/com/pasich/encly/platform/KeyboardPrivacyTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/components/KeyboardPrivacyTest.kt rename to app/src/test/java/com/pasich/encly/platform/KeyboardPrivacyTest.kt index 727114f2..668d5030 100644 --- a/app/src/test/java/com/pasich/encly/presentation/components/KeyboardPrivacyTest.kt +++ b/app/src/test/java/com/pasich/encly/platform/KeyboardPrivacyTest.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.components +package com.pasich.encly.platform import android.app.Application import android.text.InputType diff --git a/app/src/test/java/com/pasich/encly/presentation/components/SensitiveClipTest.kt b/app/src/test/java/com/pasich/encly/platform/SensitiveClipTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/components/SensitiveClipTest.kt rename to app/src/test/java/com/pasich/encly/platform/SensitiveClipTest.kt index 89032990..22d3c8e8 100644 --- a/app/src/test/java/com/pasich/encly/presentation/components/SensitiveClipTest.kt +++ b/app/src/test/java/com/pasich/encly/platform/SensitiveClipTest.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.components +package com.pasich.encly.platform import android.app.Application import android.content.ClipData diff --git a/app/src/test/java/com/pasich/encly/presentation/designsystem/NoteSpacingTest.kt b/app/src/test/java/com/pasich/encly/presentation/designsystem/NoteSpacingTest.kt deleted file mode 100644 index 2a8658b3..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/designsystem/NoteSpacingTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.pasich.encly.presentation.designsystem - -import androidx.compose.ui.unit.sp -import com.pasich.encly.domain.model.NoteLineSpacing -import org.junit.Assert.assertEquals -import org.junit.Test - -class NoteSpacingTest { - - private val normal = 24.sp - - @Test - fun theDefaultSizeKeepsTheThemeLineHeight() { - assertEquals(24f, NoteLineSpacing.DEFAULT.lineHeight(16.sp, normal).value, 0.01f) - assertEquals(30f, NoteLineSpacing.COMFORTABLE.lineHeight(16.sp, normal).value, 0.01f) - assertEquals(16f, NoteLineSpacing.COMPACT.lineHeight(16.sp, normal).value, 0.01f) - } - - @Test - fun smallTextKeepsTheThemeLineHeight() { - assertEquals(24f, NoteLineSpacing.DEFAULT.lineHeight(12.sp, normal).value, 0.01f) - } - - @Test - fun largeTextGrowsItsLineWithIt() { - assertEquals(48f, NoteLineSpacing.DEFAULT.lineHeight(32.sp, normal).value, 0.01f) - assertEquals(60f, NoteLineSpacing.COMFORTABLE.lineHeight(32.sp, normal).value, 0.01f) - assertEquals(32f, NoteLineSpacing.COMPACT.lineHeight(32.sp, normal).value, 0.01f) - } - - @Test - fun aLineIsNeverShorterThanItsTextExceptWhenCompact() { - for (size in 10..32) { - assertEquals(true, NoteLineSpacing.DEFAULT.lineHeight(size.sp, normal).value >= size * 1.5f - 0.01f) - } - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/dialogs/BlockSheetsTest.kt b/app/src/test/java/com/pasich/encly/presentation/dialogs/BlockSheetsTest.kt deleted file mode 100644 index acf692eb..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/dialogs/BlockSheetsTest.kt +++ /dev/null @@ -1,202 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.pasich.encly.R -import com.pasich.encly.domain.model.LinkDataBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.dialogs.blocks.ActionBlockDialog -import com.pasich.encly.presentation.dialogs.blocks.ActionLinkBottomSheet -import com.pasich.encly.presentation.dialogs.blocks.ActionOtherBottomSheet -import com.pasich.encly.presentation.dialogs.blocks.SettingsBlockDialog -import com.pasich.encly.presentation.dialogs.blocks.describe -import com.pasich.encly.ui.screens.ComposeScreenTest -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -/** What the block sheets offer, what they hide when the note is read-only, and what each row reports. */ -@OptIn(ExperimentalMaterial3Api::class) -class BlockSheetsTest : ComposeScreenTest() { - private val actions = mutableListOf() - private var dismissed = 0 - - private fun linkBlock(url: String, isError: Boolean = false) = - Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "Example", url = url, isError = isError))) - - private fun showOther(settings: SettingsBlockDialog) = setScreen { - ActionOtherBottomSheet( - settings = settings, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - onAction = { actions += it }, - onDismiss = { dismissed++ }, - ) - } - - private fun showLink(settings: SettingsBlockDialog) = setScreen { - ActionLinkBottomSheet( - settings = settings, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - onAction = { actions += it }, - onDismiss = { dismissed++ }, - ) - } - - private fun shown(id: Int) = countText(str(id)) > 0 - - // --- the block type names ------------------------------------------------------------ - - @Test - fun everyBlockTypeHasItsOwnName() { - val names = listOf( - Block.TextBlock() to R.string.text, - Block.QuoteBlock() to R.string.quote, - Block.ListBlock(blockType = BlockType.LIST_BULLET) to R.string.block_list, - Block.HBlock(blockType = BlockType.H1) to R.string.block_heading, - Block.LinkBlock() to R.string.block_link, - Block.SeparatorBlock() to R.string.block_separator, - ) - - names.forEach { (block, name) -> assertEquals(block::class.simpleName, name, block.describe()) } - assertEquals(names.size, names.map { it.second }.toSet().size) - } - - // --- the sheet for any other block ------------------------------------------------------ - - @Test - fun aHiddenSheetShowsNothing() { - showOther(SettingsBlockDialog(Block.QuoteBlock(), isBottomSheetVisible = false)) - - assertTrue(!shown(R.string.block_move_up) && !shown(R.string.quote)) - } - - @Test - fun aVisibleSheetNamesTheBlockAndOffersTheEditRows() { - showOther(SettingsBlockDialog(Block.QuoteBlock(), isBottomSheetVisible = true)) - - assertTrue(shown(R.string.quote)) - listOf(R.string.block_move_up, R.string.block_move_down, R.string.block_duplicate, R.string.delete_block) - .forEach { assertTrue(str(it), shown(it)) } - } - - @Test - fun aReadOnlyNoteOffersNoEditRows() { - showOther(SettingsBlockDialog(Block.QuoteBlock(), isBottomSheetVisible = true, canEdit = false)) - - assertTrue(shown(R.string.quote)) - listOf(R.string.block_move_up, R.string.block_move_down, R.string.block_duplicate, R.string.delete_block) - .forEach { assertTrue(str(it), !shown(it)) } - } - - @Test - fun theRowsReportTheirAction() { - showOther(SettingsBlockDialog(Block.QuoteBlock(), isBottomSheetVisible = true)) - - rule.onNodeWithText(str(R.string.block_move_up)).performClick() - rule.onNodeWithText(str(R.string.block_move_down)).performClick() - rule.onNodeWithText(str(R.string.block_duplicate)).performClick() - - assertEquals( - listOf(ActionBlockDialog.Move(1), ActionBlockDialog.Move(2), ActionBlockDialog.Duplicate), - actions, - ) - } - - @Test - fun deletingTakesASecondTap() { - showOther(SettingsBlockDialog(Block.QuoteBlock(), isBottomSheetVisible = true)) - - rule.onNodeWithText(str(R.string.delete_block)).performClick() - rule.waitForIdle() - assertTrue("the first tap only arms the row", actions.isEmpty()) - assertTrue(shown(R.string.tap_again_to_delete)) - - rule.onNodeWithText(str(R.string.tap_again_to_delete)).performClick() - assertEquals(listOf(ActionBlockDialog.Delete), actions) - } - - @Test - fun aBlockAlreadyAtTheTopCanNotMoveUp() { - showOther(SettingsBlockDialog(Block.QuoteBlock(), blockMove = 1, isBottomSheetVisible = true)) - - rule.onNodeWithText(str(R.string.block_move_up)).performClick() - rule.onNodeWithText(str(R.string.block_move_down)).performClick() - - assertEquals(listOf(ActionBlockDialog.Move(2)), actions) - } - - @Test - fun aBlockAlreadyAtTheBottomCanNotMoveDown() { - showOther(SettingsBlockDialog(Block.QuoteBlock(), blockMove = 2, isBottomSheetVisible = true)) - - rule.onNodeWithText(str(R.string.block_move_down)).performClick() - rule.onNodeWithText(str(R.string.block_move_up)).performClick() - - assertEquals(listOf(ActionBlockDialog.Move(1)), actions) - } - - // --- the sheet for a link ----------------------------------------------------------------- - - @Test - fun anOpenableLinkOffersOpenCopyAndEdit() { - showLink(SettingsBlockDialog(linkBlock("https://example.com/page"), isBottomSheetVisible = true)) - - assertTrue(shown(R.string.link_open)) - assertTrue(shown(R.string.link_copy)) - assertTrue(shown(R.string.link_edit)) - assertTrue("the whole address is shown", countText("https://example.com/page", substring = true) > 0) - } - - @Test - fun aBlockedAddressCanBeCopiedButNotOpened() { - showLink(SettingsBlockDialog(linkBlock("javascript:alert(1)"), isBottomSheetVisible = true)) - - assertTrue(!shown(R.string.link_open)) - assertTrue(shown(R.string.link_copy)) - assertTrue("the address is still shown to the user", countText("javascript:alert(1)", substring = true) > 0) - } - - @Test - fun theLinkRowsReportTheirAction() { - showLink(SettingsBlockDialog(linkBlock("https://example.com"), isBottomSheetVisible = true)) - - rule.onNodeWithText(str(R.string.link_open)).performClick() - rule.onNodeWithText(str(R.string.link_copy)).performClick() - rule.onNodeWithText(str(R.string.link_edit)).performClick() - - assertEquals( - listOf(ActionBlockDialog.OpenLink, ActionBlockDialog.CopyLink, ActionBlockDialog.EditLink), - actions, - ) - } - - @Test - fun aReadOnlyLinkOffersNothingThatChangesTheNote() { - showLink(SettingsBlockDialog(linkBlock("https://example.com"), isBottomSheetVisible = true, canEdit = false)) - - assertTrue(shown(R.string.link_open)) - assertTrue(shown(R.string.link_copy)) - assertTrue(!shown(R.string.link_edit)) - assertTrue(!shown(R.string.delete_block)) - assertTrue(!shown(R.string.block_duplicate)) - } - - @Test - fun aHiddenLinkSheetShowsNothing() { - showLink(SettingsBlockDialog(linkBlock("https://example.com"), isBottomSheetVisible = false)) - - assertTrue(!shown(R.string.link_open) && !shown(R.string.link_copy)) - } - - @Test - fun theLinkSheetOfAnEditableLinkAlsoHasTheBlockRows() { - showLink(SettingsBlockDialog(linkBlock("https://example.com"), isBottomSheetVisible = true)) - - assertTrue(shown(R.string.block_move_up)) - assertTrue(shown(R.string.delete_block)) - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/dialogs/ConfirmationDialogsTest.kt b/app/src/test/java/com/pasich/encly/presentation/dialogs/ConfirmationDialogsTest.kt deleted file mode 100644 index 85b9ffda..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/dialogs/ConfirmationDialogsTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.pasich.encly.R -import com.pasich.encly.ui.screens.ComposeScreenTest -import org.junit.Assert.assertEquals -import org.junit.Test - -/** The yes/no dialogs: hidden unless asked, and a confirmation always closes the dialog it came from. */ -class ConfirmationDialogsTest : ComposeScreenTest() { - private val calls = mutableListOf() - - private fun shown(id: Int) = countText(str(id)) > 0 - - @Test - fun aHiddenDeleteTagDialogShowsNothing() { - setScreen { RequestDeleteTagsDialog(showDialog = false, onDismiss = { calls += "dismiss" }, onConfirm = {}) } - - assertEquals(0, countText(str(R.string.delete_tag))) - } - - @Test - fun confirmingTheTagDeletionConfirmsThenDismisses() { - setScreen { - RequestDeleteTagsDialog( - showDialog = true, - onDismiss = { calls += "dismiss" }, - onConfirm = { calls += "confirm" }, - ) - } - assertEquals(1, countText(str(R.string.delete_tag))) - - rule.onNodeWithText(str(R.string.delete)).performClick() - - assertEquals(listOf("confirm", "dismiss"), calls) - } - - @Test - fun cancellingTheTagDeletionOnlyDismisses() { - setScreen { - RequestDeleteTagsDialog( - showDialog = true, - onDismiss = { calls += "dismiss" }, - onConfirm = { calls += "confirm" }, - ) - } - - rule.onNodeWithText(str(R.string.cancel)).performClick() - - assertEquals(listOf("dismiss"), calls) - } - - @Test - fun aHiddenClearCompletedDialogShowsNothing() { - setScreen { RequestCleanCompleteTaskDialog(showDialog = false, onDismiss = {}, onConfirm = {}) } - - assertEquals(0, countText(str(R.string.task_clear_completed_title))) - } - - @Test - fun confirmingTheClearOfCompletedTasksConfirmsThenDismisses() { - setScreen { - RequestCleanCompleteTaskDialog( - showDialog = true, - onDismiss = { calls += "dismiss" }, - onConfirm = { calls += "confirm" }, - ) - } - assertEquals(1, countText(str(R.string.task_clear_completed_title))) - - rule.onNodeWithText(str(R.string.clear)).performClick() - - assertEquals(listOf("confirm", "dismiss"), calls) - } - - @Test - fun cancellingTheClearOfCompletedTasksOnlyDismisses() { - setScreen { - RequestCleanCompleteTaskDialog( - showDialog = true, - onDismiss = { calls += "dismiss" }, - onConfirm = { calls += "confirm" }, - ) - } - - rule.onNodeWithText(str(R.string.cancel)).performClick() - - assertEquals(listOf("dismiss"), calls) - } - - @Test - fun theGenericConfirmDialogShowsItsTextAndRoutesBothButtons() { - setScreen { - ConfirmDialog( - isVisible = true, - titleText = "Erase?", - messageText = "This cannot be undone.", - onConfirm = { calls += "confirm" }, - onDismiss = { calls += "dismiss" }, - destructive = true, - ) - } - assertEquals(1, countText("Erase?")) - assertEquals(1, countText("This cannot be undone.")) - - rule.onNodeWithText(str(R.string.dialog_dismiss)).performClick() - rule.onNodeWithText(str(R.string.dialog_confirm)).performClick() - - assertEquals(listOf("dismiss", "confirm"), calls) - } - - @Test - fun anInvisibleConfirmDialogShowsNothing() { - setScreen { - ConfirmDialog(isVisible = false, titleText = "Erase?", messageText = "m", onConfirm = {}, onDismiss = {}) - } - - assertEquals(0, countText("Erase?")) - assertEquals(false, shown(R.string.dialog_confirm)) - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheetTest.kt b/app/src/test/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheetTest.kt deleted file mode 100644 index fc9e7cbb..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheetTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import com.pasich.encly.R -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.ui.screens.ComposeScreenTest -import com.pasich.encly.ui.screens.TestApp -import org.junit.Assert.assertEquals -import org.junit.Test - -/** The long-press sheet of a note: what it shows, which action each row reports, and the description editor. */ -@OptIn(ExperimentalMaterial3Api::class) -class NoteCardBottomSheetTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - private val actions = mutableListOf() - private var dismissed = 0 - - private fun show( - note: Note = Note(id = 5, title = "Groceries", date = 1_700_000_000_000L), - visible: Boolean = true, - ) { - app.tags.tags.value = listOf(Tag(id = 1, nameTag = "Work"), Tag(id = 2, nameTag = "Home")) - setScreen(viewModels(app.tagList())) { - NoteCardBottomSheet( - isVisible = visible, - item = NoteWithTag(note, null), - onAction = { actions += it }, - onDismiss = { dismissed++ }, - ) - } - } - - @Test - fun aHiddenSheetShowsNothing() { - show(visible = false) - - assertEquals(0, countText("Groceries")) - } - - @Test - fun theSheetShowsTheTitleTheTagChipsAndTheRows() { - show() - - assertEquals(1, countText("Groceries")) - waitForText("Work") - listOf("Home", str(R.string.no_tag), str(R.string.edit), str(R.string.duplicate), str(R.string.delete)) - .forEach { assertEquals(it, 1, countText(it)) } - } - - @Test - fun aNoteWithoutATitleIsCalledUntitled() { - show(Note(id = 5, title = "", date = 1L)) - - assertEquals(1, countText(str(R.string.untitled))) - } - - @Test - fun editAndDuplicateReportTheirActions() { - show() - - rule.onNodeWithText(str(R.string.edit)).performClick() - rule.onNodeWithText(str(R.string.duplicate)).performClick() - - assertEquals(listOf(NoteAction.Edit, NoteAction.Duplicate), actions) - } - - @Test - fun deletingTakesASecondTap() { - show() - - rule.onNodeWithText(str(R.string.delete)).performClick() - rule.waitForIdle() - assertEquals(emptyList(), actions) - - rule.onNodeWithText(str(R.string.tap_again_to_delete)).performClick() - assertEquals(listOf(NoteAction.Delete), actions) - } - - @Test - fun aTagChipMovesTheNoteToThatTagAndNoTagClearsIt() { - show(Note(id = 5, title = "Groceries", tagId = 2)) - waitForText("Work") - - rule.onNodeWithText("Work").performClick() - rule.onNodeWithText(str(R.string.no_tag)).performClick() - - assertEquals(listOf(NoteAction.ChangeTag(1), NoteAction.ChangeTag(0)), actions) - } - - @Test - fun theDescriptionRowShowsItsPlaceholderWhenEmptyAndTheTextWhenNot() { - show(Note(id = 5, title = "Groceries", description = "Weekly shop")) - assertEquals(1, countText("Weekly shop")) - assertEquals(0, countText(str(R.string.note_description_placeholder))) - } - - @Test - fun anEmptyDescriptionShowsThePlaceholder() { - show() - - assertEquals(1, countText(str(R.string.note_description_placeholder))) - } - - @Test - fun savingADescriptionReportsItTrimmedAndLeavesEditing() { - show() - - rule.onNodeWithText(str(R.string.note_description_placeholder)).performClick() - rule.waitForIdle() - assertEquals("the other rows make way for the field", 0, countText(str(R.string.duplicate))) - rule.onNode(hasSetTextAction()).performTextInput(" Buy milk ") - rule.onNodeWithText(str(R.string.save)).performClick() - rule.waitForIdle() - - assertEquals(listOf(NoteAction.ChangeDescription("Buy milk")), actions) - assertEquals("the rows are back", 1, countText(str(R.string.duplicate))) - } - - @Test - fun cancellingTheDescriptionEditReportsNothing() { - show(Note(id = 5, title = "Groceries", description = "Weekly shop")) - - rule.onNodeWithText("Weekly shop").performClick() - rule.waitForIdle() - rule.onNode(hasSetTextAction()).performTextInput(" and more") - rule.onNodeWithText(str(R.string.cancel)).performClick() - rule.waitForIdle() - - assertEquals(emptyList(), actions) - assertEquals(1, countText("Weekly shop")) - assertEquals(1, countText(str(R.string.duplicate))) - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheetTest.kt b/app/src/test/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheetTest.kt deleted file mode 100644 index ff438e59..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheetTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.pasich.encly.presentation.dialogs - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.pasich.encly.R -import com.pasich.encly.domain.enums.NoteSortOption -import com.pasich.encly.ui.screens.ComposeScreenTest -import com.pasich.encly.ui.screens.TestApp -import org.junit.Assert.assertEquals -import org.junit.Test - -/** The list's view options: every sort order, the list/grid switch, and closing after a choice. */ -@OptIn(ExperimentalMaterial3Api::class) -class NotesSortBottomSheetTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - private var dismissed = 0 - private var toggled = 0 - - private fun show(visible: Boolean = true, grid: Boolean = false) { - val viewModel = app.noteList() - setScreen(viewModels(viewModel)) { - NotesSortBottomSheet( - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - onDismiss = { dismissed++ }, - isGrid = grid, - onToggleView = { toggled++ }, - isBottomSheetVisible = visible, - noteListViewModel = viewModel, - ) - } - } - - @Test - fun aHiddenSheetShowsNothing() { - show(visible = false) - - assertEquals(0, countText(str(R.string.sort_by))) - } - - @Test - fun theSheetListsEverySortOrder() { - show() - - assertEquals(1, countText(str(R.string.sort_by))) - NoteSortOption.entries.forEach { assertEquals(it.name, 1, countText(str(it.labelRes))) } - } - - @Test - fun choosingASortOrderStoresItAndClosesTheSheet() { - show() - - rule.onNodeWithText(str(NoteSortOption.UPDATED_ASC.labelRes)).performClick() - - waitFor { app.settings.sort.value == NoteSortOption.UPDATED_ASC } - assertEquals(1, dismissed) - assertEquals(0, toggled) - } - - @Test - fun aListOffersTheSwitchToTheGridAndTheOtherWayAround() { - show(grid = false) - assertEquals(1, countText(str(R.string.view_switch_to_grid))) - assertEquals(0, countText(str(R.string.view_switch_to_list))) - - rule.onNodeWithText(str(R.string.view_switch_to_grid)).performClick() - - assertEquals(1, toggled) - assertEquals(1, dismissed) - } - - @Test - fun aGridOffersTheSwitchBackToTheList() { - show(grid = true) - - assertEquals(1, countText(str(R.string.view_switch_to_list))) - assertEquals(0, countText(str(R.string.view_switch_to_grid))) - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/persistence/NotePersistenceTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/persistence/NotePersistenceTest.kt deleted file mode 100644 index 65ad0802..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/editor/persistence/NotePersistenceTest.kt +++ /dev/null @@ -1,511 +0,0 @@ -package com.pasich.encly.presentation.editor.persistence - -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.UpdateNoteArchiveStatusUseCase -import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.testutil.TestNotesRepository -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class NotePersistenceTest { - private val dispatcher = StandardTestDispatcher() - private val repository = TestNotesRepository().apply { insertResult = INSERTED_ID } - private var blocks: List = listOf(Block.TextBlock()) - - // --- saving ------------------------------------------------------------------------------- - - @Test - fun aNewNoteIsInsertedOnceAndThenUpdated() = runTest(dispatcher) { - val persistence = persistence() - persistence.updateNote { it.copy(title = "draft") } - - assertTrue(persistence.save()) - assertEquals(INSERTED_ID, persistence.state.value.note.id) - assertTrue(persistence.save()) - - assertEquals(1, repository.insertCalls) - assertEquals(1, repository.updateCalls) - assertEquals(SaveStatusNote.SAVED, persistence.status.value) - } - - @Test - fun aBlankNewNoteIsNotStored() = runTest(dispatcher) { - val persistence = persistence() - blocks = listOf(text(" \n "), Block.LinkBlock()) - - assertTrue(persistence.save()) - - assertEquals(0, repository.insertCalls) - } - - @Test - fun aFailedInsertIsReportedAndLeavesTheNoteNew() = runTest(dispatcher) { - repository.insertResult = 0L - val persistence = persistence() - persistence.updateNote { it.copy(title = "draft") } - - assertFalse(persistence.save()) - - assertEquals(-1L, persistence.state.value.note.id) - // The editor keeps saying so until a save succeeds. - assertEquals(SaveStatusNote.FAILED, persistence.status.value) - repository.insertResult = INSERTED_ID - assertTrue(persistence.save()) - assertEquals(SaveStatusNote.SAVED, persistence.status.value) - } - - @Test - fun aSavedEditMovesTheShownDateAndDropsAnImportedSummary() = runTest(dispatcher) { - repository.allNotesWithTags.value = listOf( - NoteWithTag( - note = Note( - id = NOTE_ID, - title = "title", - value = BlockConverter.blocksToJson(listOf(text("body"))), - description = "imported summary", - date = OLD_DATE, - ), - tag = null, - ), - ) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = it } - blocks = listOf(text("body, edited")) - - assertTrue(persistence.save()) - - val stored = repository.updatedNotes.single() - assertTrue(stored.date > OLD_DATE) - assertEquals("", stored.description) - assertEquals(stored.date, persistence.state.value.note.date) - } - - @Test - fun anUnchangedNoteKeepsItsDateAndSummary() = runTest(dispatcher) { - repository.allNotesWithTags.value = listOf( - NoteWithTag( - note = Note( - id = NOTE_ID, - title = "title", - value = BlockConverter.blocksToJson(listOf(text("body"))), - description = "imported summary", - date = OLD_DATE, - ), - tag = null, - ), - ) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = it } - - assertTrue(persistence.save()) - - assertEquals(OLD_DATE, repository.updatedNotes.single().date) - assertEquals("imported summary", repository.updatedNotes.single().description) - } - - @Test - fun changesAreWhatDiscardWouldUndo() = runTest(dispatcher) { - storeNote(text("body")) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = it } - assertFalse(persistence.hasChanges()) - - blocks = listOf(text("body, edited")) - assertTrue(persistence.hasChanges()) - - blocks = listOf(text("body")) - persistence.updateNote { it.copy(tagId = TAG_ID) } - assertTrue(persistence.hasChanges()) - } - - @Test - fun aNewNoteWithoutTitleOrContentIsABlankDraft() = runTest(dispatcher) { - val persistence = persistence() - assertTrue(persistence.isBlankDraft) - - blocks = listOf(text("x")) - assertFalse(persistence.isBlankDraft) - } - - @Test - fun nothingIsSavedWhileTheNoteIsLoading() = runTest(dispatcher) { - val persistence = persistence(loading = true) - persistence.updateNote { it.copy(title = "typed before the note arrived") } - - assertTrue(persistence.save()) - - assertEquals(0, repository.insertCalls) - assertEquals(0, repository.updateCalls) - } - - @Test - fun aReadOnlyNoteIsNeverSaved() = runTest(dispatcher) { - storeNote(text("body")) - val persistence = persistence(loading = true, readOnly = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = it } - blocks = listOf(text("edited")) - - assertTrue(persistence.save()) - - assertEquals(0, repository.updateCalls) - } - - // --- loading ------------------------------------------------------------------------------ - - @Test - fun loadingShowsTheBlocksAndKeepsTheNoteId() = runTest(dispatcher) { - storeNote(text("body")) - val persistence = persistence(loading = true) - - assertTrue(persistence.load(NOTE_ID, isCopy = false) { blocks = it }) - - assertEquals("body", (blocks.single() as Block.TextBlock).text.value) - assertEquals(NOTE_ID, persistence.state.value.note.id) - assertEquals(SaveStatusNote.OLD, persistence.status.value) - } - - @Test - fun aCopyIsANewUntaggedNote() = runTest(dispatcher) { - storeNote(text("body")) - val persistence = persistence(loading = true) - - persistence.load(NOTE_ID, isCopy = true) { blocks = it } - - assertEquals(-1L, persistence.state.value.note.id) - assertEquals("title (копія)", persistence.state.value.note.title) - assertNull(persistence.state.value.note.tagId) - } - - @Test - fun aMissingNoteIsReportedAndNeverSaved() = runTest(dispatcher) { - val persistence = persistence(loading = true) - - assertFalse(persistence.load(NOTE_ID, isCopy = false) { blocks = it }) - persistence.updateNote { it.copy(title = "x") } - persistence.save() - - assertTrue(persistence.contentLoadFailed.value) - assertEquals(0, repository.insertCalls) - } - - @Test - fun unreadableContentIsNotOverwritten() = runTest(dispatcher) { - repository.allNotesWithTags.value = - listOf(NoteWithTag(note = Note(id = NOTE_ID, title = "t", value = "{not json"), tag = null)) - val persistence = persistence(loading = true) - - // Reported as not loaded, so the editor stays read-only; the title is still shown. - assertFalse(persistence.load(NOTE_ID, isCopy = false) { blocks = it }) - assertEquals("t", persistence.state.value.note.title) - blocks = listOf(text("edited")) - persistence.save() - - assertTrue(persistence.contentLoadFailed.value) - assertEquals(0, repository.updateCalls) - } - - @Test - fun aCopyOfUnreadableContentIsReportedAndNeverInserted() = runTest(dispatcher) { - repository.allNotesWithTags.value = - listOf(NoteWithTag(note = Note(id = NOTE_ID, title = "t", value = "{not json"), tag = null)) - val persistence = persistence(loading = true) - - assertFalse(persistence.load(NOTE_ID, isCopy = true) { blocks = it }) - blocks = listOf(text("typed")) - persistence.save() - - assertEquals(0, repository.insertCalls) - } - - // --- closing: trash, discard, delete ------------------------------------------------------ - - @Test - fun aSaveAfterTrashCannotBringTheNoteBack() = runTest(dispatcher) { - storeNote(text("original")) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = it } - blocks = listOf(text("edited")) - - assertTrue(persistence.moveToTrash()) - persistence.save() - - assertEquals(1, repository.updateCalls) - assertTrue(repository.updatedNotes.single().isTrash) - assertTrue(persistence.state.value.note.isTrash) - } - - @Test - fun restoringTakesTheNoteOutOfTheTrashWithTheEditorContent() = runTest(dispatcher) { - storeNote(text("original"), isTrash = true) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = it } - - assertTrue(persistence.restoreFromTrash()) - - val restored = repository.updatedNotes.single() - assertFalse(restored.isTrash) - assertEquals(BlockConverter.blocksToJson(blocks), restored.value) - } - - @Test - fun discardPutsBackTheOpenedNoteAndBlocksLaterSaves() = runTest(dispatcher) { - storeNote(text("original")) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = it } - blocks = listOf(text("edited")) - - assertTrue(persistence.discard()) - persistence.save() - - assertEquals(BlockConverter.blocksToJson(listOf(text("original"))), repository.updatedNotes.single().value) - } - - @Test - fun discardingAnAutosavedNewNoteDeletesIt() = runTest(dispatcher) { - val persistence = persistence() - persistence.updateNote { it.copy(title = "draft") } - persistence.save() - - assertTrue(persistence.discard()) - - assertEquals(listOf(INSERTED_ID), repository.deletedIds) - } - - @Test - fun deletingANewNoteTouchesNothing() = runTest(dispatcher) { - val persistence = persistence() - - assertTrue(persistence.delete()) - - assertTrue(repository.deletedIds.isEmpty()) - } - - @Test - fun aDuplicateIsStoredAsACopy() = runTest(dispatcher) { - val persistence = persistence() - persistence.updateNote { it.copy(title = "note") } - - assertEquals(INSERTED_ID, persistence.duplicate()) - assertEquals("note (копія)", repository.insertedNotes.single().title) - } - - @Test - fun titleAndTagChangedWhileTheFirstInsertRunsAreKeptAndStored() = runTest(dispatcher) { - val gate = CompletableDeferred() - repository.insertGate = gate - val persistence = persistence() - persistence.updateNote { it.copy(title = "dra") } - - val firstSave = async { persistence.save() } - runCurrent() // the insert is in flight - persistence.updateNote { it.copy(title = "draft", tagId = TAG_ID) } - gate.complete(Unit) - assertTrue(firstSave.await()) - - val note = persistence.state.value.note - assertEquals(INSERTED_ID, note.id) - assertEquals("draft", note.title) - assertEquals(TAG_ID, note.tagId) - - // The later changes are not in the database yet, so the next save writes them. - assertTrue(persistence.save()) - assertEquals("draft", repository.updatedNotes.single().title) - assertEquals(TAG_ID, repository.updatedNotes.single().tagId) - } - - @Test - fun aTagChangeAloneIsAutosaved() = runTest(dispatcher) { - storeNote(text("body")) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = it } - persistence.startAutosave(backgroundScope, contentChangesOf(MutableStateFlow(Unit))) - passQuietTime() - assertEquals(0, repository.updateCalls) - - persistence.updateNote { it.copy(tagId = TAG_ID) } - passQuietTime() - - assertEquals(TAG_ID, repository.updatedNotes.single().tagId) - assertEquals(BlockConverter.blocksToJson(listOf(text("body"))), repository.updatedNotes.single().value) - } - - // --- autosave ----------------------------------------------------------------------------- - - @Test - fun autosaveWritesOnceAfterTheQuietTime() = runTest(dispatcher) { - val changes = MutableSharedFlow(replay = 1) - val persistence = persistence() - persistence.startAutosave(backgroundScope, changes) - changes.emit(Unit) - - persistence.updateNote { it.copy(title = "a") } - runCurrent() - persistence.updateNote { it.copy(title = "ab") } - advanceTimeBy(AUTOSAVE_DEBOUNCE_MS - 1) - runCurrent() - assertEquals(0, repository.insertCalls) - - advanceTimeBy(2) - runCurrent() - assertEquals(1, repository.insertCalls) - } - - @Test - fun openingANoteAutosavesNothingButTheFirstEditIsSaved() = runTest(dispatcher) { - storeNote(text("body")) - val body = MutableStateFlow("body") - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { blocks = listOf(Block.TextBlock(body)) } - persistence.startAutosave(backgroundScope, contentChangesOf(body)) - passQuietTime() - assertEquals(0, repository.updateCalls) - - body.value = "body, edited" - passQuietTime() - - assertEquals(1, repository.updateCalls) - } - - @Test - fun autosaveStopsWhenTheEditorCloses() = runTest(dispatcher) { - val persistence = persistence() - persistence.startAutosave(backgroundScope, contentChangesOf(MutableStateFlow(Unit))) - persistence.updateNote { it.copy(title = "draft") } - - persistence.close() - passQuietTime() - - assertEquals(0, repository.insertCalls) - } - - // --- pin, archive and colour ---------------------------------------------------------------- - - @Test - fun aNewNoteKeepsItsPinAndColourWhenFirstStored() = runTest(dispatcher) { - val persistence = persistence() - persistence.updateNote { it.copy(title = "draft", isPinned = true, color = "teal") } - - assertTrue(persistence.save()) - - val inserted = repository.insertedNotes.single() - assertTrue(inserted.isPinned) - assertEquals("teal", inserted.color) - } - - @Test - fun aChangedPinOrColourIsAutosavedAlthoughTheContentIsTheSame() = runTest(dispatcher) { - storeNote(text("body")) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { } - val changes = MutableStateFlow(0) - persistence.startAutosave(backgroundScope, contentChangesOf(changes)) - passQuietTime() - assertEquals(0, repository.updateCalls) - - persistence.updateNote { it.copy(isPinned = true) } - passQuietTime() - persistence.updateNote { it.copy(color = "blue") } - passQuietTime() - - assertEquals(listOf(true, true), repository.updatedNotes.map { it.isPinned }) - assertEquals(listOf("none", "blue"), repository.updatedNotes.map { it.color }) - } - - @Test - fun archivingUnpinsAndClosesTheEditor() = runTest(dispatcher) { - storeNote(text("body")) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = false) { } - persistence.updateNote { it.copy(isPinned = true) } - - assertTrue(persistence.setArchived(true)) - - assertEquals(listOf("archived:$NOTE_ID=true"), repository.columnWrites) - assertFalse(persistence.state.value.note.isPinned) - // Closed: a later save cannot write the note again. - assertTrue(persistence.save()) - assertEquals(0, repository.updateCalls) - } - - @Test - fun aNewUnsavedNoteCannotBeArchived() = runTest(dispatcher) { - assertFalse(persistence().setArchived(true)) - assertEquals(0, repository.updateCalls) - } - - @Test - fun aCopyKeepsTheColourButIsNeitherPinnedNorArchived() = runTest(dispatcher) { - repository.allNotesWithTags.value = listOf( - NoteWithTag( - Note(id = NOTE_ID, title = "t", value = "[]", isPinned = true, isArchived = true, color = "pink"), - null, - ), - ) - val persistence = persistence(loading = true) - persistence.load(NOTE_ID, isCopy = true) { } - - val copy = persistence.state.value.note - assertEquals(-1L, copy.id) - assertFalse(copy.isPinned) - assertFalse(copy.isArchived) - assertEquals("pink", copy.color) - } - - // The autosave runs in backgroundScope, whose timers advanceUntilIdle() does not wait for. - private fun TestScope.passQuietTime() { - advanceTimeBy(AUTOSAVE_DEBOUNCE_MS + 1) - runCurrent() - } - - private fun contentChangesOf(flow: MutableStateFlow<*>) = flow { - flow.collect { emit(Unit) } - } - - private fun persistence(loading: Boolean = false, readOnly: Boolean = false) = NotePersistence( - notesRepository = repository, - updateNoteTrashStatusUseCase = UpdateNoteTrashStatusUseCase(repository), - updateNoteArchiveStatusUseCase = UpdateNoteArchiveStatusUseCase(repository), - ioDispatcher = dispatcher, - readOnly = readOnly, - blocks = { blocks }, - copyTitle = { "$it (копія)" }, - loading = loading, - ) - - private fun storeNote(vararg content: Block, isTrash: Boolean = false) { - val note = Note( - id = NOTE_ID, - title = "title", - value = BlockConverter.blocksToJson(content.toList()), - isTrash = isTrash, - ) - repository.allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) - } - - private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) - - private companion object { - const val NOTE_ID = 91L - const val INSERTED_ID = 73L - const val TAG_ID = 5L - const val OLD_DATE = 1_000L - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockOperationsTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockOperationsTest.kt deleted file mode 100644 index ff3e99c7..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockOperationsTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.pasich.encly.presentation.editor.state - -import androidx.compose.runtime.snapshots.SnapshotStateList -import com.pasich.encly.dynamicBlocks.Block -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class BlockOperationsTest { - private var now = 0L - private val block = Block.TextBlock() - private val blocks = SnapshotStateList().apply { add(block) } - private val operations = BlockOperations(blocks) { now } - - @Test - fun typingWithinTheMergeWindowIsOneUndoStep() { - "hello".forEachIndexed { i, _ -> - operations.changeValue(block.id, block.text, "hello".take(i + 1), mergeable = true) - now += 100 - } - - assertTrue(operations.undo()) - assertEquals("", block.text.value) - assertFalse(operations.canUndo()) - } - - @Test - fun aPauseStartsANewUndoStep() { - operations.changeValue(block.id, block.text, "one", mergeable = true) - now += BlockOperations.MERGE_WINDOW_MS + 1 - operations.changeValue(block.id, block.text, "one two", mergeable = true) - - operations.undo() - - assertEquals("one", block.text.value) - } - - @Test - fun editsAfterAnUndoAreNotMergedIntoTheStepBelow() { - operations.changeValue(block.id, block.text, "a", mergeable = true) - operations.addBlock(1, Block.SeparatorBlock()) - operations.undo() - - operations.changeValue(block.id, block.text, "ab", mergeable = true) - operations.undo() - - assertEquals("a", block.text.value) - } - - @Test - fun historyIsCapped() { - repeat(BlockOperations.MAX_HISTORY + 50) { i -> - operations.changeValue(block.id, block.text, "v$i", mergeable = false) - } - - var undone = 0 - while (operations.undo()) undone++ - - assertEquals(BlockOperations.MAX_HISTORY, undone) - } - - @Test - fun contentUndoFollowsTheBlockNotItsPosition() { - val other = Block.TextBlock(MutableStateFlow("other")) - operations.changeValue(block.id, block.text, "mine", mergeable = false) - operations.addBlock(0, other) - - // Undo the insert, then the edit: the edit still lands on its own block. - operations.undo() - operations.undo() - - assertEquals("", block.text.value) - assertEquals("other", other.text.value) - } - - @Test - fun eachWordTypedIsItsOwnUndoStep() { - var text = "" - "one two".forEach { char -> - text += char - operations.changeValue(block.id, block.text, text, mergeable = true) - now += 50 - } - - operations.undo() - assertEquals("one ", block.text.value) - operations.undo() - assertEquals("", block.text.value) - } - - @Test - fun deletingAfterTypingIsItsOwnStep() { - operations.changeValue(block.id, block.text, "abc", mergeable = true) - now += 50 - operations.changeValue(block.id, block.text, "ab", mergeable = true) - - operations.undo() - - assertEquals("abc", block.text.value) - } - - @Test - fun aBatchIsUndoneAndRedoneAsOneStep() { - val added = Block.TextBlock(MutableStateFlow("world")) - operations.changeValue(block.id, block.text, "hello world", mergeable = false) - - operations.batch { - setValue(block.id, block.text, "hello") - addBlock(1, added) - } - assertEquals(listOf("hello", "world"), blocks.map { (it as Block.TextBlock).text.value }) - - operations.undo() - assertEquals(listOf("hello world"), blocks.map { (it as Block.TextBlock).text.value }) - operations.redo() - assertEquals(listOf("hello", "world"), blocks.map { (it as Block.TextBlock).text.value }) - } - - @Test - fun anEmptyBatchRecordsNothing() { - operations.batch { setValue(block.id, block.text, block.text.value) } - - assertFalse(operations.canUndo()) - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/ChecklistLockTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/ChecklistLockTest.kt deleted file mode 100644 index 025f734d..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/ChecklistLockTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -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.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.assertTrue -import org.junit.Test - -class ChecklistLockTest { - private val editor = BlockEditorState { 0L } - - private fun checklist() = Block.ListBlock( - MutableStateFlow(listOf(ItemListBlock("milk"), ItemListBlock("eggs", isCheck = true))), - blockType = BlockType.LIST_CHECK, - ) - - @Test - fun lockingIsOneUndoStepAndOnlyForChecklists() { - val list = checklist() - val bullets = Block.ListBlock(blockType = BlockType.LIST_BULLET) - editor.load(listOf(list, bullets)) - - assertTrue(editor.toggleChecklistLock(list.id)) - assertTrue(list.locked.value) - editor.undo() - assertFalse(list.locked.value) - editor.redo() - assertTrue(list.locked.value) - - assertFalse(editor.toggleChecklistLock(bullets.id)) - assertFalse(bullets.locked.value) - } - - @Test - fun aLockedChecklistIsNotConvertedByTheToolbar() { - val list = checklist() - editor.load(listOf(list)) - editor.selection.onFocused(list.id) - editor.toggleChecklistLock(list.id) - - editor.applyTool(BlockType.LIST_BULLET) - - assertTrue(editor.blocks.single() === list) - assertTrue(list.locked.value) - } - - @Test - fun aLockedChecklistTakesNoFocusButKeepsItsItems() { - val list = checklist() - editor.load(listOf(list)) - - editor.toggleChecklistLock(list.id) - - assertFalse(list.canTakeFocus()) - assertEquals(listOf("milk", "eggs"), list.items.value.map { it.value }) - } - - @Test - fun theLockSurvivesSavingAndAnUnlockedListStoresNoExtraKey() { - val locked = checklist().also { it.locked.value = true } - val open = checklist() - - val json = BlockConverter.blocksToJson(listOf(locked, open)) - val back = BlockConverter.jsonToBlocks(json).filterIsInstance() - - assertEquals(listOf(true, false), back.map { it.locked.value }) - assertEquals(1, Regex("isLocked").findAll(json).count()) - } - - @Test - fun aNoteWrittenBeforeTheLockOpensUnlocked() { - val old = """[{"blockType":"LIST_CHECK","items":[{"value":"milk","isCheck":false}]}]""" - - val list = BlockConverter.jsonToBlocks(old).single() as Block.ListBlock - - assertFalse(list.locked.value) - } - - @Test - fun aDuplicateKeepsTheLockWithoutSharingIt() { - val list = checklist().also { it.locked.value = true } - editor.load(listOf(list)) - - editor.duplicateBlockOf(list.id) - - val copy = editor.blocks[1] as Block.ListBlock - assertTrue(copy.locked.value) - copy.locked.value = false - assertTrue(list.locked.value) - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/effects/UnlockRevealTest.kt b/app/src/test/java/com/pasich/encly/presentation/effects/UnlockRevealTest.kt deleted file mode 100644 index 2e8aeaf9..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/effects/UnlockRevealTest.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.pasich.encly.presentation.effects - -import androidx.compose.ui.geometry.Rect -import com.pasich.encly.ui.screens.ComposeScreenTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** The unlock reveal: when the lock screen is left, how often the full animation plays, and how it is cut short. */ -class UnlockRevealTest : ComposeScreenTest() { - private val state = UnlockRevealState() - private var covered = 0 - - private val logo = Rect(left = 100f, top = 200f, right = 164f, bottom = 264f) - - @Test - fun withoutTheLogoBoundsTheLockScreenIsLeftAtOnce() { - state.start { covered++ } - - assertEquals(1, covered) - assertFalse(state.running) - } - - @Test - fun withTheLogoTheLockScreenIsLeftOnlyOnceCovered() { - state.logoBounds = logo - - state.start { covered++ } - - assertTrue(state.running) - assertEquals("nothing happens until the colour fills the window", 0, covered) - - state.cover() - state.cover() - assertEquals("leaving happens once", 1, covered) - assertEquals("the screens wait off the right edge", 1f, state.contentOffset, 0f) - } - - @Test - fun theListSlidesInAsTheProgressGrowsAndFinishingSettlesIt() { - state.logoBounds = logo - state.start {} - state.cover() - - state.push(0.25f) - assertEquals(0.75f, state.contentOffset, 0.0001f) - state.push(1f) - assertEquals(0f, state.contentOffset, 0f) - - state.finish() - assertFalse(state.running) - assertEquals(0f, state.contentOffset, 0f) - } - - @Test - fun aSecondStartWhilePlayingChangesNothing() { - state.logoBounds = logo - state.start { covered += 1 } - - state.start { covered += 100 } - state.cover() - - assertEquals(1, covered) - } - - @Test - fun onlyTheFirstUnlockGetsTheFullReveal() { - state.logoBounds = logo - state.start {} - assertTrue(state.full) - state.finish() - - state.start {} - - assertFalse(state.full) - } - - @Test - fun cancellingDropsTheLeaveAndPutsTheScreensBack() { - state.logoBounds = logo - state.start { covered++ } - state.cover() - - state.cancel() - - assertFalse(state.running) - assertEquals(0f, state.contentOffset, 0f) - state.cover() - assertEquals("the pending navigation does not run after a cancel", 1, covered) - } - - @Test - fun revealThenRunsTheLeaveDirectlyWithoutAReveal() { - var left = 0 - val none: UnlockRevealState? = null - - none.revealThen { left++ } - - assertEquals(1, left) - } - - @Test - fun revealThenStartsTheRevealWhenOneIsProvided() { - var left = 0 - state.logoBounds = logo - - state.revealThen { left++ } - - assertTrue(state.running) - assertEquals(0, left) - } - - @Test - fun theFullRevealPlaysToTheEndAndLeavesTheLockScreenOnce() { - state.logoBounds = logo - setScreen { UnlockRevealOverlay(state) } - - rule.runOnIdle { state.start { covered++ } } - waitFor { !state.running } - - assertEquals(1, covered) - assertEquals(0f, state.contentOffset, 0f) - } - - @Test - fun theLaterRevealIsTheShortFadeAndAlsoLeavesOnce() { - state.logoBounds = logo - setScreen { UnlockRevealOverlay(state) } - rule.runOnIdle { state.start { covered++ } } - waitFor { !state.running } - - rule.runOnIdle { state.start { covered++ } } - assertFalse(state.full) - waitFor { !state.running } - - assertEquals(2, covered) - assertEquals(0f, state.contentOffset, 0f) - } - - @Test - fun aRevealCancelledMidwayNeverLeavesTheLockScreen() { - state.logoBounds = logo - setScreen { UnlockRevealOverlay(state) } - rule.mainClock.autoAdvance = false - - rule.runOnIdle { state.start { covered++ } } - rule.mainClock.advanceTimeBy(50) - rule.runOnIdle { state.cancel() } - rule.mainClock.autoAdvance = true - rule.waitForIdle() - - assertEquals(0, covered) - assertFalse(state.running) - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/screen/LockFormStateTest.kt b/app/src/test/java/com/pasich/encly/presentation/screen/LockFormStateTest.kt deleted file mode 100644 index badc2ccd..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/screen/LockFormStateTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.pasich.encly.presentation.screen - -import com.pasich.encly.R -import com.pasich.encly.presentation.viewmodel.PinUnlockResult -import com.pasich.encly.presentation.viewmodel.SeedUnlockResult -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 LockFormStateTest { - private val form = LockFormState() - - private fun typePin(pin: String) = pin.forEach { form.typeDigit(it.digitToInt()) } - - @Test - fun aWrongPinBeforeTheLockoutShowsTheErrorAndShakes() { - typePin("123456") - assertEquals("123456", String(form.takePin())) - - form.onPinResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 0L) - - assertEquals(R.string.lock_wrong_pin, form.pinError) - assertEquals(1, form.shakeKey) - assertEquals(0, form.pinLength) - assertEquals(0L, form.lockoutSeconds) - } - - @Test - fun theNextAttemptClearsTheError() { - form.onPinResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 0L) - - form.typeDigit(1) - - assertNull(form.pinError) - } - - @Test - fun aWrongPinThatStartsALockoutCountsItDownAndBlocksDigits() { - form.onPinResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 30_000L) - - assertEquals(30L, form.lockoutSeconds) - form.typeDigit(1) - assertEquals(0, form.pinLength) - } - - @Test - fun aWrongPhraseKeepsTheWordsAndShowsTheError() { - form.phrase.onValueChange(0, "orbit cactus velvet") - - form.onSeedResult(SeedUnlockResult.WRONG_SEED) - - assertEquals(listOf("orbit", "cactus", "velvet"), form.phrase.words.take(3)) - assertEquals(R.string.lock_wrong_recovery_phrase, form.phraseError) - form.onPhraseEdited() - assertNull(form.phraseError) - } - - @Test - fun backToThePinPadDropsTheWords() { - form.useRecovery = true - form.phrase.onValueChange(0, "orbit cactus") - form.onSeedResult(SeedUnlockResult.WRONG_SEED) - - form.leaveRecovery() - - assertFalse(form.useRecovery) - assertTrue(form.phrase.words.all(String::isEmpty)) - assertNull(form.phraseError) - } - - @Test - fun aBackgroundedUnlockShowsNoError() { - form.onPinResult(PinUnlockResult.BACKGROUNDED, lockoutRemainingMillis = 0L) - form.onSeedResult(SeedUnlockResult.BACKGROUNDED) - - assertNull(form.pinError) - assertNull(form.phraseError) - assertEquals(0, form.pinLength) - } - - @Test - fun theTypedDigitsAreWipedWhenTakenOrCleared() { - typePin("12") - form.deleteDigit() - typePin("3456") - assertEquals(5, form.pinLength) - form.typeDigit(7) - - val taken = form.takePin() - - assertEquals("134567", String(taken)) - assertEquals(0, form.pinLength) - typePin("99") - form.clearPin() - assertEquals(0, form.pinLength) - assertEquals("a new PIN starts from nothing", "1", String(form.also { it.typeDigit(1) }.takePin())) - } - - @Test - fun aLostPinKeyExplainsItOnBothForms() { - form.onPinResult(PinUnlockResult.KEY_LOST, lockoutRemainingMillis = 0L) - - assertEquals(R.string.lock_pin_key_lost, form.pinError) - assertEquals(R.string.lock_pin_key_lost, form.phraseError) - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/screen/PinLockoutTextTest.kt b/app/src/test/java/com/pasich/encly/presentation/screen/PinLockoutTextTest.kt deleted file mode 100644 index 588cc70c..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/screen/PinLockoutTextTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.pasich.encly.presentation.screen - -import com.pasich.encly.R -import com.pasich.encly.presentation.screen.pincode.lockoutUnit -import org.junit.Assert.assertEquals -import org.junit.Test - -/** Lockouts now run up to a day: the countdown switches to minutes and hours, rounded up. */ -class PinLockoutTextTest { - @Test - fun shortLockoutsCountSecondsLongOnesMinutesThenHours() { - assertEquals(R.plurals.lock_lockout_seconds to 30, lockoutUnit(30)) - assertEquals(R.plurals.lock_lockout_seconds to 119, lockoutUnit(119)) - assertEquals(R.plurals.lock_lockout_minutes to 2, lockoutUnit(120)) - assertEquals(R.plurals.lock_lockout_minutes to 16, lockoutUnit(15 * 60 + 1)) - assertEquals(R.plurals.lock_lockout_hours to 2, lockoutUnit(2 * 3600)) - assertEquals(R.plurals.lock_lockout_hours to 24, lockoutUnit(24 * 3600)) - } -} 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 deleted file mode 100644 index 649cdc7a..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import android.content.Context -import com.pasich.encly.R -import com.pasich.encly.core.security.SecurityManager -import com.pasich.encly.core.security.SessionLockManager -import com.pasich.encly.data.backup.BackupDocuments -import com.pasich.encly.data.backup.BackupManager -import com.pasich.encly.data.backup.BackupPhraseSetup -import com.pasich.encly.testutil.InMemorySharedPreferences -import com.pasich.encly.testutil.InMemoryVaultDataStore -import com.pasich.encly.testutil.anyCharArray -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` - -@OptIn(ExperimentalCoroutinesApi::class) -class BackupViewModelTest { - - private lateinit var security: SecurityManager - private lateinit var viewModel: BackupViewModel - - @Before - fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - security = mock(SecurityManager::class.java) - viewModel = BackupViewModel( - backupManager = BackupManager(security, InMemoryVaultDataStore(), InMemorySharedPreferences()), - phraseSetup = BackupPhraseSetup(security), - securityManager = security, - sessionLockManager = SessionLockManager(security), - documents = BackupDocuments(mock(Context::class.java)), - ) - viewModel.start(BackupAction.IMPORT) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun theRightPinDuringALockoutIsNotReportedAsWrong() { - `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) - - viewModel.reauthFlow.submitPin("123456".toCharArray()) - - val step = viewModel.uiState.value.step as BackupStep.Reauth - assertNull(step.error) - // Nothing is verified, so the lockout is neither extended nor reported as a wrong PIN. - verify(security, never()).verifyPin(anyCharArray()) - } - - @Test - fun theSubmittedPinIsWipedWhetherItIsCheckedOrNot() { - `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) - val refused = "123456".toCharArray() - viewModel.reauthFlow.submitPin(refused) - assertArrayEquals(CharArray(refused.size), refused) - - `when`(security.pinLockoutRemainingMillis()).thenReturn(0L) - `when`(security.verifyPin(anyCharArray())).thenReturn(false) - val checked = "000000".toCharArray() - viewModel.reauthFlow.submitPin(checked) - waitForStep { (it as? BackupStep.Reauth)?.error != null } - assertArrayEquals(CharArray(checked.size), checked) - } - - @Test - fun aWrongPinOutsideALockoutIsReported() { - `when`(security.verifyPin(anyCharArray())).thenReturn(false) - - viewModel.reauthFlow.submitPin("000000".toCharArray()) - - waitForStep { (it as? BackupStep.Reauth)?.error != null } - assertEquals(R.string.lock_wrong_pin, (viewModel.uiState.value.step as BackupStep.Reauth).error) - } - - @Test - fun theRightPinOutsideALockoutProceeds() { - `when`(security.verifyPin(anyCharArray())).thenReturn(true) - - viewModel.reauthFlow.submitPin("123456".toCharArray()) - - waitForStep { it == BackupStep.PickImportFile } - } - - @Test - fun creatingARecoveryPhraseLaterShowsNewWordsAfterThePin() { - `when`(security.verifyPin(anyCharArray())).thenReturn(true) - `when`(security.hasRecoverySeed()).thenReturn(false) - `when`(security.generateMnemonicCode()).thenReturn(WORDS.toCharArray()) - viewModel.start(BackupAction.CREATE_PHRASE) - - viewModel.reauthFlow.submitPin("123456".toCharArray()) - - waitForStep { it is BackupStep.ShowNewPhrase } - assertEquals(WORDS.split(' '), (viewModel.uiState.value.step as BackupStep.ShowNewPhrase).words) - } - - @Test - fun eachWrongPinCountsAFailureSoTheDotsShakeAgain() { - `when`(security.verifyPin(anyCharArray())).thenReturn(false) - - viewModel.reauthFlow.submitPin("000000".toCharArray()) - waitForStep { (it as? BackupStep.Reauth)?.failures == 1 } - viewModel.reauthFlow.submitPin("000000".toCharArray()) - - waitForStep { (it as? BackupStep.Reauth)?.failures == 2 } - } - - @Test - fun creatingARecoveryPhraseWhenOneExistsSaysSo() { - `when`(security.verifyPin(anyCharArray())).thenReturn(true) - `when`(security.hasRecoverySeed()).thenReturn(true) - viewModel.start(BackupAction.CREATE_PHRASE) - - viewModel.reauthFlow.submitPin("123456".toCharArray()) - - waitForStep { it == BackupStep.Idle } - assertEquals(BackupMessage.Text(R.string.backup_phrase_exists), viewModel.uiState.value.message) - } - - @Test - fun theCheckKnowsItsWordsAndCanGoBackToThem() { - `when`(security.verifyPin(anyCharArray())).thenReturn(true) - `when`(security.hasRecoverySeed()).thenReturn(false) - `when`(security.generateMnemonicCode()).thenReturn(WORDS.toCharArray()) - viewModel.start(BackupAction.CREATE_PHRASE) - viewModel.reauthFlow.submitPin("123456".toCharArray()) - waitForStep { it is BackupStep.ShowNewPhrase } - - viewModel.phraseFlow.writtenDown() - val check = viewModel.uiState.value.step as BackupStep.CheckNewPhrase - assertEquals(check.positions.map(WORDS.split(' ')::get), check.expected) - - viewModel.phraseFlow.showAgain() - assertEquals(WORDS.split(' '), (viewModel.uiState.value.step as BackupStep.ShowNewPhrase).words) - } - - @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 - verify(security, never()).wipeAndReset() - - viewModel.reauthFlow.submitPin("123456".toCharArray()) - - waitForStep { it == BackupStep.Idle && viewModel.uiState.value.erased } - verify(security).wipeAndReset() - 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() - - assertEquals(BackupStep.Idle, viewModel.uiState.value.step) - assertEquals( - BackupMessage.Text(R.string.backup_error_no_picker), - viewModel.uiState.value.message, - ) - } - - /** - * verifyPin runs on Dispatchers.Default, a real thread. Waits for the whole busy block to - * end too, so nothing touches Dispatchers.Main after [tearDown] resets it. - */ - private fun waitForStep(matches: (BackupStep) -> Boolean) { - val deadline = System.currentTimeMillis() + TIMEOUT_MS - while (!matches(viewModel.uiState.value.step) || viewModel.uiState.value.busy) { - check(System.currentTimeMillis() < deadline) { "step is ${viewModel.uiState.value.step}" } - Thread.sleep(POLL_MS) - } - } - - private companion object { - const val TIMEOUT_MS = 5_000L - const val POLL_MS = 10L - const val WORDS = "one two three four five six seven eight nine ten eleven twelve" - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt deleted file mode 100644 index 4d6ed6fa..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt +++ /dev/null @@ -1,245 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModelStore -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.UpdateNoteArchiveStatusUseCase -import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.presentation.editor.persistence.SaveStatusNote -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.UnconfinedTestDispatcher -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.assertTrue -import org.junit.Before -import org.junit.Test - -/** The editor around its content: arguments, settings, saves on leaving, lock and status. */ -@OptIn(ExperimentalCoroutinesApi::class) -class EditNoteViewModelLifecycleTest { - private val dispatcher = StandardTestDispatcher() - private val repository = TestNotesRepository().apply { insertResult = INSERTED_ID } - private val settings = TestSettingsRepository() - - @Before - fun setUp() { - Dispatchers.setMain(dispatcher) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun aNoteCreatedFromATagsListStartsInThatTag() = runTest(dispatcher) { - val viewModel = viewModel(SavedStateHandle(mapOf("addTag" to TAG_ID))) - advanceUntilIdle() - - assertEquals(TAG_ID, viewModel.state.value.note.tagId) - assertTrue(viewModel.isNewNote) - } - - @Test - fun clearingTheScreenSavesWhatWasTypedOnce() = runTest(dispatcher) { - val store = ViewModelStore() - val viewModel = viewModel(SavedStateHandle()) - store.put("editor", viewModel) - advanceUntilIdle() - viewModel.updateTitle("Typed just before leaving") - - store.clear() - advanceUntilIdle() - - assertEquals("Typed just before leaving", repository.insertedNotes.single().title) - } - - @Test - fun aHandledExitStopsTheLastChanceSave() = runTest(dispatcher) { - val store = ViewModelStore() - val viewModel = viewModel(SavedStateHandle()) - store.put("editor", viewModel) - advanceUntilIdle() - viewModel.updateTitle("Discarded") - - viewModel.markExitHandled() - store.clear() - advanceUntilIdle() - - assertEquals(0, repository.insertCalls) - } - - @Test - fun goingToTheBackgroundSavesWithoutWaitingForThePause() = runTest(dispatcher) { - val viewModel = viewModel(SavedStateHandle()) - advanceUntilIdle() - viewModel.updateTitle("Before the re-lock") - - viewModel.saveForBackground() - dispatcher.scheduler.runCurrent() - advanceUntilIdle() - - assertEquals(1, repository.insertCalls) - assertEquals(SaveStatusNote.SAVED, viewModel.status.value) - } - - @Test - fun aFailedSaveIsShownAsNotSaved() = runTest(dispatcher) { - repository.insertResult = 0 - val viewModel = viewModel(SavedStateHandle()) - advanceUntilIdle() - viewModel.updateTitle("Will fail") - - assertFalse(viewModel.saveNote()) - - assertEquals(SaveStatusNote.FAILED, viewModel.status.value) - } - - @Test - fun theFontSizeOfTheMenuIsTheNotesOwnAndFollowed() = runTest(dispatcher) { - val viewModel = viewModel(SavedStateHandle()) - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.fontSize.collect {} } - advanceUntilIdle() - - viewModel.updateFontSize(FONT_SIZE) - advanceUntilIdle() - - // The note's own size; the default in Settings is left as it was. - assertEquals(DEFAULT_FONT_SIZE, settings.fontSizeFlow.value) - assertEquals(FONT_SIZE, viewModel.fontSize.value) - } - - @Test - fun theLockToggleLocksAndUnlocksAnOpenedNote() = runTest(dispatcher) { - storeNote() - val viewModel = viewModel(SavedStateHandle(mapOf("idNote" to NOTE_ID))) - advanceUntilIdle() - assertFalse(viewModel.lockEditor.value) - - viewModel.toggleLockEditor() - assertTrue(viewModel.lockEditor.value) - viewModel.toggleLockEditor() - assertFalse(viewModel.lockEditor.value) - } - - @Test - fun aNoteInTheTrashCannotBeUnlocked() = runTest(dispatcher) { - storeNote(isTrash = true) - val viewModel = viewModel( - SavedStateHandle(mapOf("idNote" to NOTE_ID, "isReadTrashOnly" to true)), - ) - advanceUntilIdle() - - viewModel.toggleLockEditor() - - assertTrue(viewModel.lockEditor.value) - } - - @Test - fun hasChangesIsWhatDiscardWouldUndo() = runTest(dispatcher) { - storeNote() - val viewModel = viewModel(SavedStateHandle(mapOf("idNote" to NOTE_ID))) - advanceUntilIdle() - assertFalse(viewModel.hasChanges()) - - viewModel.onBlockTextChanged(viewModel.blocks.first(), "changed") - assertTrue(viewModel.hasChanges()) - - assertTrue(viewModel.discardChanges()) - advanceUntilIdle() - assertEquals( - "body", - BlockConverter.jsonToBlocksOrNull(repository.updatedNotes.last().value)!!.let { - (it.single() as Block.TextBlock).text.value - }, - ) - } - - @Test - fun deletingAStoredNoteRemovesIt() = runTest(dispatcher) { - storeNote(isTrash = true) - val viewModel = viewModel(SavedStateHandle(mapOf("idNote" to NOTE_ID, "isReadTrashOnly" to true))) - advanceUntilIdle() - - assertTrue(viewModel.noteDelete()) - - assertEquals(listOf(NOTE_ID), repository.deletedIds) - } - - @Test - fun undoAndRedoFollowTheEdits() = runTest(dispatcher) { - val viewModel = viewModel(SavedStateHandle()) - advanceUntilIdle() - assertFalse(viewModel.canUndo.value) - - viewModel.onBlockTextChanged(viewModel.blocks.first(), "first") - assertTrue(viewModel.canUndo.value) - - viewModel.undo() - assertEquals("", (viewModel.blocks.first() as Block.TextBlock).text.value) - assertTrue(viewModel.canRedo.value) - - viewModel.redo() - assertEquals("first", (viewModel.blocks.first() as Block.TextBlock).text.value) - } - - @Test - fun focusFollowsTheBlockTheUserWorksOn() = runTest(dispatcher) { - val viewModel = viewModel(SavedStateHandle()) - advanceUntilIdle() - val block = viewModel.blocks.first() - - viewModel.onBlockFocused(block) - assertEquals(block.id, viewModel.interactedBlockId.value) - assertEquals(0, viewModel.indexOfBlock(block)) - - viewModel.onBlockFocusLost(block) - viewModel.onBlockInteraction(block) - assertEquals(block.id, viewModel.interactedBlockId.value) - assertEquals(-1, viewModel.indexOfBlock(Block.TextBlock())) - } - - private fun storeNote(isTrash: Boolean = false) { - val note = Note( - id = NOTE_ID, - title = "Stored", - value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("body")))), - isTrash = isTrash, - ) - repository.allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) - } - - private fun viewModel(handle: SavedStateHandle) = EditNoteViewModel( - notesRepository = repository, - savedStateHandle = handle, - updateNoteTrashStatusUseCase = UpdateNoteTrashStatusUseCase(repository), - updateNoteArchiveStatusUseCase = UpdateNoteArchiveStatusUseCase(repository), - settingsRepository = settings, - appScope = CoroutineScope(dispatcher), - ioDispatcher = dispatcher, - copyTitle = { "$it (Copy)" }, - ) - - private companion object { - const val NOTE_ID = 12L - const val INSERTED_ID = 40L - const val TAG_ID = 6L - const val FONT_SIZE = 20 - const val DEFAULT_FONT_SIZE = 16 - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt deleted file mode 100644 index fd4f138f..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ /dev/null @@ -1,321 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.viewmodel.CreationExtras -import com.pasich.encly.core.security.AuthStrategy -import com.pasich.encly.core.security.SecurityManager -import com.pasich.encly.core.security.SessionLockManager -import com.pasich.encly.core.security.VaultUnlockResult -import com.pasich.encly.testutil.MockActivity -import com.pasich.encly.testutil.answerCallback -import com.pasich.encly.testutil.anyByteArray -import com.pasich.encly.testutil.anyCallback -import com.pasich.encly.testutil.anyCharArray -import com.pasich.encly.testutil.eqValue -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.ArgumentMatchers -import org.mockito.Mockito.doAnswer -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.timeout -import org.mockito.Mockito.times -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.reflect.KClass - -/** The lock screen: PIN, recovery words and biometric unlock, and an unlock that lands in the background. */ -@OptIn(ExperimentalCoroutinesApi::class) -class LockViewModelTest { - private lateinit var security: SecurityManager - private lateinit var sessionLock: SessionLockManager - private lateinit var viewModel: LockViewModel - private val host = MockActivity() - private val activity: FragmentActivity = host.activity - - @Before - fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - security = mock(SecurityManager::class.java) - sessionLock = SessionLockManager(security) - sessionLock.onStart(mock(LifecycleOwner::class.java)) - viewModel = LockViewModel(security, sessionLock, CoroutineScope(Dispatchers.Default)) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun theRightPinUnlocks() = runTest { - `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) - - assertEquals(PinUnlockResult.SUCCESS, pin()) - assertFalse(viewModel.busy.value) - assertFalse(sessionLock.locked.value) - } - - @Test - fun theRestOfAWipePinEraseRunsAfterThePinUnlockedAndOnlyThen() = runTest { - `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) - pin() - verify(security, never()).completePendingWipe() - - `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) - assertEquals(PinUnlockResult.SUCCESS, pin()) - - // On the application scope, off the unlock's path. - verify(security, timeout(WAIT_MS)).completePendingWipe() - } - - @Test - fun aNoteIsReopenedOnlyInTheVaultItWasRememberedIn() { - `when`(security.eraseEpoch()).thenReturn(7L) - - assertTrue(viewModel.canReopenNote(7L)) - assertFalse("an erase changed the epoch", viewModel.canReopenNote(6L)) - assertFalse("nothing remembered", viewModel.canReopenNote(null)) - } - - @Test - fun aWrongPinAndABrokenVaultAreToldApart() = runTest { - `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) - assertEquals(PinUnlockResult.WRONG_PIN, pin()) - - `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.DB_ERROR) - assertEquals(PinUnlockResult.DB_ERROR, pin()) - - verify(security, never()).lock() - } - - @Test - fun aPinUnlockThatFinishesInTheBackgroundClosesTheVaultAgain() = runTest { - `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) - sessionLock.onStop(mock(LifecycleOwner::class.java)) - - assertEquals(PinUnlockResult.BACKGROUNDED, pin()) - verify(security).lock() - assertTrue(sessionLock.locked.value) - } - - @Test - fun anUnlockThatFinishesAfterTheScreenIsGoneClosesTheVaultAgain() { - // The PIN check (KDF) is running when the lock screen's ViewModel is cleared. - val entered = CountDownLatch(1) - val release = CountDownLatch(1) - `when`(security.unlockWithPin(anyCharArray())).thenAnswer { - entered.countDown() - release.await(5, TimeUnit.SECONDS) - VaultUnlockResult.SUCCESS - } - `when`(security.isLockable()).thenReturn(true) - `when`(security.isDatabaseUnlocked()).thenReturn(true) - val store = ViewModelStore() - val owned = ViewModelProvider.create( - store, - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: KClass, extras: CreationExtras): T = viewModel as T - }, - )[LockViewModel::class] - var published: PinUnlockResult? = null - - owned.authenticatePin(PIN.toCharArray()) { published = it } - assertTrue(entered.await(5, TimeUnit.SECONDS)) - store.clear() - release.countDown() - - verify(security, timeout(5_000)).lock() - assertTrue(sessionLock.locked.value) - assertEquals("nothing navigates into the vault", null, published) - } - - @Test - fun aLostPinKeyAndALockoutAreReported() = runTest { - `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.PIN_KEY_LOST) - assertEquals(PinUnlockResult.KEY_LOST, pin()) - - `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.LOCKED_OUT) - assertEquals(PinUnlockResult.LOCKED_OUT, pin()) - } - - @Test - fun theRecoveryWordsUnlockAndAreWipedAfterwards() = runTest { - `when`(security.unlockWithSeed(ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0))) - .thenReturn(VaultUnlockResult.SUCCESS) - val words = WORDS.toCharArray() - - assertEquals(SeedUnlockResult.SUCCESS, seed(words)) - assertArrayEquals(CharArray(words.size), words) - } - - @Test - fun wrongRecoveryWordsAreReportedAndStillWiped() = runTest { - `when`(security.unlockWithSeed(ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0))) - .thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) - val words = WORDS.toCharArray() - - assertEquals(SeedUnlockResult.WRONG_SEED, seed(words)) - assertArrayEquals(CharArray(words.size), words) - - `when`(security.unlockWithSeed(ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0))) - .thenReturn(VaultUnlockResult.DB_ERROR) - assertEquals(SeedUnlockResult.DB_ERROR, seed(WORDS.toCharArray())) - } - - @Test - fun aSeedUnlockThatFinishesInTheBackgroundIsNotPublished() = runTest { - `when`(security.unlockWithSeed(ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0))) - .thenReturn(VaultUnlockResult.SUCCESS) - sessionLock.onStop(mock(LifecycleOwner::class.java)) - - assertEquals(SeedUnlockResult.BACKGROUNDED, seed(WORDS.toCharArray())) - } - - @Test - fun aBiometricKeyUnlocksAndIsWiped() = runTest { - val key = ByteArray(KEY_LENGTH) { 7 } - answerCallback(key).`when`(security).requestBiometricKey(eqValue(activity), anyCallback()) - `when`(security.unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean())).thenReturn(true) - - assertTrue(biometric()) - assertArrayEquals(ByteArray(KEY_LENGTH), key) - } - - @Test - fun aCancelledPromptOrARejectedKeyDoesNotUnlock() = runTest { - answerCallback(null).`when`(security).requestBiometricKey(eqValue(activity), anyCallback()) - assertFalse(biometric()) - verify(security, never()).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) - - answerCallback(ByteArray(KEY_LENGTH)).`when`(security) - .requestBiometricKey(eqValue(activity), anyCallback()) - `when`(security.unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean())).thenReturn(false) - assertFalse(biometric()) - } - - @Test - fun aSecondBiometricRequestWhileOneIsOpenIsIgnored() { - // The prompt never answers: the first request stays in flight. - viewModel.authenticateBiometric(activity) {} - viewModel.authenticateBiometric(activity) {} - - verify(security, times(1)).requestBiometricKey(eqValue(activity), anyCallback()) - assertTrue(viewModel.biometricInFlight) - } - - @Test - fun aRotationDuringThePromptReleasesItForTheRecreatedScreen() { - viewModel.authenticateBiometric(activity) {} - assertTrue(viewModel.biometricInFlight) - - // androidx.biometric drops the answer of a prompt whose activity is gone: without this the - // ViewModel, which outlives the rotation, would never prompt again. - host.destroy() - - assertFalse(viewModel.biometricInFlight) - val recreated = MockActivity().activity - viewModel.authenticateBiometric(recreated) {} - verify(security).requestBiometricKey(eqValue(recreated), anyCallback()) - } - - @Test - fun aLateAnswerFromTheDestroyedActivityIsDroppedAndItsKeyWiped() { - val answer = arrayOfNulls<(ByteArray?) -> Unit>(1) - doAnswer { invocation -> - @Suppress("UNCHECKED_CAST") - answer[0] = invocation.arguments.last() as (ByteArray?) -> Unit - null - }.`when`(security).requestBiometricKey(eqValue(activity), anyCallback()) - val results = mutableListOf() - viewModel.authenticateBiometric(activity) { results += it } - host.destroy() - - val key = ByteArray(KEY_LENGTH) { 7 } - answer[0]!!(key) - - assertTrue(results.isEmpty()) - assertArrayEquals(ByteArray(KEY_LENGTH), key) - verify(security, never()).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) - } - - @Test - fun aDestroyedActivityGetsNoPrompt() { - host.destroy() - - viewModel.authenticateBiometric(activity) {} - - verify(security, never()).requestBiometricKey(eqValue(activity), anyCallback()) - assertFalse(viewModel.biometricInFlight) - } - - @Test - fun theStrategyDecidesWhichFormTheLockScreenShows() { - `when`(security.authStrategy()).thenReturn(AuthStrategy.SEED_PHRASE_BIOMETRIC) - assertTrue(viewModel.isSeedStrategy()) - `when`(security.authStrategy()).thenReturn(AuthStrategy.SEED_PHRASE) - assertTrue(viewModel.isSeedStrategy()) - `when`(security.authStrategy()).thenReturn(AuthStrategy.PIN_BIOMETRIC) - assertFalse(viewModel.isSeedStrategy()) - assertEquals(AuthStrategy.PIN_BIOMETRIC, viewModel.strategy()) - } - - @Test - fun theLockScreenAsksTheVaultWhatItCanOffer() { - `when`(security.isBiometricEnabled()).thenReturn(true) - `when`(security.biometricAvailable()).thenReturn(false) - `when`(security.hasRecoverySeed()).thenReturn(true) - `when`(security.pinLockoutRemainingMillis()).thenReturn(LOCKOUT_MS) - - assertTrue(viewModel.biometricEnabled()) - assertFalse(viewModel.biometricAvailable()) - assertTrue(viewModel.recoveryAvailable()) - assertEquals(LOCKOUT_MS, viewModel.lockoutRemainingMillis()) - } - - private suspend fun pin(): PinUnlockResult { - val result = CompletableDeferred() - viewModel.authenticatePin(PIN.toCharArray()) { result.complete(it) } - return result.await() - } - - private suspend fun seed(words: CharArray): SeedUnlockResult { - val result = CompletableDeferred() - viewModel.authenticateSeed(words) { result.complete(it) } - return result.await() - } - - private suspend fun biometric(): Boolean { - val result = CompletableDeferred() - viewModel.authenticateBiometric(activity) { result.complete(it) } - return result.await() - } - - private companion object { - const val PIN = "482915" - const val WORDS = "one two three four five six seven eight nine ten eleven twelve" - const val KEY_LENGTH = 32 - const val LOCKOUT_MS = 30_000L - const val WAIT_MS = 5_000L - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt deleted file mode 100644 index 8e3d0483..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt +++ /dev/null @@ -1,396 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import android.net.Uri -import com.pasich.encly.R -import com.pasich.encly.core.backup.BackupError -import com.pasich.encly.core.backup.BackupException -import com.pasich.encly.core.common.AppStrings -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.backup.VaultSnapshot -import com.pasich.encly.data.export.MarkdownDocuments -import com.pasich.encly.data.model.Note -import com.pasich.encly.testutil.InMemoryVaultDataStore -import com.pasich.encly.testutil.anyCharArray -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -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.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.ArgumentMatchers.any -import org.mockito.Mockito.doAnswer -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.OutputStream -import java.util.zip.ZipInputStream - -@OptIn(ExperimentalCoroutinesApi::class) -class MarkdownExportViewModelTest { - - private lateinit var security: SecurityManager - private lateinit var documents: MarkdownDocuments - private lateinit var store: InMemoryVaultDataStore - private lateinit var sessionLock: SessionLockManager - private lateinit var viewModel: MarkdownExportViewModel - private val written = ByteArrayOutputStream() - private val uri: Uri = mock(Uri::class.java) - - @Before - fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - security = mock(SecurityManager::class.java) - `when`(security.verifyPin(anyCharArray())).thenReturn(true) - documents = mock(MarkdownDocuments::class.java) - doAnswer { invocation -> - @Suppress("UNCHECKED_CAST") - (invocation.getArgument(1) as (OutputStream) -> Unit)(written) - }.`when`(documents).write(any(Uri::class.java) ?: uri, anyBlock()) - store = InMemoryVaultDataStore().apply { - notes += Note(id = 1, title = "Groceries", value = "[]", uid = "n1", date = 5L) - notes += Note(id = 2, title = "Trashed", value = "[]", uid = "n2", isTrash = true) - } - sessionLock = SessionLockManager(security) - viewModel = viewModelOver(store) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - private fun viewModelOver(vaultStore: VaultDataStore) = MarkdownExportViewModel( - securityManager = security, - sessionLockManager = sessionLock, - store = vaultStore, - documents = documents, - strings = mock(AppStrings::class.java) { "label" }, - ) - - private fun anyBlock(): (OutputStream) -> Unit = any() ?: {} - - private fun step() = viewModel.uiState.value.step - - private fun authenticate() { - viewModel.confirmWarning() - viewModel.reauthFlow.submitPin("123456".toCharArray()) - waitUntil { step() is BackupStep.PickMarkdownTarget || step() == BackupStep.Idle } - } - - /** Lets the work the ViewModel started on other threads finish, so none outlives the test. */ - private fun settle() = waitUntil { true } - - private fun waitUntil(condition: () -> Boolean) { - val deadline = System.currentTimeMillis() + TIMEOUT_MS - while (!condition() || viewModel.uiState.value.busy) { - check(System.currentTimeMillis() < deadline) { "state is ${viewModel.uiState.value}" } - Thread.sleep(POLL_MS) - } - } - - private fun zipNames(): List = ZipInputStream(ByteArrayInputStream(written.toByteArray())).use { zip -> - generateSequence { zip.nextEntry }.map { it.name }.toList() - } - - // --- the order: warning, PIN, save dialog, write ------------------------------------------------- - - @Test - fun theWarningComesFirstAndNothingIsReadOrWritten() { - viewModel.start(MarkdownExportTarget.AllData) - - assertEquals(BackupStep.ConfirmPlainExport, step()) - verify(documents, never()).write(any(Uri::class.java) ?: uri, anyBlock()) - verify(security, never()).verifyPin(anyCharArray()) - } - - @Test - fun continuingFromTheWarningAsksForThePinWithTheExistingReauthStep() { - viewModel.start(MarkdownExportTarget.AllData) - - viewModel.confirmWarning() - - assertEquals(BackupStep.Reauth(BackupAction.EXPORT_MARKDOWN, biometric = false), step()) - } - - @Test - fun theWarningCannotBeSkippedByAskingForThePinDirectly() { - // Without start() there is no export pending: a PIN submitted now has nowhere to go. - viewModel.confirmWarning() - - assertEquals(BackupStep.Idle, step()) - } - - @Test - fun aWrongPinStaysOnTheReauthAndReachesNothingElse() { - `when`(security.verifyPin(anyCharArray())).thenReturn(false) - viewModel.start(MarkdownExportTarget.AllData) - viewModel.confirmWarning() - - viewModel.reauthFlow.submitPin("000000".toCharArray()) - waitUntil { (step() as? BackupStep.Reauth)?.error != null } - - assertEquals(R.string.lock_wrong_pin, (step() as BackupStep.Reauth).error) - verify(documents, never()).write(any(Uri::class.java) ?: uri, anyBlock()) - } - - @Test - fun theExportNeverAsksForARecoveryPhraseOrSealsABackup() { - // The encrypted export would stop at NeedsPhrase for a vault without a phrase. - viewModel.start(MarkdownExportTarget.AllData) - authenticate() - - assertTrue(step() is BackupStep.PickMarkdownTarget) - verify(security, never()).hasBackupKey() - verify(security, never()).copyBackupRootKey() - } - - @Test - fun theSaveDialogSuggestsADateOnlyNameNeverATitle() { - viewModel.start(MarkdownExportTarget.AllData) - authenticate() - val all = step() as BackupStep.PickMarkdownTarget - assertTrue(all.zip) - assertTrue(all.name.matches(Regex("""encly-notes-\d{4}-\d{2}-\d{2}\.zip"""))) - - viewModel.cancel() - viewModel.start(MarkdownExportTarget.SingleNote(noteId = 1)) - authenticate() - val one = step() as BackupStep.PickMarkdownTarget - assertFalse(one.zip) - assertTrue(one.name.matches(Regex("""encly-note-\d{4}-\d{2}-\d{2}\.md"""))) - } - - // --- writing --------------------------------------------------------------------------------------- - - @Test - fun theZipIsWrittenAfterTheSaveDialogReturns() { - viewModel.start(MarkdownExportTarget.AllData) - authenticate() - viewModel.onSystemPickerLaunched() - - viewModel.onTarget(uri) - waitUntil { viewModel.uiState.value.message != null } - - assertEquals(BackupMessage.Text(R.string.markdown_export_done), viewModel.uiState.value.message) - assertEquals(listOf("tasks.md", "Groceries.md"), zipNames()) - assertEquals(BackupStep.Idle, step()) - } - - @Test - fun oneNoteIsWrittenAsOneMarkdownDocument() { - viewModel.start(MarkdownExportTarget.SingleNote(noteId = 1)) - authenticate() - viewModel.onSystemPickerLaunched() - - viewModel.onTarget(uri) - waitUntil { viewModel.uiState.value.message != null } - - assertTrue(written.toString(Charsets.UTF_8).startsWith("---\ntitle: \"Groceries\"\n")) - } - - @Test - fun aCancelledSaveDialogWritesNothingAndKeepsNothing() { - viewModel.start(MarkdownExportTarget.AllData) - authenticate() - viewModel.onSystemPickerLaunched() - - viewModel.onTarget(null) - viewModel.onTarget(uri) - settle() - - // The pending export was dropped with the first call: a second result has nothing to write. - // The empty document it names is removed, because nothing will be written to it. - assertEquals(0, written.size()) - verify(documents).discard(uri) - assertEquals(BackupStep.Idle, step()) - } - - @Test - fun cancellingAtAnyStepDropsTheExport() { - viewModel.start(MarkdownExportTarget.AllData) - viewModel.cancel() - assertEquals(BackupStep.Idle, step()) - - viewModel.start(MarkdownExportTarget.AllData) - viewModel.confirmWarning() - viewModel.cancel() - assertEquals(BackupStep.Idle, step()) - - viewModel.onTarget(uri) - settle() - assertEquals(0, written.size()) - } - - @Test - fun aVaultThatClosedMeanwhileFailsInsteadOfExportingAndTheEmptyDocumentIsRemoved() { - val closing = object : VaultDataStore by store { - override fun bindToCurrentSession(): VaultDataStore = this - - override suspend fun snapshot(): VaultSnapshot = error("Vault locked") - } - viewModel = viewModelOver(closing) - viewModel.start(MarkdownExportTarget.AllData) - authenticate() - viewModel.onSystemPickerLaunched() - - viewModel.onTarget(uri) - waitUntil { viewModel.uiState.value.message != null } - - assertEquals(BackupMessage.Text(R.string.backup_error_locked), viewModel.uiState.value.message) - verify(documents).discard(uri) - assertEquals(0, written.size()) - } - - @Test - fun aReLockAfterTheReadStopsTheWriteBeforeTheDocumentIsOpened() { - val closing = object : VaultDataStore by store { - override fun bindToCurrentSession(): VaultDataStore = this - - // The read succeeds; the vault closes right after it. - override fun assertCurrentSession() = error("Vault locked") - } - viewModel = viewModelOver(closing) - viewModel.start(MarkdownExportTarget.AllData) - authenticate() - viewModel.onSystemPickerLaunched() - - viewModel.onTarget(uri) - waitUntil { viewModel.uiState.value.message != null } - - assertEquals(BackupMessage.Text(R.string.backup_error_locked), viewModel.uiState.value.message) - verify(documents, never()).write(any(Uri::class.java) ?: uri, anyBlock()) - verify(documents).discard(uri) - assertEquals(0, written.size()) - } - - @Test - fun aPinThatVerifiesAfterTheFlowWasCancelledCannotSkipTheNextWarning() { - val release = java.util.concurrent.CountDownLatch(1) - `when`(security.verifyPin(anyCharArray())).thenAnswer { - release.await(TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS) - true - } - viewModel.start(MarkdownExportTarget.AllData) - viewModel.confirmWarning() - viewModel.reauthFlow.submitPin("123456".toCharArray()) - - // The user backs out and starts again while the first PIN is still being checked. - viewModel.cancel() - viewModel.start(MarkdownExportTarget.AllData) - release.countDown() - waitUntil { true } - - assertEquals(BackupStep.ConfirmPlainExport, step()) - verify(documents, never()).write(any(Uri::class.java) ?: uri, anyBlock()) - } - - @Test - fun oneNoteIsReadWithoutReadingTheWholeVault() { - val noteOnly = object : VaultDataStore by store { - override fun bindToCurrentSession(): VaultDataStore = this - - override suspend fun snapshot(): VaultSnapshot = error("the whole vault must not be read") - } - viewModel = viewModelOver(noteOnly) - viewModel.start(MarkdownExportTarget.SingleNote(noteId = 1)) - authenticate() - viewModel.onSystemPickerLaunched() - - viewModel.onTarget(uri) - waitUntil { viewModel.uiState.value.message != null } - - assertEquals(BackupMessage.Text(R.string.markdown_export_done), viewModel.uiState.value.message) - assertTrue(written.toString(Charsets.UTF_8).contains("Groceries")) - } - - @Test - fun theFingerprintIsOfferedOnlyWhereTheSecurityLayerAllowsIt() { - `when`(security.canReauthWithBiometric()).thenReturn(true) - viewModel.start(MarkdownExportTarget.AllData) - viewModel.confirmWarning() - assertEquals(true, (step() as BackupStep.Reauth).biometric) - - viewModel.cancel() - `when`(security.canReauthWithBiometric()).thenReturn(false) - viewModel.start(MarkdownExportTarget.AllData) - viewModel.confirmWarning() - assertEquals(false, (step() as BackupStep.Reauth).biometric) - } - - @Test - fun aVaultThatIsAlreadyLockedWhenTheExportIsAuthenticatedEndsWithAMessage() { - val locked = object : VaultDataStore by store { - override fun bindToCurrentSession(): VaultDataStore = error("Vault locked") - } - viewModel = viewModelOver(locked) - viewModel.start(MarkdownExportTarget.AllData) - viewModel.confirmWarning() - viewModel.reauthFlow.submitPin("123456".toCharArray()) - waitUntil { viewModel.uiState.value.message != null } - - assertEquals(BackupMessage.Text(R.string.backup_error_locked), viewModel.uiState.value.message) - assertEquals(BackupStep.Idle, step()) - } - - @Test - fun aProviderFailureIsReportedWithoutItsMessage() { - doAnswer { throw BackupException(BackupError.TARGET_EXISTS) } - .`when`(documents).write(any(Uri::class.java) ?: uri, anyBlock()) - viewModel.start(MarkdownExportTarget.AllData) - authenticate() - viewModel.onSystemPickerLaunched() - - viewModel.onTarget(uri) - waitUntil { viewModel.uiState.value.message != null } - - assertEquals(BackupMessage.Text(R.string.backup_error_target_exists), viewModel.uiState.value.message) - } - - @Test - fun aNoteThatIsGoneMeanwhileFailsTheWholeWriteSoTheDocumentIsDiscardedByTheWriter() { - viewModel.start(MarkdownExportTarget.SingleNote(noteId = 99)) - authenticate() - viewModel.onSystemPickerLaunched() - // The real writer deletes the document when its block throws; the fake only runs the block. - doAnswer { invocation -> - @Suppress("UNCHECKED_CAST") - runCatching { (invocation.getArgument(1) as (OutputStream) -> Unit)(written) } - .getOrElse { throw it } - }.`when`(documents).write(any(Uri::class.java) ?: uri, anyBlock()) - - viewModel.onTarget(uri) - waitUntil { viewModel.uiState.value.message != null } - - assertEquals(BackupMessage.Text(R.string.backup_error_io), viewModel.uiState.value.message) - } - - @Test - fun theDecoyVaultExportsWhatItHolds() { - `when`(security.isDecoySession()).thenReturn(true) - store.notes.clear() - store.notes += Note(id = 1, title = "Decoy note", value = "[]", uid = "d1") - viewModel.start(MarkdownExportTarget.AllData) - authenticate() - viewModel.onSystemPickerLaunched() - - viewModel.onTarget(uri) - waitUntil { viewModel.uiState.value.message != null } - - assertEquals(listOf("tasks.md", "Decoy note.md"), zipNames()) - } - - private companion object { - const val TIMEOUT_MS = 5_000L - const val POLL_MS = 10L - } -} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt deleted file mode 100644 index b745f2c7..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt +++ /dev/null @@ -1,233 +0,0 @@ -package com.pasich.encly.presentation.viewmodel - -import androidx.fragment.app.FragmentActivity -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.common.UiText -import com.pasich.encly.core.security.SecurityManager -import com.pasich.encly.data.backup.BackupDocuments -import com.pasich.encly.data.backup.BackupManager -import com.pasich.encly.data.backup.PendingRestore -import com.pasich.encly.domain.usecase.OnboardingUseCase -import com.pasich.encly.testutil.InMemorySharedPreferences -import com.pasich.encly.testutil.InMemoryVaultDataStore -import com.pasich.encly.testutil.answerCallback -import com.pasich.encly.testutil.anyCallback -import com.pasich.encly.testutil.anyCharArray -import com.pasich.encly.testutil.eqValue -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.UnconfinedTestDispatcher -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.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.ArgumentMatchers -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` - -/** Onboarding when something fails, plus the small switches of its steps. */ -@OptIn(ExperimentalCoroutinesApi::class) -class OnboardingFailuresTest { - private val words = MnemonicCode(WordCount.COUNT_12).chars - private lateinit var security: SecurityManager - private val activity: FragmentActivity = mock(FragmentActivity::class.java) - - @Before - fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - security = mock(SecurityManager::class.java) - `when`(security.generateMnemonicCode()).thenAnswer { words.copyOf() } - `when`(security.initializeNewVault(anyChars())).thenReturn(true) - `when`(security.configurePin(anyCharArray())).thenReturn(true) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun aVaultThatCannotBeCreatedOnSkipShowsAnErrorAndStays() = runTest { - `when`(security.initializeNewVault(anyChars())).thenReturn(false) - val vm = atRecoveryInfo() - - vm.skipPhrase() - val state = vm.uiState.first { !it.isLoading } - - assertEquals(OnboardingStep.RECOVERY_INFO, state.step) - assertEquals(UiText.of(R.string.onboarding_error_initialization), state.error) - verify(security, never()).configurePin(anyCharArray()) - } - - @Test - fun aVaultThatCannotBeCreatedAfterTheCheckShowsAnErrorAndCanBeRetried() = runTest { - `when`(security.initializeNewVault(anyChars())).thenReturn(false) - val vm = atRecoveryInfo() - vm.createPhrase() - vm.startSeedPhraseVerification() - vm.uiState.value.verificationWords.forEach { (index, word) -> vm.updateUserAnswer(index, word) } - - vm.completeVerification() - val failed = vm.uiState.first { !it.isLoading } - assertEquals(UiText.of(R.string.onboarding_error_vault_creation), failed.error) - assertEquals(OnboardingStep.VERIFY, failed.step) - - `when`(security.initializeNewVault(anyChars())).thenReturn(true) - vm.completeVerification() - - assertEquals(OnboardingStep.READY, vm.uiState.first { it.step == OnboardingStep.READY }.step) - } - - @Test - fun wordsThatCannotBeGeneratedShowAnError() { - `when`(security.generateMnemonicCode()).thenThrow(IllegalStateException("no entropy")) - val vm = atRecoveryInfo() - - vm.createPhrase() - - assertTrue(vm.uiState.value.words.isEmpty()) - assertEquals(UiText.of(R.string.onboarding_error_seed_generation), vm.uiState.value.error) - } - - @Test - fun theWordsCanBeHiddenAndShownAgain() { - val vm = atRecoveryInfo() - vm.createPhrase() - val hidden = vm.uiState.value.wordsHidden - - vm.toggleWordsHidden() - assertEquals(!hidden, vm.uiState.value.wordsHidden) - vm.toggleWordsHidden() - assertEquals(hidden, vm.uiState.value.wordsHidden) - } - - @Test - fun answersAreComparedTrimmedAndIgnoringCase() { - val vm = atRecoveryInfo() - vm.createPhrase() - vm.startSeedPhraseVerification() - val checks = vm.uiState.value.verificationWords - - checks.forEach { (index, word) -> vm.updateUserAnswer(index, " ${word.uppercase()} ") } - - assertTrue(vm.uiState.value.isVerificationComplete) - } - - @Test - fun oneWrongAnswerKeepsTheCheckOpen() { - val vm = atRecoveryInfo() - vm.createPhrase() - vm.startSeedPhraseVerification() - val checks = vm.uiState.value.verificationWords - - checks.forEach { (index, word) -> vm.updateUserAnswer(index, word) } - vm.updateUserAnswer(checks.first().first, "zzz") - vm.completeVerification() - - assertFalse(vm.uiState.value.isVerificationComplete) - verify(security, never()).initializeNewVault(anyChars()) - } - - @Test - fun skippingTheFingerprintContinuesWithThePinOnly() = runTest { - val vm = readyWithBiometricPending() - - vm.skipBiometric() - - assertFalse(vm.uiState.value.biometricPending) - assertFalse(vm.uiState.value.biometricEnabled) - verify(security, never()).enrollBiometric(eqValue(activity), anyCallback()) - } - - @Test - fun theFingerprintPromptIsShownOnceAndItsResultKept() = runTest { - answerCallback(true).`when`(security).enrollBiometric(eqValue(activity), anyCallback()) - val vm = readyWithBiometricPending() - - vm.enrollBiometric(activity) - vm.enrollBiometric(activity) - - assertTrue(vm.uiState.value.biometricEnabled) - verify(security).enrollBiometric(eqValue(activity), anyCallback()) - } - - @Test - fun noDocumentPickerIsReportedOnTheRestoreStep() { - val vm = viewModel() - vm.startRestore() - - vm.onRestorePickerUnavailable() - - assertEquals(UiText.of(R.string.backup_error_no_picker), vm.uiState.value.restoreFileError) - } - - @Test - fun wordsTypedBeforeAFileIsPickedAreIgnored() { - val vm = viewModel() - vm.startRestore() - vm.onRestoreFilePicked(null) - - vm.restoreBackup(String(words).toCharArray()) - - assertFalse(vm.uiState.value.isLoading) - verify(security, never()).initializeNewVault(anyChars()) - } - - @Test - fun anAnswerIsWrongOnlyOnceItIsAsLongAsTheWord() { - assertEquals(AnswerState.EMPTY, phraseAnswerState("orbit", " ")) - assertEquals(AnswerState.TYPING, phraseAnswerState("orbit", "orb")) - assertEquals(AnswerState.TYPING, phraseAnswerState("orbit", "xyz")) - assertEquals(AnswerState.WRONG, phraseAnswerState("orbit", "orbix")) - assertEquals(AnswerState.CORRECT, phraseAnswerState("orbit", " ORBIT ")) - assertEquals(AnswerState.TYPING, phraseAnswerState(null, "orbit")) - } - - private fun viewModel(biometric: Boolean = false): OnboardingViewModel { - `when`(security.biometricAvailable()).thenReturn(biometric) - val backupManager = BackupManager(security, InMemoryVaultDataStore(), InMemorySharedPreferences()) - return OnboardingViewModel( - OnboardingUseCase(security, backupManager, PendingRestore(backupManager)), - mock(BackupDocuments::class.java), - ) - } - - private fun atRecoveryInfo(): OnboardingViewModel = viewModel().apply { - getStarted() - repeat(2) { - PIN.forEach { onPinDigit(it.digitToInt()) } - submitPin() - } - check(uiState.value.step == OnboardingStep.RECOVERY_INFO) { "at ${uiState.value.step}" } - } - - private suspend fun readyWithBiometricPending(): OnboardingViewModel { - val vm = viewModel(biometric = true) - vm.getStarted() - vm.setBiometricRequested(true) - repeat(2) { - PIN.forEach { vm.onPinDigit(it.digitToInt()) } - vm.submitPin() - } - vm.skipPhrase() - vm.uiState.first { it.step == OnboardingStep.READY && !it.isLoading } - check(vm.uiState.value.biometricPending) { "no biometric pending" } - return vm - } - - private fun anyChars(): CharArray? = ArgumentMatchers.any() - - private companion object { - const val PIN = "482915" - } -} diff --git a/app/src/test/java/com/pasich/encly/release/CodeQualityToolingTest.kt b/app/src/test/java/com/pasich/encly/release/CodeQualityToolingTest.kt index 075e2d19..ed49cd69 100644 --- a/app/src/test/java/com/pasich/encly/release/CodeQualityToolingTest.kt +++ b/app/src/test/java/com/pasich/encly/release/CodeQualityToolingTest.kt @@ -40,9 +40,12 @@ class CodeQualityToolingTest { @Test fun detektLoadsTheComposeRules() { val config = File(root, "config/detekt/detekt.yml").readText() - val build = File(root, "app/build.gradle.kts").readText() + val build = File(root, "build-logic/src/main/kotlin/encly.quality.gradle.kts").readText() assertTrue("detekt.yml must configure the Compose rule set", Regex("(?m)^Compose:$").containsMatchIn(config)) - assertTrue("the Compose rules must be a detekt plugin", "detektPlugins(libs.compose.rules.detekt)" in build) + assertTrue( + "the Compose rules must be a detekt plugin", + """add("detektPlugins", libs.findLibrary("compose-rules-detekt")""" in build, + ) } @Test diff --git a/app/src/test/java/com/pasich/encly/release/NoColorLiteralsTest.kt b/app/src/test/java/com/pasich/encly/release/NoColorLiteralsTest.kt index aca5312b..6e030d3a 100644 --- a/app/src/test/java/com/pasich/encly/release/NoColorLiteralsTest.kt +++ b/app/src/test/java/com/pasich/encly/release/NoColorLiteralsTest.kt @@ -2,7 +2,6 @@ package com.pasich.encly.release import org.junit.Assert.assertTrue import org.junit.Test -import java.io.File /** * Screens read colour roles (MaterialTheme.colorScheme.*) and never raw colours: a hex colour @@ -11,20 +10,18 @@ import java.io.File */ class NoColorLiteralsTest { - private val sourceRoot = listOf(File("src/main/java"), File("app/src/main/java")).first { it.isDirectory } + private val sources = ShippedSources.files(setOf("kt")) @Test fun colorLiteralsOnlyInUiTheme() { - val offenders = sourceRoot.walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .filterNot { it.invariantSeparatorsPath.contains("/com/pasich/encly/ui/theme/") } - .flatMap { file -> + assertTrue("no sources found", sources.size > 250) + val offenders = sources + .filterNot { (_, path) -> path.contains("/com/pasich/encly/ui/theme/") } + .flatMap { (file, path) -> file.readLines().mapIndexedNotNull { index, line -> - "${file.relativeTo(sourceRoot).invariantSeparatorsPath}:${index + 1}" - .takeIf { COLOR_LITERAL.containsMatchIn(line) } + "$path:${index + 1}".takeIf { COLOR_LITERAL.containsMatchIn(line) } } } - .toList() assertTrue("Use MaterialTheme.colorScheme roles instead of colour literals: $offenders", offenders.isEmpty()) } diff --git a/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt b/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt index 639f9e3b..580fc05b 100644 --- a/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt +++ b/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt @@ -3,7 +3,6 @@ package com.pasich.encly.release import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import java.io.File /** * Screens take their spacing and sizes from the design system (EnclyTheme.spacing and the @@ -12,20 +11,19 @@ import java.io.File */ class NoDpLiteralsTest { - private val sourceRoot = listOf(File("src/main/java"), File("app/src/main/java")).first { it.isDirectory } + private val sources = ShippedSources.files(setOf("kt")) @Test fun dpLiteralsOnlyInTheDesignSystem() { - val offenders = sourceRoot.walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .filterNot { file -> ALLOWED.any { file.invariantSeparatorsPath.contains(it) } } - .flatMap { file -> + assertTrue("no sources found", sources.size > 250) + val offenders = sources + .filterNot { (_, path) -> ALLOWED.any { path.contains(it) } } + .flatMap { (file, path) -> file.readLines().mapIndexedNotNull { index, line -> - "${file.relativeTo(sourceRoot).invariantSeparatorsPath}:${index + 1}" + "$path:${index + 1}" .takeIf { DP_LITERAL.findAll(line).any { it.groupValues[1].toDouble() != 0.0 } } } } - .toList() assertTrue( "Use EnclyTheme.spacing or a design-system component instead of dp literals: $offenders", offenders.isEmpty(), @@ -41,7 +39,10 @@ class NoDpLiteralsTest { } private companion object { - val ALLOWED = listOf("/com/pasich/encly/ui/theme/", "/com/pasich/encly/presentation/designsystem/") + val ALLOWED = listOf( + "/com/pasich/encly/ui/theme/", + "/com/pasich/encly/presentation/designsystem/", + ) val DP_LITERAL = Regex("""(? - val path = file.relativeTo(mainRoot).invariantSeparatorsPath + assertTrue("no sources found", sources.size > 250) + val offenders = sources + .flatMap { (file, path) -> file.readLines().mapIndexedNotNull { index, line -> if (isComment(line)) return@mapIndexedNotNull null BANNED.filter { (_, rule) -> rule.matches(path, line) } @@ -34,7 +32,6 @@ class NoPlaintextExportTest { .takeIf { it.isNotEmpty() } }.flatten() } - .toList() assertTrue( "Plaintext must not leave the app except as an encrypted backup or the warned, re-authenticated " + "Markdown export (see SECURITY.md): $offenders", @@ -56,6 +53,7 @@ class NoPlaintextExportTest { "clipboard write" to "clipboard.setClipEntry(entry)", "clipboard write" to "val clipboard = LocalClipboardManager.current", "document creation" to "ActivityResultContracts.CreateDocument(\"text/plain\")", + "document creation" to "val launcher = pickers.rememberCreateDocument(\"text/plain\") { }", "document creation" to "Intent(Intent.ACTION_CREATE_DOCUMENT)", ) caught.forEach { (name, line) -> @@ -67,8 +65,10 @@ class NoPlaintextExportTest { OTHER_FILE to "val intent = Intent(Intent.ACTION_SENDTO).apply {", SENSITIVE_CLIP to "clipboard.setPrimaryClip(clip)", SENSITIVE_CLIP to "delegate.setClipEntry(clipEntry)", - BACKUP_SCREEN to "ActivityResultContracts.CreateDocument(BackupFormat.MIME_TYPE),", - MARKDOWN_HOST to "ActivityResultContracts.CreateDocument(MarkdownExport.MIME_NOTE),", + BACKUP_SCREEN to "pickers.rememberCreateDocument(BackupFormat.MIME_TYPE, onExportTarget)", + MARKDOWN_HOST to "pickers.rememberCreateDocument(MarkdownExport.MIME_NOTE, viewModel::onTarget)", + PICKERS to "ActivityResultContracts.CreateDocument(mimeType)", + PICKER_API to "fun rememberCreateDocument(mimeType: String, onPicked: (DocumentRef?) -> Unit)", ).forEach { (path, line) -> assertFalse("$path: $line", BANNED.values.any { it.matches(path, line) }) } @@ -83,10 +83,16 @@ class NoPlaintextExportTest { private companion object { val SCANNED = setOf("kt", "java", "xml") - const val SENSITIVE_CLIP = "java/com/pasich/encly/presentation/components/SensitiveClip.kt" - const val BACKUP_SCREEN = "java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt" - const val MARKDOWN_HOST = "java/com/pasich/encly/presentation/screen/backup/MarkdownExportHost.kt" - const val OTHER_FILE = "java/com/pasich/encly/presentation/screen/SomeScreen.kt" + const val SENSITIVE_CLIP = "app/src/main/java/com/pasich/encly/platform/SensitiveClip.kt" + const val IOS_CLIP = "shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosClipboard.kt" + const val IOS_PICKERS = "shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocumentPickers.kt" + const val PICKERS = "app/src/main/java/com/pasich/encly/platform/AndroidDocumentPickers.kt" + const val PICKER_API = "shared/src/commonMain/kotlin/com/pasich/encly/platform/Documents.kt" + const val BACKUP_SCREEN = + "shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupScreen.kt" + const val MARKDOWN_HOST = + "shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/MarkdownExportHost.kt" + const val OTHER_FILE = "shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/SomeScreen.kt" val BANNED = mapOf( // ACTION_SEND and ACTION_SEND_MULTIPLE, in code or an intent filter; not ACTION_SENDTO. @@ -96,12 +102,13 @@ class NoPlaintextExportTest { "alarm" to Rule(Regex("""\bAlarmManager\b""")), "clipboard write" to Rule( Regex("""\bsetPrimaryClip\b|\bsetClipEntry\b|\bLocalClipboardManager\b"""), - allowedIn = setOf(SENSITIVE_CLIP), + allowedIn = setOf(SENSITIVE_CLIP, IOS_CLIP), ), - // Creating a document is how a file leaves the app: only the two export hosts may. + // Creating a document is how a file leaves the app: only the two export hosts may ask the platform + // for one, and only the picker adapter (and its interface) names the system dialog. "document creation" to Rule( - Regex("""\bCreateDocument\b|ACTION_CREATE_DOCUMENT"""), - allowedIn = setOf(BACKUP_SCREEN, MARKDOWN_HOST), + Regex("""\bCreateDocument\b|ACTION_CREATE_DOCUMENT|\brememberCreateDocument\b"""), + allowedIn = setOf(BACKUP_SCREEN, MARKDOWN_HOST, PICKERS, PICKER_API, IOS_PICKERS), ), ) diff --git a/app/src/test/java/com/pasich/encly/release/ShippedSources.kt b/app/src/test/java/com/pasich/encly/release/ShippedSources.kt new file mode 100644 index 00000000..3320daf8 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/release/ShippedSources.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.release + +import java.io.File + +/** + * The sources that ship, for the tests that scan them: the app's own (`app/src/main`) and every production + * source set of `:shared` (`commonMain`, `androidMain`, ...). The UI and the data logic live in `:shared`, the + * activities and the Android adapters in `:app`; a scan of one alone would miss the other. + */ +internal object ShippedSources { + private val repoRoot = listOf(File(".."), File(".")).first { File(it, "version.properties").isFile } + + /** The source roots; the path of a file below one starts with `java/`, `kotlin/` or `res/`. */ + val roots: List = listOf(File(repoRoot, "app/src/main")) + + File(repoRoot, "shared/src").listFiles().orEmpty().filter { it.isDirectory && it.name.endsWith("Main") } + + /** Every file with one of the [extensions], paired with its path from the repository root. */ + fun files(extensions: Set): List> = roots.flatMap { sourceRoot -> + sourceRoot.walkTopDown().filter { it.isFile && it.extension in extensions } + .map { it to it.relativeTo(repoRoot).invariantSeparatorsPath }.toList() + } +} diff --git a/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt b/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt index 87ecd95d..17f24976 100644 --- a/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt +++ b/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt @@ -39,7 +39,7 @@ class StoreMetadataTest { @Test fun storeLocalesCoverExactlyTheShippedAppLanguages() { - val resDir = File(root, "app/src/main/res") + val resDir = File(root, "core/resources/src/commonMain/strings") val appLanguages = resDir.listFiles().orEmpty() .filter { it.isDirectory && APP_LOCALE_DIR.matches(it.name) } .map { it.name.removePrefix("values-").substringBefore('-') } @@ -156,7 +156,7 @@ class StoreMetadataTest { const val PUBLISH_GUARD = "vars.PLAY_PUBLISH_ENABLED == 'true'" /** Google Play locale codes; F-Droid accepts the same folder names. */ - val STORE_LOCALES = setOf("en-US", "uk", "de-DE", "fr-FR", "es-ES", "it-IT", "pl-PL", "pt-PT", "nl-NL") + val STORE_LOCALES = setOf("en-US", "uk", "de-DE", "fr-FR", "es-ES", "it-IT", "pl-PL", "pt-PT", "nl-NL", "ar") /** Play Console limits, in characters. */ val LIMITS = mapOf(TITLE to 30, "short_description.txt" to 80, FULL_DESCRIPTION to 4000) diff --git a/app/src/test/java/com/pasich/encly/testutil/InMemoryKeyValueStore.kt b/app/src/test/java/com/pasich/encly/testutil/InMemoryKeyValueStore.kt new file mode 100644 index 00000000..354adb0b --- /dev/null +++ b/app/src/test/java/com/pasich/encly/testutil/InMemoryKeyValueStore.kt @@ -0,0 +1,10 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.platform.SharedPreferencesKeyValueStore + +/** + * A [KeyValueStore] for unit tests: the real Android adapter over in-memory SharedPreferences, so + * the tests of the security logic run through the same `apply`/`commit` code the app does. + */ +internal class InMemoryKeyValueStore : KeyValueStore by SharedPreferencesKeyValueStore(InMemorySharedPreferences()) diff --git a/app/src/test/java/com/pasich/encly/testutil/TestLocale.kt b/app/src/test/java/com/pasich/encly/testutil/TestLocale.kt new file mode 100644 index 00000000..c2a3aee7 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/testutil/TestLocale.kt @@ -0,0 +1,27 @@ +package com.pasich.encly.testutil + +import android.os.LocaleList +import java.util.Locale + +/** + * The process's default locale, which is what the Compose resources read: a test that wants a + * language sets it here and puts the original back afterwards. + */ +object TestLocale { + fun set(tag: String) { + val locales = LocaleList.forLanguageTags(tag) + LocaleList.setDefault(locales) + Locale.setDefault(locales[0]) + } + + fun within(tag: String, block: () -> T): T { + val saved = LocaleList.getDefault() + set(tag) + try { + return block() + } finally { + LocaleList.setDefault(saved) + Locale.setDefault(saved[0]) + } + } +} diff --git a/app/src/test/java/com/pasich/encly/testutil/TestNotesRepository.kt b/app/src/test/java/com/pasich/encly/testutil/TestNotesRepository.kt deleted file mode 100644 index 9e9293b0..00000000 --- a/app/src/test/java/com/pasich/encly/testutil/TestNotesRepository.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.pasich.encly.testutil - -import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag -import com.pasich.encly.domain.repository.NotesRepository -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map - -internal class TestNotesRepository : NotesRepository { - val allNotesWithTags = MutableStateFlow>(emptyList()) - - /** The id an insert returns; zero or less makes the insert fail. */ - var insertResult: Long = DEFAULT_INSERT_ID - var updateResult: Boolean = true - var insertCalls: Int = 0 - private set - var updateCalls: Int = 0 - private set - - private val notesByTag = mutableMapOf>>() - - fun tagFlow(tagId: Long): MutableStateFlow> = notesByTag.getOrPut(tagId) { - MutableStateFlow(emptyList()) - } - - override fun getAllNotesWithTag(): Flow> = allNotesWithTags - - override fun getNotesByTagId(tagId: Long): Flow> = tagFlow(tagId) - - override suspend fun getNoteById(noteId: Long): Note? = allNotesWithTags.value.firstOrNull { - it.note.id == noteId - }?.note - - /** Every note passed to [insertNote], in call order. */ - val insertedNotes = mutableListOf() - - /** When set, [insertNote] waits for it: a test can act while an insert is in flight. */ - var insertGate: CompletableDeferred? = null - - override suspend fun insertNote(note: Note): Result { - insertCalls += 1 - insertedNotes += note - insertGate?.await() - return if (insertResult > 0L) Result.success(insertResult) else Result.failure(IllegalStateException()) - } - - /** Every note passed to [updateNote], in call order. */ - val updatedNotes = mutableListOf() - - override suspend fun updateNote(note: Note): Result { - updateCalls += 1 - updatedNotes += note - return if (updateResult) Result.success(Unit) else Result.failure(IllegalStateException()) - } - - val archivedNotesWithTags = MutableStateFlow>(emptyList()) - - override fun getArchivedNotesWithTag(): Flow> = archivedNotesWithTags - - override fun getArchivedNotesByTagId(tagId: Long): Flow> = - archivedNotesWithTags.map { list -> list.filter { it.note.tagId == tagId } } - - override fun observeNote(noteId: Long): Flow = combine(allNotesWithTags, archivedNotesWithTags) { - a, - b, - -> - (a + b).firstOrNull { it.note.id == noteId } - } - - /** Every single-column write, as "column:id=value", in call order. */ - val columnWrites = mutableListOf() - - var columnWriteResult: Boolean = true - - private fun column(write: String): Result { - columnWrites += write - return if (columnWriteResult) Result.success(Unit) else Result.failure(IllegalStateException()) - } - - override suspend fun setColor(noteId: Long, color: String) = column("color:$noteId=$color") - - override suspend fun setTag(noteId: Long, tagId: Long?) = column("tag:$noteId=$tagId") - - override suspend fun setDescription(noteId: Long, description: String) = column("description:$noteId=$description") - - override suspend fun setPinned(noteId: Long, pinned: Boolean) = column("pinned:$noteId=$pinned") - - override suspend fun setArchived(noteId: Long, archived: Boolean) = column("archived:$noteId=$archived") - - val trashNotes = MutableStateFlow>(emptyList()) - - override fun getTrashNotes(): Flow> = trashNotes - - /** Every id passed to [deleteNoteById], in call order. */ - val deletedIds = mutableListOf() - - override suspend fun deleteNoteById(id: Long): Result { - deletedIds += id - return Result.success(Unit) - } - - private companion object { - const val DEFAULT_INSERT_ID = 42L - } -} 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 deleted file mode 100644 index a6e7ba44..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt +++ /dev/null @@ -1,231 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasAnyAncestor -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isDialog -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTextReplacement -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.data.model.Note -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.backup.BackupScreen -import com.pasich.encly.presentation.viewmodel.BackupStep -import com.pasich.encly.presentation.viewmodel.BackupViewModel -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Test -import org.mockito.Mockito.verifyNoInteractions - -class BackupScreenTest : ComposeScreenTest() { - private val words = String(MnemonicCode(WordCount.COUNT_12).chars) - private val app by lazy { TestApp(context).withWorkingVault(words) } - private lateinit var backup: BackupViewModel - - private fun show() { - backup = app.backup() - setNavScreen(viewModels(backup, app.markdownExport()), route = NavRoutes.BackupRoute.name) { nav -> - BackupScreen(nav) - } - } - - private fun step() = backup.uiState.value.step - - /** Import, the PIN, then the picked file: the 12-word step. */ - private fun openPhraseStep(vararg notes: Note = arrayOf(Note(title = "Plan", value = "[]", uid = "n-plan"))) { - rule.onNode(hasText(str(R.string.backup_import)) and hasClickAction()).performClick() - waitForText(str(R.string.backup_reauth_title)) - typePin("123456") - waitFor(describe = { "${backup.uiState.value}" }) { step() == BackupStep.Idle && !backup.uiState.value.busy } - val file = runBlocking { app.backupFile(words, *notes) } - rule.runOnIdle { backup.importFlow.onFile(app.document("backup.enclybak", file)) } - waitFor(describe = { "${backup.uiState.value}" }) { step() is BackupStep.EnterImportPhrase } - } - - @Test - fun showsExportAndImportWithTheirExplanations() { - show() - - rule.onNodeWithText(str(R.string.backup_intro_short)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.backup_export)).assertIsEnabled() - rule.onNodeWithText(str(R.string.backup_never_exported)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.backup_import_desc)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.backup_intro_title)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 6) - } - - @Test - fun exportAsksForThePinFirst() { - show() - - rule.onNodeWithText(str(R.string.backup_export)).performClick() - - waitForText(str(R.string.backup_reauth_title)) - rule.onNodeWithText(str(R.string.backup_reauth_subtitle)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.cancel)).performClick() - waitFor { step() == BackupStep.Idle } - } - - @Test - fun markdownExportWarnsThatTheFileIsNotEncryptedBeforeAnythingElse() { - show() - rule.onNodeWithText(str(R.string.markdown_export_desc)).assertIsDisplayed() - - rule.onNode(hasText(str(R.string.markdown_export)) and hasClickAction()).performClick() - - waitForText(str(R.string.markdown_export_warning_title)) - rule.onNodeWithText(str(R.string.markdown_export_warning_body)).assertIsDisplayed() - // No PIN prompt and nothing written yet. - assertEquals(0, countText(str(R.string.backup_reauth_title))) - verifyNoInteractions(app.markdownDocuments) - rule.onNodeWithText(str(R.string.cancel)).performClick() - waitFor { countText(str(R.string.markdown_export_warning_title)) == 0 } - } - - @Test - fun markdownExportAsksForThePinAfterTheWarning() { - show() - - rule.onNode(hasText(str(R.string.markdown_export)) and hasClickAction()).performClick() - waitForText(str(R.string.markdown_export_warning_title)) - rule.onNodeWithText(str(R.string.action_continue)).performClick() - - waitForText(str(R.string.backup_reauth_title)) - rule.onNodeWithText(str(R.string.backup_reauth_subtitle)).assertIsDisplayed() - verifyNoInteractions(app.markdownDocuments) - // The encrypted backup flow was not started by it. - assertEquals(BackupStep.Idle, step()) - } - - @Test - fun theImportPhraseStepHasTwelveCellsAndWaitsForAValidPhrase() { - show() - openPhraseStep() - - rule.onNodeWithText(str(R.string.backup_enter_phrase_title)).assertIsDisplayed() - (1..12).forEach { phraseCell(it).assertExists() } - rule.onNodeWithText(str(R.string.backup_decrypt)).assertIsNotEnabled() - - phraseCell(1).performTextReplacement(words.split(' ').take(6).joinToString(" ")) - assertEquals(words.split(' ').take(6), phraseCells().take(6)) - rule.onNodeWithText(str(R.string.backup_decrypt)).assertIsNotEnabled() - - phraseCell(7).performTextReplacement(words.split(' ').drop(6).joinToString(" ")) - assertEquals(words.split(' '), phraseCells()) - rule.onNodeWithText(str(R.string.backup_decrypt)).assertIsEnabled() - } - - @Test - fun twelveKnownWordsWithABadChecksumAreFlagged() { - show() - openPhraseStep() - - // Twelve real words, but "abandon" twelve times has no valid checksum. - phraseCell(1).performTextReplacement(List(12) { "abandon" }.joinToString(" ")) - - rule.onNodeWithText(str(R.string.recovery_phrase_checksum)).performScrollTo().assertIsDisplayed() - rule.onNodeWithText(str(R.string.backup_decrypt)).assertIsNotEnabled() - } - - @Test - fun aDifferentPhraseDoesNotOpenTheBackupAndKeepsTheWords() { - show() - openPhraseStep() - val other = PHRASE - - phraseCell(1).performTextReplacement(other) - rule.onNodeWithText(str(R.string.backup_decrypt)).performClick() - - waitForText(str(R.string.backup_error_wrong_phrase), timeoutMillis = LONG_WAIT_MS) - assertEquals(other.split(' '), phraseCells()) - - phraseCell(12).performTextInput("x") - waitFor { countText(str(R.string.backup_error_wrong_phrase)) == 0 } - } - - @Test - fun theRightPhraseOffersMergeOrReplace() { - show() - openPhraseStep(Note(title = "One", value = "[]", uid = "a"), Note(title = "Two", value = "[]", uid = "b")) - phraseCell(1).performTextReplacement(words) - - rule.onNodeWithText(str(R.string.backup_decrypt)).performClick() - - waitForText(str(R.string.backup_import_mode_title), timeoutMillis = LONG_WAIT_MS) - rule.onNodeWithText(plural(R.plurals.backup_count_notes, 2, 2), substring = true).assertIsDisplayed() - rule.onNodeWithText(str(R.string.backup_merge)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.backup_replace_all)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.cancel)).assertIsDisplayed() - } - - @Test - fun cancellingReplaceReturnsToTheChoice() { - show() - openPhraseStep() - phraseCell(1).performTextReplacement(words) - rule.onNodeWithText(str(R.string.backup_decrypt)).performClick() - waitForText(str(R.string.backup_import_mode_title), timeoutMillis = LONG_WAIT_MS) - - rule.onNodeWithText(str(R.string.backup_replace_all)).performClick() - waitForText(str(R.string.backup_replace_confirm_title)) - rule.onNode(hasText(str(R.string.cancel)) and hasClickAction() and hasAnyAncestor(isDialog())).performClick() - - waitFor { step() is BackupStep.ChooseImportMode } - rule.onNodeWithText(str(R.string.backup_merge)).assertIsDisplayed() - } - - @Test - fun mergeImportsAndReportsIt() { - show() - openPhraseStep() - phraseCell(1).performTextReplacement(words) - rule.onNodeWithText(str(R.string.backup_decrypt)).performClick() - waitForText(str(R.string.backup_import_mode_title), timeoutMillis = LONG_WAIT_MS) - - rule.onNodeWithText(str(R.string.backup_merge)).performClick() - - waitFor(LONG_WAIT_MS, describe = { "${backup.uiState.value} ${runBlocking { app.store.snapshot() }}" }) { - runBlocking { app.store.snapshot() }.notes.any { it.title == "Plan" } - } - waitForText(str(R.string.backup_import_done, 1, 0, 0, 0), timeoutMillis = LONG_WAIT_MS) - } - - 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/ComposeScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt deleted file mode 100644 index 40968275..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt +++ /dev/null @@ -1,265 +0,0 @@ -package com.pasich.encly.ui.screens - -import android.app.Application -import android.content.Context -import androidx.annotation.PluralsRes -import androidx.annotation.StringRes -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.semantics.SemanticsNode -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.getOrNull -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasScrollToNodeAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo -import androidx.compose.ui.test.performScrollToNode -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -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 -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.reflect.KClass - -/** - * Base for the Compose screen tests. They run on the JVM under Robolectric, on a phone-sized - * screen, with a plain Application (no Hilt graph, no keystore). Every `hiltViewModel()` in the - * composition resolves to a ViewModel the test built from fakes (see [FakeViewModels]). - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") -abstract class ComposeScreenTest { - @get:Rule - val rule = createComposeRule() - - protected val context: Context get() = ApplicationProvider.getApplicationContext() - - private val owners = mutableListOf() - - @After - fun clearViewModels() { - owners.forEach { it.viewModelStore.clear() } - } - - protected fun str(@StringRes id: Int, vararg args: Any): String = context.getString(id, *args) - - protected fun plural(@PluralsRes id: Int, count: Int, vararg args: Any): String = - context.resources.getQuantityString(id, count, *args) - - protected fun viewModels(vararg viewModels: ViewModel): FakeViewModels = - FakeViewModels(*viewModels).also(owners::add) - - /** [content] in the app theme, with [owner] answering every `hiltViewModel()`. */ - protected fun setScreen( - owner: FakeViewModels = viewModels(), - settings: ThemeSettings = ThemeSettings(), - dark: Boolean = false, - content: @Composable () -> Unit, - ) { - rule.setContent { - EnclyTheme(settings = settings, dark = dark) { - ProvideViewModels(owner, content) - } - } - rule.waitForIdle() - } - - protected lateinit var nav: NavHostController - - /** - * [content] as the destination [route] of a graph that has every app route, the others as - * stubs, so the screen can navigate and a test can see where it went ([currentRoute]). - * [start] is where the back stack begins; tests reach [route] with [navigateTo] when it differs. - */ - protected fun setNavScreen( - owner: FakeViewModels = viewModels(), - route: String, - start: String = route, - settings: ThemeSettings = ThemeSettings(), - dark: Boolean = false, - liveTheme: StateFlow? = null, - content: @Composable (NavHostController) -> Unit, - ) { - rule.setContent { - val controller = rememberNavController() - nav = controller - // With [liveTheme] the app theme follows the stored settings, as AppTheme does. - val theme = liveTheme?.collectAsState()?.value ?: settings - EnclyTheme(settings = theme, dark = if (liveTheme != null) theme.isDark(false) else dark) { - NavHost(navController = controller, startDestination = start) { - TestRoutes.patterns.filter { it.pattern != route }.forEach { stub -> - composable(stub.pattern, arguments = stub.arguments) { - Text("stub:${stub.name}") - } - } - composable(route, arguments = TestRoutes.argumentsFor(route)) { - ProvideViewModels(owner) { content(controller) } - } - } - } - } - rule.waitForIdle() - } - - protected fun navigateTo(route: String) { - rule.runOnIdle { nav.navigate(route) } - rule.waitForIdle() - } - - /** The route pattern of the destination on top, e.g. `HomeRoute`. */ - protected fun currentRoute(): String? = rule.runOnIdle { nav.currentBackStackEntry?.destination?.route } - - /** The route's name without its arguments, e.g. `TasksRoute` for `TasksRoute?add={add}`. */ - protected fun currentRouteName(): String? = currentRoute()?.substringBefore('?')?.substringBefore('/') - - /** - * The screen shows real content, not a blank body: at least [minTexts] visible texts - * besides the top bar title, inside the window. - */ - protected fun assertBodyNotEmpty(minTexts: Int = 2) { - val root = rule.onRoot().fetchSemanticsNode() - val window = root.boundsInRoot - val texts = visibleTexts().filter { node -> - val bounds = node.boundsInRoot - bounds.height > 0f && bounds.width > 0f && bounds.overlaps(window) - } - assertTrue("expected at least $minTexts visible texts, found ${texts.map(::textOf)}", texts.size >= minTexts) - } - - protected fun visibleTexts(): List = rule.onAllNodes(hasAnyText, useUnmergedTree = true) - .fetchSemanticsNodes() - - protected fun textOf(node: SemanticsNode): String = node.config.getOrNull(SemanticsProperties.Text) - ?.joinToString { it.text } - ?: node.config.getOrNull(SemanticsProperties.EditableText)?.text - ?: "" - - /** - * Waits for [condition], letting both real threads (the ViewModels' IO and Default work) and - * the Compose clock (animations and `delay` in effects) move on. - */ - protected fun waitFor(timeoutMillis: Long = WAIT_MS, describe: () -> String = { "" }, condition: () -> Boolean) { - val deadline = System.currentTimeMillis() + timeoutMillis - while (!condition()) { - if (System.currentTimeMillis() > - deadline - ) { - throw AssertionError("condition not met in $timeoutMillis ms ${describe()}") - } - rule.mainClock.advanceTimeBy(FRAME_MS) - rule.waitForIdle() - Thread.sleep(POLL_MS) - } - rule.waitForIdle() - } - - protected fun waitForText( - text: String, - substring: Boolean = false, - ignoreCase: Boolean = false, - timeoutMillis: Long = WAIT_MS, - ) { - waitFor(timeoutMillis, describe = { "for \"$text\"; shown: ${visibleTexts().map(::textOf)}" }) { - countText(text, substring, ignoreCase) > 0 - } - } - - protected fun countText(text: String, substring: Boolean = false, ignoreCase: Boolean = false): Int = - rule.onAllNodes(hasText(text, substring = substring, ignoreCase = ignoreCase), useUnmergedTree = true) - .fetchSemanticsNodes().size - - /** Taps [pin] on the on-screen keypad. */ - protected fun typePin(pin: String) { - pin.forEach { digit -> - rule.onNode(hasText(digit.toString()) and hasClickAction()).performClick() - rule.waitForIdle() - } - } - - /** The recovery phrase cell [number] (1-based) of 12. */ - protected fun phraseCell(number: Int) = rule.onNodeWithContentDescription( - str(com.pasich.encly.R.string.recovery_word_cell, number, PHRASE_WORDS), - ) - - /** What the 12 cells hold, in order. */ - protected fun phraseCells(): List = (1..PHRASE_WORDS).map { number -> - phraseCell(number).fetchSemanticsNode().config.getOrNull(SemanticsProperties.EditableText)?.text.orEmpty() - } - - /** - * Brings [text] into view: in a scrolling column it is already composed; in a lazy list the - * first scrollable list is scrolled until it is. - */ - protected fun scrollToText(text: String, substring: Boolean = false, ignoreCase: Boolean = false) { - val matcher = hasText(text, substring = substring, ignoreCase = ignoreCase) - if (rule.onAllNodes(matcher).fetchSemanticsNodes().isEmpty()) { - rule.onAllNodes(hasScrollToNodeAction())[0].performScrollToNode(matcher) - } else { - rule.onAllNodes(matcher)[0].performScrollTo() - } - rule.waitForIdle() - } - - protected companion object { - const val WAIT_MS = 5_000L - const val PHRASE_WORDS = 12 - const val FRAME_MS = 16L - const val POLL_MS = 5L - - /** A valid BIP39 phrase (its checksum holds). */ - val PHRASE = (List(11) { "abandon" } + "about").joinToString(" ") - - val hasAnyText = SemanticsMatcher("has text") { - it.config.getOrNull(SemanticsProperties.Text).orEmpty().any { text -> text.isNotBlank() } || - !it.config.getOrNull(SemanticsProperties.EditableText)?.text.isNullOrBlank() - } - } -} - -/** - * 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) : PreloadedViewModelOwner { - override val viewModelStore = ViewModelStore() - - init { - viewModels.forEach { add(it) } - } - - fun add(viewModel: ViewModel) { - val factory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: KClass, extras: CreationExtras): T = viewModel as T - } - ViewModelProvider.create(viewModelStore, factory)[viewModel::class] - } -} - -@Composable -fun ProvideViewModels(owner: FakeViewModels, content: @Composable () -> Unit) { - CompositionLocalProvider(LocalViewModelStoreOwner provides owner, content = content) -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/EditNoteScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/EditNoteScreenTest.kt deleted file mode 100644 index 45b57b6a..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/EditNoteScreenTest.kt +++ /dev/null @@ -1,584 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.state.ToggleableState -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isEnabled -import androidx.compose.ui.test.isFocused -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.performScrollTo -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTextInputSelection -import androidx.compose.ui.test.pressKey -import androidx.compose.ui.text.TextRange -import com.pasich.encly.R -import com.pasich.encly.core.serialization.BlockConverter -import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.editor.persistence.SaveStatusNote -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.editnote.EditNoteScreen -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.Mockito.verifyNoInteractions - -class EditNoteScreenTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - private lateinit var editor: EditNoteViewModel - - /** The notes list with the editor opened on top of it, as from a tap on a card. */ - private fun open(noteId: Long = -1L, readTrashOnly: Boolean = false) { - editor = app.editor(noteId = noteId, readTrashOnly = readTrashOnly) - setNavScreen( - viewModels(editor, app.tagList(), app.markdownExport()), - route = TestRoutes.EDIT_NOTE, - start = NavRoutes.HomeRoute.name, - ) { nav -> EditNoteScreen(nav) } - navigateTo("${NavRoutes.EditNoteRoute.name}/$noteId?isReadTrashOnly=$readTrashOnly") - waitFor { editor.status.value != SaveStatusNote.LOADING } - } - - private fun stored(vararg blocks: Block, title: String = "Trip", id: Long = NOTE_ID) = Note( - id = id, - title = title, - value = BlockConverter.blocksToJson(blocks.toList()), - date = 1_700_000_000_000, - ).also { app.withNotes(it) } - - private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) - - private fun field(index: Int) = rule.onAllNodes(hasSetTextAction())[index] - - private fun tool(id: Int) = rule.onNode(hasContentDescription(str(id)) and hasClickAction()) - - private fun hasTool(label: String) = - rule.onAllNodes(hasContentDescription(label) and hasClickAction()).fetchSemanticsNodes().isNotEmpty() - - private fun moreToolsToggles() = - rule.onAllNodes(hasContentDescription(str(R.string.block_more_tools))).fetchSemanticsNodes() - - private fun back() = rule.onNodeWithContentDescription(str(R.string.back)).performClick() - - @Test - fun aNewNoteShowsTheTitleAParagraphAndTheToolbar() { - open() - - rule.onNodeWithText(str(R.string.note_title_placeholder), useUnmergedTree = true).assertExists() - listOf( - R.string.block_more_tools, - R.string.block_heading, - R.string.block_checklist, - R.string.block_bulleted_list, - R.string.block_numbered_list, - R.string.quote, - R.string.undo, - R.string.redo, - ).forEach { tool(it).assertExists() } - assertTrue(rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size >= 2) - rule.onNodeWithContentDescription(str(R.string.edit_tags)).assertExists() - rule.onNodeWithContentDescription(str(R.string.editor_lock)).assertExists() - } - - @Test - fun typingAndBackSavesTheNewNote() { - open() - - field(0).performTextInput("Shopping") - field(1).performTextInput("eggs and flour") - back() - - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - val saved = app.notes.insertedNotes.last() - assertEquals("Shopping", saved.title) - assertTrue(saved.value, saved.value.contains("eggs and flour")) - } - - @Test - fun aBlankNewNoteIsNotSaved() { - open() - - back() - - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - assertEquals(0, app.notes.insertCalls) - } - - @Test - fun twoQuickBacksLeaveOnlyTheEditor() { - stored(text("body")) - open(NOTE_ID) - - // Both taps land before the first save finishes, as a fast double tap does. - val tap = rule.onNodeWithContentDescription(str(R.string.back)).fetchSemanticsNode() - .config[SemanticsActions.OnClick].action - rule.runOnUiThread { - tap?.invoke() - tap?.invoke() - } - - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - rule.mainClock.advanceTimeBy(LEAVE_MS) - rule.waitForIdle() - assertEquals(NavRoutes.HomeRoute.name, currentRoute()) - } - - @Test - fun anExistingNoteShowsItsContentAndWhenItWasEdited() { - stored(text("first line"), text("second line"), title = "Trip plan") - open(NOTE_ID) - - waitForText("Trip plan") - rule.onNodeWithText("first line").assertExists() - rule.onNodeWithText("second line").assertExists() - assertTrue(visibleTexts().map(::textOf).any { it.contains("2023") }) - assertBodyNotEmpty(minTexts = 3) - } - - @Test - fun theChecklistToolTurnsTheParagraphIntoAChecklist() { - stored(text("milk")) - open(NOTE_ID) - field(1).performClick() - - tool(R.string.block_checklist).performClick() - - waitFor { editor.blocks.first() is Block.ListBlock } - assertEquals(BlockType.LIST_CHECK, (editor.blocks.first() as Block.ListBlock).blockType) - rule.onNodeWithText("milk").assertExists() - rule.onNode(isCheckbox and hasContentDescription("milk")).assertExists() - } - - @Test - fun aChecklistItemCanBeTicked() { - stored( - Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("milk"))), blockType = BlockType.LIST_CHECK), - ) - open(NOTE_ID) - - rule.onNode(isCheckbox and hasContentDescription("milk")).performClick() - - waitFor { - (editor.blocks.first() as Block.ListBlock).items.value.single().isCheck - } - assertEquals( - ToggleableState.On, - rule.onNode( - isCheckbox and hasContentDescription("milk"), - ).fetchSemanticsNode().config[SemanticsProperties.ToggleableState], - ) - } - - @Test - fun aLockedChecklistStillTicksButItsTextTakesNoFocus() { - stored( - Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("milk"))), blockType = BlockType.LIST_CHECK), - ) - open(NOTE_ID) - field(1).performClick() - tool(R.string.block_more_tools).performClick() - waitFor { hasTool(str(R.string.checklist_lock)) } - - // The submenu scrolls sideways; the lock is past the edge of a phone-wide row. - tool(R.string.checklist_lock).performScrollTo().performClick() - - val list = editor.blocks.first() as Block.ListBlock - waitFor { list.locked.value } - waitFor { hasTool(str(R.string.checklist_unlock)) } - rule.onNode(isCheckbox and hasContentDescription("milk")).performClick() - waitFor { list.items.value.single().isCheck } - assertEquals( - 0, - rule.onAllNodes(hasSetTextAction() and hasText("milk") and isEnabled()).fetchSemanticsNodes().size, - ) - } - - @Test - fun aLockedChecklistCanBeUnlockedAfterWorkingElsewhere() { - stored( - Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("milk"))), blockType = BlockType.LIST_CHECK), - text("after"), - ) - open(NOTE_ID) - field(1).performClick() - tool(R.string.block_more_tools).performClick() - waitFor { hasTool(str(R.string.checklist_lock)) } - tool(R.string.checklist_lock).performScrollTo().performClick() - val list = editor.blocks.first() as Block.ListBlock - waitFor { list.locked.value } - rule.onNode(hasText("after") and hasSetTextAction()).performClick() - waitFor { !hasTool(str(R.string.checklist_unlock)) } - - // A tap on the locked list makes it the block the toolbar acts on again. - rule.onNode(isCheckbox and hasContentDescription("milk")).performClick() - - waitFor { hasTool(str(R.string.checklist_unlock)) } - tool(R.string.checklist_unlock).performScrollTo().performClick() - waitFor { !list.locked.value } - } - - @Test - fun theHeadingToolTurnsTheParagraphIntoAHeading() { - stored(text("Plan")) - open(NOTE_ID) - field(1).performClick() - - tool(R.string.block_heading).performClick() - - waitFor { editor.blocks.first() is Block.HBlock } - rule.onNodeWithText("Plan").assertExists() - } - - @Test - fun addBlockOpensTheSecondRowAndCloseReturns() { - open() - - tool(R.string.block_more_tools).performClick() - - val second = (1..4).map { str(R.string.block_heading_level, it) } + - listOf( - R.string.text, - R.string.block_link, - R.string.block_separator, - R.string.block_move_up, - R.string.block_move_down, - ).map(::str) - second.forEach { label -> waitFor { hasTool(label) } } - tool(R.string.close).performClick() - waitFor { hasTool(str(R.string.block_checklist)) } - } - - @Test - fun aHeadingLevelFromTheSecondRowIsApplied() { - stored(text("Plan")) - open(NOTE_ID) - field(1).performClick() - tool(R.string.block_more_tools).performClick() - waitFor { hasTool(str(R.string.block_heading_level, 3)) } - - rule.onNode(hasContentDescription(str(R.string.block_heading_level, 3)) and hasClickAction()).performClick() - - waitFor { (editor.blocks.first() as? Block.HBlock)?.blockType == BlockType.H3 } - } - - @Test - fun theSeparatorToolAddsASeparator() { - stored(text("above")) - open(NOTE_ID) - field(1).performClick() - tool(R.string.block_more_tools).performClick() - waitFor { hasTool(str(R.string.block_separator)) } - - tool(R.string.block_separator).performClick() - - waitFor { editor.blocks.any { it is Block.SeparatorBlock } } - } - - @Test - fun lockingHidesTheToolbarAndKeepsTheText() { - stored(text("secret plan")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.editor_lock)).performClick() - - waitFor { moreToolsToggles().isEmpty() } - rule.onNodeWithText("secret plan").assertExists() - assertTrue(editor.lockEditor.value) - } - - @Test - fun aNoteInTheTrashIsReadOnlyWithRestoreAndDelete() { - stored(text("old")) - open(NOTE_ID, readTrashOnly = true) - - rule.onNodeWithText(str(R.string.restore)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.delete_from_trash)).assertIsDisplayed() - assertEquals(0, moreToolsToggles().size) - rule.onNodeWithText("old").assertExists() - } - - @Test - fun deletingFromTheTrashAsksFirst() { - stored(text("old")) - open(NOTE_ID, readTrashOnly = true) - - rule.onNodeWithText(str(R.string.delete_from_trash)).performClick() - - waitForText(str(R.string.dialog_title)) - } - - @Test - fun theMoreMenuOffersDuplicateDiscardAndDelete() { - stored(text("body")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - - waitForText(str(R.string.duplicate)) - rule.onNodeWithText(str(R.string.note_discard_all_changes)).assertExists() - rule.onNodeWithText(str(R.string.delete)).assertExists() - } - - @Test - fun theMoreMenuOffersArchiveColourAndMarkdownExportButNoPin() { - stored(text("body")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - - waitForText(str(R.string.note_archive)) - rule.onNodeWithText(str(R.string.markdown_export)).assertExists() - // Pinning orders the notes list, so it is offered there, not inside a note. - assertEquals(0, countText(str(R.string.note_pin))) - // Section headings are drawn in capitals. - rule.onNodeWithText(str(R.string.note_color_title).uppercase()).assertExists() - rule.onNodeWithContentDescription(str(R.string.note_color_teal)).assertExists() - } - - @Test - fun useDefaultTextAppearsOnlyWhileTheNoteHasItsOwn() { - stored(text("body")) - open(NOTE_ID) - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - waitForText(str(R.string.note_archive)) - assertEquals(0, countText(str(R.string.note_text_use_default))) - - rule.runOnIdle { editor.updateNoteLineSpacing(com.pasich.encly.domain.model.NoteLineSpacing.COMFORTABLE) } - - waitForText(str(R.string.note_text_use_default)) - rule.onNodeWithText(str(R.string.note_text_use_default)).performScrollTo().performClick() - waitFor { countText(str(R.string.note_text_use_default)) == 0 } - } - - @Test - fun archivingFromTheMenuStoresTheNoteArchivedAndLeaves() { - stored(text("body")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - waitForText(str(R.string.note_archive)) - rule.onNodeWithText(str(R.string.note_archive)).performScrollTo().performClick() - - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - assertEquals(listOf("archived:$NOTE_ID=true"), app.notes.columnWrites) - } - - @Test - fun anArchivedNoteOffersUnarchive() { - Note(id = NOTE_ID, title = "Trip", value = "[]", date = 1L, isArchived = true).also { app.withNotes(it) } - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - - waitForText(str(R.string.note_unarchive)) - rule.onNodeWithText(str(R.string.note_unarchive)).performScrollTo().performClick() - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - assertEquals(listOf("archived:$NOTE_ID=false"), app.notes.columnWrites) - } - - @Test - fun colouringIsSavedWithTheNote() { - stored(text("body")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - waitForText(str(R.string.note_archive)) - rule.onNodeWithContentDescription(str(R.string.note_color_teal)).performScrollTo().performClick() - waitFor { editor.state.value.note.color == "teal" } - // Leaving the editor saves the note, with its colour. - rule.onNodeWithContentDescription(str(R.string.back)).performClick() - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - - assertEquals("teal", app.notes.updatedNotes.last().color) - } - - @Test - fun exportingAsMarkdownSavesTheNoteThenWarnsAndTouchesNoFile() { - stored(text("body")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - waitForText(str(R.string.markdown_export)) - rule.onNodeWithText(str(R.string.markdown_export)).performScrollTo().performClick() - - waitForText(str(R.string.markdown_export_warning_title)) - assertEquals(0, countText(str(R.string.backup_reauth_title))) - verifyNoInteractions(app.markdownDocuments) - // The editor stays open behind the warning. - assertEquals(NavRoutes.EditNoteRoute.name, currentRoute()?.substringBefore('/')) - } - - private fun anyTextFieldFocused() = - rule.onAllNodes(hasSetTextAction() and isFocused()).fetchSemanticsNodes().isNotEmpty() - - /** Waits until block [type] is the note's first and its field really has the focus (not just been asked to). */ - private fun settleOn(isList: Boolean) { - waitFor { (editor.blocks.first() is Block.ListBlock) == isList } - rule.mainClock.advanceTimeBy(SETTLE_MS) - waitFor { editor.focusedBlockId.value == editor.blocks.first().id } - } - - /** Runs [action] a frame at a time and says whether a text field (so the keyboard) had focus in every frame. */ - private fun focusNeverLapsesDuring(action: () -> Unit): Boolean { - rule.mainClock.autoAdvance = false - action() - var always = true - repeat(FRAMES_TO_WATCH) { - rule.mainClock.advanceTimeByFrame() - always = always && anyTextFieldFocused() - } - rule.mainClock.autoAdvance = true - return always - } - - @Test - fun convertingTheFocusedParagraphToAListNeverLeavesNothingFocused() { - stored(text("milk")) - open(NOTE_ID) - field(1).performClick() - waitFor { anyTextFieldFocused() } - - val kept = focusNeverLapsesDuring { tool(R.string.block_checklist).performClick() } - - assertTrue("a frame had no focused text field", kept) - // The list's own field has the focus in the end, the anchor has let go. - settleOn(isList = true) - } - - @Test - @OptIn(ExperimentalTestApi::class) - fun deletingTheLastListItemWithBackspaceNeverLeavesNothingFocused() { - stored( - Block.ListBlock( - MutableStateFlow(listOf(ItemListBlock("one"), ItemListBlock("two"))), - blockType = BlockType.LIST_BULLET, - ), - ) - open(NOTE_ID) - rule.onNode(hasSetTextAction() and hasText("two")).performClick() - rule.onNode(hasSetTextAction() and hasText("two")).performTextInputSelection(TextRange(0)) - waitFor { editor.focusedBlockId.value == editor.blocks.first().id } - - val kept = focusNeverLapsesDuring { - rule.onNode(hasSetTextAction() and hasText("two")).performKeyInput { pressKey(Key.Backspace) } - } - - assertTrue("a frame had no focused text field", kept) - waitFor { (editor.blocks.first() as Block.ListBlock).items.value.size == 1 } - } - - @Test - fun undoingTheConversionAndRedoingItNeverLeavesNothingFocusedEither() { - stored(text("milk")) - open(NOTE_ID) - field(1).performClick() - tool(R.string.block_checklist).performClick() - settleOn(isList = true) - - assertTrue(focusNeverLapsesDuring { tool(R.string.undo).performClick() }) - settleOn(isList = false) - assertTrue(focusNeverLapsesDuring { tool(R.string.redo).performClick() }) - settleOn(isList = true) - } - - @Test - @OptIn(ExperimentalTestApi::class) - fun controlZInTheTitleLeavesTheEditorHistoryAlone() { - stored(text("body")) - open(NOTE_ID) - field(1).performTextInput(" changed") - field(0).performClick() - - field(0).performKeyInput { - keyDown(Key.CtrlLeft) - pressKey(Key.Z) - keyUp(Key.CtrlLeft) - } - - assertEquals("body changed", (editor.blocks.first() as Block.TextBlock).text.value) - } - - @Test - fun discardingEditsAsksFirst() { - stored(text("body")) - open(NOTE_ID) - field(1).performTextInput(" changed") - - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - waitForText(str(R.string.note_discard_all_changes)) - rule.onNodeWithText(str(R.string.note_discard_all_changes)).performClick() - - waitForText(str(R.string.note_discard_confirm_title)) - rule.onNodeWithText(str(R.string.note_discard_confirm_message)).assertIsDisplayed() - } - - @Test - fun discardingWithoutEditsJustLeaves() { - stored(text("body")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.more_options)).performClick() - waitForText(str(R.string.note_discard_all_changes)) - rule.onNodeWithText(str(R.string.note_discard_all_changes)).performClick() - - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - assertEquals(0, countText(str(R.string.note_discard_confirm_title))) - } - - @Test - fun anUnreadableNoteSaysSoAndCannotBeUnlocked() { - Note(id = NOTE_ID, title = "Broken", value = "{not json", date = 1L).also { app.withNotes(it) } - open(NOTE_ID) - - waitForText(str(R.string.note_load_failed)) - assertEquals(0, rule.onAllNodes(hasContentDescription(str(R.string.editor_lock))).fetchSemanticsNodes().size) - } - - @Test - fun theTagButtonOffersToCreateATagWhenThereAreNone() { - stored(text("body")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.edit_tags)).performClick() - - waitFor { countText(str(R.string.create_new_tag)) + countText(str(R.string.tags_manage)) > 0 } - } - - @Test - fun choosingATagTagsTheNote() { - app.withTags(com.pasich.encly.data.model.Tag(id = 4, nameTag = "Travel")) - stored(text("body")) - open(NOTE_ID) - - rule.onNodeWithContentDescription(str(R.string.edit_tags)).performClick() - waitForText("Travel") - rule.onNode(hasText("Travel") and hasClickAction()).performClick() - - waitFor { editor.state.value.note.tagId == 4L } - waitForText("TRAVEL", substring = true) - } - - private companion object { - const val NOTE_ID = 91L - const val LEAVE_MS = 1_000L - const val FRAMES_TO_WATCH = 40 - const val SETTLE_MS = 1_000L - val isCheckbox = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) - } -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/EditorPlaceholdersTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/EditorPlaceholdersTest.kt deleted file mode 100644 index c97f35a7..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/EditorPlaceholdersTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.performClick -import com.pasich.encly.R -import com.pasich.encly.data.model.Note -import com.pasich.encly.presentation.editor.persistence.SaveStatusNote -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.editnote.EditNoteScreen -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import org.junit.Assert.assertEquals -import org.junit.Test - -class EditorPlaceholdersTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - private lateinit var editor: EditNoteViewModel - - private fun open(json: String) { - app.withNotes(Note(id = NOTE_ID, title = "Trip", value = json, date = 1L)) - editor = app.editor(noteId = NOTE_ID) - setNavScreen( - viewModels(editor, app.tagList(), app.markdownExport()), - route = TestRoutes.EDIT_NOTE, - start = NavRoutes.HomeRoute.name, - ) { nav -> EditNoteScreen(nav) } - navigateTo("${NavRoutes.EditNoteRoute.name}/$NOTE_ID?isReadTrashOnly=false") - waitFor { editor.status.value != SaveStatusNote.LOADING } - } - - private fun field(index: Int) = rule.onAllNodes(hasSetTextAction())[index] - - private fun hints() = listOf( - R.string.list_item_placeholder, - R.string.quote, - R.string.block_heading_level, - ).map { id -> if (id == R.string.block_heading_level) str(id, 1) else str(id) } - - @Test - fun emptyBlocksShowNoHintUntilTheyAreFocused() { - open(EMPTY_BLOCKS) - - hints().forEach { assertEquals(it, 0, countText(it)) } - } - - @Test - fun theFocusedEmptyBlockShowsItsHintAndOthersStayQuiet() { - open(EMPTY_BLOCKS) - - field(2).performClick() - - waitForText(str(R.string.list_item_placeholder)) - assertEquals(0, countText(str(R.string.quote))) - assertEquals(0, countText(str(R.string.block_heading_level, 1))) - } - - @Test - fun anEmptyQuoteAndHeadingShowTheirHintWhenFocused() { - open(EMPTY_BLOCKS) - - field(3).performClick() - waitForText(str(R.string.quote)) - assertEquals(0, countText(str(R.string.list_item_placeholder))) - - field(4).performClick() - waitForText(str(R.string.block_heading_level, 1)) - waitFor { countText(str(R.string.quote)) == 0 } - } - - private companion object { - const val NOTE_ID = 7L - const val EMPTY_BLOCKS = """[{"blockType":"TEXT","text":"a"}, - {"blockType":"LIST_BULLET","items":[{"value":"","isCheck":false}]}, - {"blockType":"QUOTE","text":""}, - {"blockType":"H1","text":""}]""" - } -} 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 deleted file mode 100644 index bf0c23a0..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/EditorWritingTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -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 -import com.pasich.encly.data.model.Note -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.editor.persistence.SaveStatusNote -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.editnote.EditNoteScreen -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals -import org.junit.Test - -/** Writing in the editor as a user does: Enter, a typed mark, a paste, Backspace, undo. */ -@OptIn(ExperimentalTestApi::class) -class EditorWritingTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - private lateinit var editor: EditNoteViewModel - - private fun open(vararg blocks: Block) { - Note(id = NOTE_ID, title = "Trip", value = BlockConverter.blocksToJson(blocks.toList()), date = 1L) - .also { app.withNotes(it) } - editor = app.editor(noteId = NOTE_ID) - setNavScreen( - viewModels(editor, app.tagList(), app.markdownExport()), - route = TestRoutes.EDIT_NOTE, - start = NavRoutes.HomeRoute.name, - ) { nav -> EditNoteScreen(nav) } - navigateTo("${NavRoutes.EditNoteRoute.name}/$NOTE_ID?isReadTrashOnly=false") - waitFor { editor.status.value != SaveStatusNote.LOADING } - } - - private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) - - private fun paragraph(value: String) = rule.onNode(hasSetTextAction() and hasText(value)) - - private fun texts(): List = editor.blocks.map { - (it as? Block.TextBlock)?.text?.value - ?: it::class.simpleName.orEmpty() - } - - @Test - fun enterSplitsTheParagraphAtTheCursor() { - open(text("Hello world")) - paragraph("Hello world").performClick() - paragraph("Hello world").performTextInputSelection(TextRange(5)) - - paragraph("Hello world").performTextInput("\n") - - waitFor { editor.blocks.size == 2 } - assertEquals(listOf("Hello", " world"), texts()) - } - - @Test - fun aTypedMarkTurnsTheParagraphIntoAHeading() { - open(text("")) - val field = rule.onAllNodes(hasSetTextAction())[1] - field.performClick() - - field.performTextInput("#") - field.performTextInput(" ") - - waitFor { editor.blocks.first() is Block.HBlock } - assertEquals(BlockType.H1, (editor.blocks.first() as Block.HBlock).blockType) - } - - @Test - fun pastedLinesBecomeBlocksAndOneUndoTakesThemBack() { - open(text("Start")) - paragraph("Start").performClick() - - paragraph("Start").performTextInput(" one\n- milk\n- eggs\nlast") - - waitFor { editor.blocks.size == 3 } - assertEquals(listOf("Start one", "ListBlock", "last"), texts()) - rule.runOnUiThread { editor.undo() } - waitFor { editor.blocks.size == 1 } - rule.onNodeWithText("Start").assertExists() - } - - @Test - fun backspaceAtTheStartJoinsTheParagraphToTheOneAbove() { - open(text("Hello"), text(" world")) - paragraph(" world").performClick() - paragraph(" world").performTextInputSelection(TextRange(0)) - - paragraph(" world").performKeyInput { pressKey(Key.Backspace) } - - waitFor { editor.blocks.size == 1 } - 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")) - - rule.onNode(hasContentDescription(str(R.string.keyboard_show)) and hasClickAction()).performClick() - - waitFor { editor.focusedBlockId.value == editor.blocks.first().id } - paragraph("body").assertIsFocused() - } - - private companion object { - const val NOTE_ID = 92L - } -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt deleted file mode 100644 index c3f91fe3..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.lifecycle.SavedStateHandle -import com.pasich.encly.R -import com.pasich.encly.presentation.screen.handoff.ImportFromMyNotesScreen -import com.pasich.encly.presentation.viewmodel.ImportFromMyNotesViewModel -import kotlinx.coroutines.Dispatchers -import org.junit.Test - -/** The hand-off screen while the vault is closed. */ -class ImportFromMyNotesScreenTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - - @Test - fun aVaultNothingCanOpenIsReportedInsteadOfTheLockScreen() { - val handoff = ImportFromMyNotesViewModel( - context = context, - securityManager = app.security, - sessionLockManager = app.sessionLock, - backupManager = app.backupManager, - ioDispatcher = Dispatchers.Main, - savedStateHandle = SavedStateHandle(), - ) - app.sessionLock.requireUnlock() - setScreen(viewModels(handoff, app.lock())) { ImportFromMyNotesScreen(onClose = {}) } - waitForText(str(R.string.lock_title)) - - // The lock screen found that the PIN key is gone and there is nothing else to unlock with. - rule.runOnIdle { handoff.onVaultLost() } - - waitForText(str(R.string.handoff_error_vault)) - } -} 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 deleted file mode 100644 index 6fc746e8..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt +++ /dev/null @@ -1,256 +0,0 @@ -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() - // How to export and what is lost unfold on a tap, so the list stays short. - rule.onNodeWithText(str(source.howTo)).assertDoesNotExist() - rule.onNodeWithText(str(source.title)).performClick() - 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 whenEveryNoteIsAlreadyHereThereIsNothingToSelect() { - val seen = listOf(row("c", "Seen before", existing = true)) - show(ImportStep.Preview(plain, ReviewState(seen, emptySet(), ReviewFilter.ALL))) - - rule.onNodeWithText(str(R.string.import_review_selected, 0, 0)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.import_select_all)).assertDoesNotExist() - rule.onNodeWithText(str(R.string.import_select_none)).assertDoesNotExist() - rule.onNodeWithText(str(R.string.import_action_import_count, 0)).assertIsNotEnabled() - } - - @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/java/com/pasich/encly/ui/screens/InfoScreensTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/InfoScreensTest.kt deleted file mode 100644 index 7b51e416..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/InfoScreensTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTouchInput -import com.pasich.encly.BuildConfig -import com.pasich.encly.R -import com.pasich.encly.core.LINK_REPOSITORY -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.AboutScreen -import com.pasich.encly.presentation.screen.FaqScreen -import com.pasich.encly.presentation.screen.LicensesScreen -import com.pasich.encly.presentation.screen.LossReason -import com.pasich.encly.presentation.screen.LossRecoveryScreen -import com.pasich.encly.presentation.screen.SupportScreen -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class InfoScreensTest : ComposeScreenTest() { - - @Test - fun aboutShowsTheAppItsProtectionAndItsLinks() { - setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } - - rule.onNodeWithText(str(R.string.about_data_protection)).assertIsDisplayed() - scrollToText(str(R.string.about_privacy_policy)) - scrollToText(str(R.string.about_write_developer)) - scrollToText(str(R.string.licenses_title)) - assertTrue(visibleTexts().map(::textOf).any { it.contains(BuildConfig.VERSION_NAME) }) - assertBodyNotEmpty(minTexts = 6) - } - - @Test - fun aboutLinksTheSourceCodeInEveryBuild() { - setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } - - scrollToText(str(R.string.about_source_code)) - scrollToText(str(R.string.about_source_code_desc)) - assertEquals("https://github.com/pasichDev/Encly", LINK_REPOSITORY) - } - - @Test - fun aboutOffersARatingOnlyInThePlayBuild() { - setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } - - assertEquals(BuildConfig.STORE_RATING_ENABLED, countText(str(R.string.about_rate_app)) > 0) - } - - @Test - fun aboutLinksTheSupportPageOnlyInTheFdroidBuild() { - setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } - - assertEquals(BuildConfig.DONATIONS_ENABLED, countText(str(R.string.about_support_dev_desc)) > 0) - } - - @Test - fun aboutLeadsToTheLicenses() { - setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } - scrollToText(str(R.string.licenses_title)) - - rule.onNodeWithText(str(R.string.licenses_title)).performClick() - - waitFor { currentRoute() == NavRoutes.LicensesRoute.name } - } - - @Test - fun licensesListLibrariesAndFonts() { - setNavScreen(route = NavRoutes.LicensesRoute.name) { nav -> LicensesScreen(nav) } - - rule.onNodeWithText(str(R.string.licenses_heading)).assertIsDisplayed() - scrollToText(str(R.string.licenses_libraries), ignoreCase = true) - scrollToText(str(R.string.licenses_fonts), ignoreCase = true) - assertBodyNotEmpty(minTexts = 8) - } - - @Test - fun faqAnswersOpenOnTap() { - setNavScreen(route = NavRoutes.FaqRoute.name) { nav -> FaqScreen(nav) } - scrollToText(str(R.string.faq_forgot_pin_q)) - assertFalse(countText(str(R.string.faq_forgot_pin_a)) > 0 && isShown(str(R.string.faq_forgot_pin_a))) - - rule.onNodeWithText(str(R.string.faq_forgot_pin_q)).performClick() - - waitFor { isShown(str(R.string.faq_forgot_pin_a)) } - assertBodyNotEmpty(minTexts = 6) - } - - @Test - fun supportExplainsTheDonation() { - setNavScreen(route = NavRoutes.SupportRoute.name) { nav -> SupportScreen(nav) } - - rule.onNodeWithText(str(R.string.donation_title)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 4) - } - - @Test - fun supportOffersKofi() { - setNavScreen(route = NavRoutes.SupportRoute.name) { nav -> SupportScreen(nav) } - - scrollToText("Ko-fi") - scrollToText(str(R.string.support_method_kofi_hint)) - scrollToText(str(R.string.support_browser_note)) - } - - @Test - fun damagedDataExplainsAndWipesOnlyAfterAHold() { - var wiped = false - setScreen { LossRecoveryScreen(onRecoveryConfirm = { wiped = true }, reason = LossReason.DAMAGED) } - - rule.onNodeWithText(str(R.string.loss_title)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.vault_reset_restore_hint)).assertIsDisplayed() - val button = rule.onNodeWithText(str(R.string.loss_hold_to_wipe)) - button.performClick() - rule.mainClock.advanceTimeBy(HOLD_MS) - assertFalse("a tap must not wipe", wiped) - - button.performTouchInput { down(center) } - rule.mainClock.advanceTimeBy(HOLD_MS + 500) - button.performTouchInput { up() } - rule.waitForIdle() - - assertTrue(wiped) - } - - @Test - fun dataFromAnOlderVersionSaysSo() { - setScreen { LossRecoveryScreen(onRecoveryConfirm = {}, reason = LossReason.OLDER_VERSION) } - - rule.onNodeWithText(str(R.string.legacy_vault_title)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 3) - } - - private fun isShown(text: String): Boolean = rule.onAllNodes(androidx.compose.ui.test.hasText(text)) - .fetchSemanticsNodes().any { it.boundsInRoot.height > 0f } - - private companion object { - const val HOLD_MS = 4_000L - } -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/LocalizedScreensTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/LocalizedScreensTest.kt deleted file mode 100644 index c04aed0f..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/LocalizedScreensTest.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.pasich.encly.ui.screens - -import android.content.res.Configuration -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.pasich.encly.R -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.LockScreen -import com.pasich.encly.presentation.screen.MainRootScreen -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.onboarding.OnboardingScreen -import com.pasich.encly.presentation.screen.settings.AppearanceScreen -import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen -import com.pasich.encly.presentation.screen.settings.SettingsScreen -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.`when` -import org.robolectric.ParameterizedRobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import java.util.Locale - -/** - * Every main screen in each of the nine languages: it renders, shows its own text, and shows no - * English string that this language translates (a hardcoded or untranslated string). - */ -@RunWith(ParameterizedRobolectricTestRunner::class) -class LocalizedScreensTest(private val language: String) : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - - /** English texts that [language] translates differently: none of them may be shown. */ - private val untranslated: Set by lazy { - val english = context.createConfigurationContext( - Configuration(context.resources.configuration).apply { - setLocale(Locale.ENGLISH) - }, - ).resources - R.string::class.java.fields.mapNotNull { field -> - val id = field.getInt(null) - val en = english.getString(id) - en.takeIf { language != "en" && it.length > 3 && it != context.getString(id) } - }.flatMap { listOf(it, it.uppercase()) }.toSet() - } - - @Before - fun useLanguage() { - RuntimeEnvironment.setQualifiers("+$language") - assertEquals(language, context.resources.configuration.locales[0].language) - if (language != "en") assertTrue("nothing is translated into $language", untranslated.size > MIN_TRANSLATED) - } - - private fun assertInLanguage(expected: Int) { - waitForText(str(expected), ignoreCase = true) - val leaks = visibleTexts().map(::textOf).filter { it in untranslated } - assertTrue("[$language] English text on screen: $leaks", leaks.isEmpty()) - assertBodyNotEmpty() - } - - @Test - fun welcome() { - setScreen(viewModels(app.onboarding(), app.authSetup())) { OnboardingScreen(onComplete = {}) } - assertInLanguage(R.string.onboarding_welcome_headline) - } - - @Test - fun notes() { - setNavScreen(viewModels(*app.home()), route = NavRoutes.HomeRoute.name) { nav -> MainRootScreen(nav) } - assertInLanguage(R.string.empty_notes) - } - - @Test - fun tasks() { - setNavScreen(viewModels(app.tasksVm()), route = TestRoutes.TASKS) { nav -> TasksScreen(nav) } - assertInLanguage(R.string.task_empty_title) - } - - @Test - fun editor() { - val editor = app.editor() - setNavScreen( - viewModels(editor, app.tagList(), app.markdownExport()), - route = TestRoutes.EDIT_NOTE, - start = NavRoutes.HomeRoute.name, - ) { nav -> - EditNoteScreen(nav) - } - navigateTo("${NavRoutes.EditNoteRoute.name}/-1") - waitFor { - rule.onAllNodes( - androidx.compose.ui.test.hasContentDescription(str(R.string.block_more_tools)), - ).fetchSemanticsNodes().isNotEmpty() - } - val leaks = visibleTexts().map(::textOf).filter { it in untranslated } - assertTrue("[$language] English text on screen: $leaks", leaks.isEmpty()) - } - - @Test - fun settings() { - setNavScreen(viewModels(app.settingsVm()), route = NavRoutes.SettingsRoute.name) { nav -> SettingsScreen(nav) } - assertInLanguage(R.string.settings_appearance) - } - - @Test - fun appearance() { - setNavScreen(viewModels(app.appearance()), route = NavRoutes.AppearanceRoute.name) { nav -> - AppearanceScreen(nav) - } - assertInLanguage(R.string.palette_midnight) - } - - @Test - fun security() { - setNavScreen( - viewModels(app.securitySettings(), app.backup()), - route = NavRoutes.SecuritySettingsRoute.name, - ) { nav -> - SecuritySettingsScreen(nav) - } - assertInLanguage(R.string.security_encryption_active) - } - - @Test - fun backup() { - setNavScreen(viewModels(app.backup(), app.markdownExport()), route = NavRoutes.BackupRoute.name) { nav -> - BackupScreen(nav) - } - assertInLanguage(R.string.backup_intro_short) - } - - @Test - fun lockAndRecovery() { - `when`(app.security.hasRecoverySeed()).thenReturn(true) - setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> LockScreen(nav) } - assertInLanguage(R.string.lock_title) - - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - - assertInLanguage(R.string.lock_recover_access) - (1..PHRASE_WORDS).forEach { phraseCell(it).assertExists() } - } - - companion object { - /** Far fewer translated strings than this means the language did not load. */ - private const val MIN_TRANSLATED = 300 - - @JvmStatic - @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") - fun languages(): List> = - listOf("en", "uk", "de", "es", "fr", "it", "nl", "pl", "pt").map { arrayOf(it) } - } -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/LockScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/LockScreenTest.kt deleted file mode 100644 index 6049d8a5..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/LockScreenTest.kt +++ /dev/null @@ -1,292 +0,0 @@ -package com.pasich.encly.ui.screens - -import android.app.Activity -import androidx.activity.OnBackPressedDispatcher -import androidx.activity.compose.LocalActivity -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performImeAction -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTextReplacement -import androidx.compose.ui.test.pressKey -import com.pasich.encly.R -import com.pasich.encly.core.security.VaultUnlockResult -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.LockScreen -import com.pasich.encly.testutil.anyCharArray -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.ArgumentMatchers -import org.mockito.Mockito.`when` -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -@OptIn(ExperimentalTestApi::class) -class LockScreenTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - - private fun show(recovery: Boolean = true) { - `when`(app.security.hasRecoverySeed()).thenReturn(recovery) - setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> - back = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher - activity = LocalActivity.current - LockScreen(nav) - } - } - - private lateinit var back: OnBackPressedDispatcher - private var activity: Activity? = null - - /** - * The lock screen over a vault screen, as after a re-lock whose navigation left Home below it: - * the worst case, where a Back that got through would reveal the notes. - */ - private fun showOverTheVault() { - `when`(app.security.hasRecoverySeed()).thenReturn(true) - setNavScreen( - viewModels(app.lock()), - route = NavRoutes.LockRoute.name, - start = NavRoutes.HomeRoute.name, - ) { nav -> - back = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher - activity = LocalActivity.current - LockScreen(nav) - } - navigateTo(NavRoutes.LockRoute.name) - assertStillLocked() - } - - private fun pressBack() { - rule.runOnIdle { back.onBackPressed() } - rule.waitForIdle() - } - - /** Still on the lock screen; the vault screen below was never drawn and the app did not close. */ - private fun assertStillLocked() { - assertEquals(NavRoutes.LockRoute.name, currentRoute()) - assertEquals(0, countText("stub:${NavRoutes.HomeRoute.name}")) - assertFalse(activity?.isFinishing == true) - } - - private fun anyChars(): CharArray = ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0) - - @Test - fun showsThePinPad() { - show() - - rule.onNodeWithText(str(R.string.lock_title)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.lock_subtitle)).assertIsDisplayed() - (0..9).forEach { rule.onNodeWithText(it.toString()).assertIsDisplayed() } - rule.onNodeWithContentDescription(str(R.string.pin_delete_digit)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 10) - } - - @Test - fun withoutARecoveryPhraseThereIsNoRecoveryLink() { - show(recovery = false) - - assertEquals(0, countText(str(R.string.lock_use_recovery_phrase))) - } - - @Test - fun theDotsFollowTheDigitsAndBackspace() { - show() - - typePin("12") - rule.onNodeWithContentDescription(str(R.string.pin_digits_entered, 2, 6)).assertExists() - rule.onNodeWithContentDescription(str(R.string.pin_delete_digit)).performClick() - rule.onNodeWithContentDescription(str(R.string.pin_digits_entered, 1, 6)).assertExists() - } - - @Test - fun aWrongPinSaysSoAndStays() { - `when`(app.security.unlockWithPin(anyCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) - show() - - typePin("000000") - - waitForText(str(R.string.lock_wrong_pin)) - rule.onNodeWithContentDescription(str(R.string.pin_digits_entered, 0, 6)).assertExists() - assertEquals(NavRoutes.LockRoute.name, currentRoute()) - } - - @Test - fun theRightPinOpensTheNotes() { - `when`(app.security.unlockWithPin(anyCharArray())).thenReturn(VaultUnlockResult.SUCCESS) - show() - - typePin("123456") - - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - } - - @Test - fun theRecoveryFormHasTwelveCellsAndWaitsForAValidPhrase() { - show() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - - rule.onNodeWithText(str(R.string.lock_recovery_title)).assertIsDisplayed() - (1..12).forEach { phraseCell(it).assertExists() } - rule.onNodeWithText(str(R.string.lock_recover_access)).assertIsNotEnabled() - - phraseCell(1).performTextReplacement(PHRASE) - - assertEquals(PHRASE.split(' '), phraseCells()) - rule.onNodeWithText(str(R.string.lock_recover_access)).assertIsEnabled() - } - - @Test - fun spaceMovesOnAndBackspaceInAnEmptyCellGoesBack() { - show() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - phraseCell(1).performClick() - - phraseCell(1).performTextInput("abandon ") - phraseCell(2).assertIsFocused() - assertEquals("abandon", phraseCells()[0]) - - phraseCell(2).performKeyInput { pressKey(Key.Backspace) } - phraseCell(1).assertIsFocused() - } - - @Test - fun nextOnTheKeyboardMovesToTheNextCell() { - show() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - phraseCell(4).performTextInput("zoo") - - phraseCell(4).performImeAction() - - phraseCell(5).assertIsFocused() - } - - @Test - fun capitalsAndDigitsAreCleanedFromAWord() { - show() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - - phraseCell(1).performTextInput("Zoo1") - - assertEquals("zoo", phraseCells()[0]) - } - - @Test - fun aWordOutsideTheWordlistIsFlagged() { - show() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - - phraseCell(3).performTextInput("qqq") - - rule.onNodeWithText(str(R.string.recovery_word_unknown, 3)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.lock_recover_access)).assertIsNotEnabled() - } - - @Test - fun aWrongPhraseSaysSoAndKeepsTheWords() { - `when`(app.security.unlockWithSeed(anyChars())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) - show() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - phraseCell(1).performTextReplacement(PHRASE) - - rule.onNodeWithText(str(R.string.lock_recover_access)).performClick() - - waitForText(str(R.string.lock_wrong_recovery_phrase)) - assertEquals(PHRASE.split(' '), phraseCells()) - } - - @Test - fun theRightPhraseAsksForANewPin() { - `when`(app.security.unlockWithSeed(anyChars())).thenReturn(VaultUnlockResult.SUCCESS) - show() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - phraseCell(1).performTextReplacement(PHRASE) - - rule.onNodeWithText(str(R.string.lock_recover_access)).performClick() - - waitFor { currentRoute() == NavRoutes.PinCodeConfig.name } - } - - @Test - fun backOnThePinPadNeverLeavesTheLockScreen() { - showOverTheVault() - typePin("12") - - repeat(3) { - pressBack() - assertStillLocked() - } - rule.onNodeWithText(str(R.string.lock_title)).assertIsDisplayed() - } - - @Test - fun backWhenTheLockScreenIsAllThereIsDoesNotCloseTheApp() { - show() - - pressBack() - - assertStillLocked() - rule.onNodeWithText(str(R.string.lock_title)).assertIsDisplayed() - } - - @Test - fun backFromTheRecoveryFormReturnsToThePinPadNotTheVault() { - showOverTheVault() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - rule.onNodeWithText(str(R.string.lock_recovery_title)).assertIsDisplayed() - - pressBack() - - assertStillLocked() - rule.onNodeWithText(str(R.string.lock_title)).assertIsDisplayed() - assertEquals(0, countText(str(R.string.lock_recovery_title))) - - pressBack() - assertStillLocked() - } - - @Test - fun backWhileThePinIsCheckedStaysOnTheLockScreen() { - val checking = CountDownLatch(1) - val release = CountDownLatch(1) - `when`(app.security.unlockWithPin(anyCharArray())).thenAnswer { - checking.countDown() - release.await(WAIT_MS, TimeUnit.MILLISECONDS) - VaultUnlockResult.INVALID_CREDENTIAL - } - showOverTheVault() - typePin("000000") - assertTrue(checking.await(WAIT_MS, TimeUnit.MILLISECONDS)) - - pressBack() - assertStillLocked() - - release.countDown() - waitForText(str(R.string.lock_wrong_pin)) - assertStillLocked() - } - - @Test - fun backToPinDropsTheWords() { - show() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - phraseCell(1).performTextReplacement(PHRASE) - - rule.onNodeWithText(str(R.string.lock_back_to_pin)).performClick() - rule.onNodeWithText(str(R.string.lock_title)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.lock_use_recovery_phrase)).performClick() - - assertTrue(phraseCells().all(String::isEmpty)) - } -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/MainScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/MainScreenTest.kt deleted file mode 100644 index 5e78010f..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/MainScreenTest.kt +++ /dev/null @@ -1,291 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.longClick -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTouchInput -import com.pasich.encly.BuildConfig -import com.pasich.encly.R -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.data.model.Tag -import com.pasich.encly.data.model.Task -import com.pasich.encly.domain.repository.NotesRepository -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.navigation.TASKS_ADD_ARG -import com.pasich.encly.presentation.screen.MainRootScreen -import com.pasich.encly.testutil.TestNotesRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.flow -import org.junit.Assert.assertEquals -import org.junit.Test -import java.io.IOException - -class MainScreenTest : ComposeScreenTest() { - private companion object { - const val FADE_MS = 1_000L - } - - private val app by lazy { TestApp(context) } - - private fun show(notesRepository: NotesRepository = app.notes) { - setNavScreen(viewModels(*app.home(app.noteList(notesRepository))), route = NavRoutes.HomeRoute.name) { nav -> - MainRootScreen(nav) - } - } - - private fun note(id: Long, title: String, text: String = "") = Note( - id = id, - title = title, - value = BlockConverter.blocksToJson(listOf(Block.TextBlock(kotlinx.coroutines.flow.MutableStateFlow(text)))), - date = 1_700_000_000_000 + id, - ) - - @Test - fun noNotesShowsTheEmptyStateUnderTheHeader() { - show() - - waitForText(str(R.string.empty_notes)) - rule.mainClock.advanceTimeBy(FADE_MS) - rule.onNodeWithText(str(R.string.empty_notes_desc)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.search_placeholder)).assertExists() - rule.onNodeWithText(str(R.string.note_add), useUnmergedTree = true).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 4) - } - - @Test - fun oneNoteIsListed() { - app.withNotes(note(1, "Groceries", "milk, bread")) - show() - - waitForText("Groceries") - assertEquals(0, countText(str(R.string.empty_notes))) - } - - @Test - fun manyNotesAreListed() { - app.withNotes(*Array(12) { note(it + 1L, "Note ${it + 1}", "body ${it + 1}") }) - show() - - waitForText("Note 12") - assertBodyNotEmpty(minTexts = 8) - } - - @Test - fun aFailedReadIsAnErrorNeverAnEmptyVault() { - val failing = object : NotesRepository by TestNotesRepository() { - override fun getAllNotesWithTag(): Flow> = flow { throw IOException("vault closed") } - } - show(failing) - - waitFor { visibleTexts().size > 3 } - assertEquals(0, countText(str(R.string.empty_notes))) - assertBodyNotEmpty(minTexts = 4) - } - - @Test - fun whileLoadingNeitherEmptyNorErrorIsShown() { - val slow = object : NotesRepository by TestNotesRepository() { - override fun getAllNotesWithTag(): Flow> = MutableSharedFlow() - } - show(slow) - - rule.onNodeWithText(str(R.string.search_placeholder)).assertExists() - assertEquals(0, countText(str(R.string.empty_notes))) - } - - @Test - fun longPressOpensTheNoteActions() { - app.withNotes(note(1, "Groceries")) - show() - waitForText("Groceries") - - rule.onNode(hasText("Groceries") and hasClickAction()).performTouchInput { longClick() } - - waitForText(str(R.string.duplicate)) - rule.onNodeWithText(str(R.string.edit)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.delete)).assertIsDisplayed() - } - - @Test - fun deletingFromTheSheetMovesTheNoteToTheTrash() { - app.withNotes(note(1, "Groceries")) - show() - waitForText("Groceries") - rule.onNode(hasText("Groceries") and hasClickAction()).performTouchInput { longClick() } - waitForText(str(R.string.delete)) - - rule.onNodeWithText(str(R.string.delete)).performClick() - rule.onNodeWithText(str(R.string.tap_again_to_delete)).assertIsDisplayed() - assertEquals(emptyList(), app.notes.updatedNotes) - rule.onNodeWithText(str(R.string.tap_again_to_delete)).performClick() - - waitFor(describe = { "${visibleTexts().map(::textOf)} ${app.notes.updatedNotes}" }) { - app.notes.updatedNotes.any { it.id == 1L && it.isTrash } - } - } - - @Test - fun editFromTheSheetOpensTheEditor() { - app.withNotes(note(7, "Groceries")) - show() - waitForText("Groceries") - rule.onNode(hasText("Groceries") and hasClickAction()).performTouchInput { longClick() } - waitForText(str(R.string.edit)) - - rule.onNodeWithText(str(R.string.edit)).performClick() - - waitFor { currentRoute() == TestRoutes.EDIT_NOTE } - assertEquals(7L, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getLong("idNote") }) - } - - @Test - fun tappingANoteOpensIt() { - app.withNotes(note(3, "Groceries")) - show() - waitForText("Groceries") - - rule.onNode(hasText("Groceries") and hasClickAction()).performClick() - - waitFor { currentRoute() == TestRoutes.EDIT_NOTE } - assertEquals(3L, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getLong("idNote") }) - } - - @Test - fun theFabStartsANewNote() { - show() - waitForText(str(R.string.empty_notes)) - - rule.onNodeWithText(str(R.string.note_add), useUnmergedTree = true).performClick() - - waitFor { currentRoute() == TestRoutes.EDIT_NOTE } - assertEquals(-1L, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getLong("idNote") }) - } - - @Test - fun searchShowsMatchesAndSaysWhenNothingMatches() { - app.withNotes(note(1, "Groceries", "milk"), note(2, "Trip", "tickets")) - show() - waitForText("Trip") - - rule.onNode(hasSetTextAction()).performTextInput("zzz") - - waitForText(str(R.string.search_nothing_matches, "zzz")) - rule.onNodeWithContentDescription(str(R.string.search_clear)).performClick() - rule.onNode(hasSetTextAction()).performTextInput("tick") - waitFor(describe = { - "${visibleTexts().map(::textOf)}" - }) { countText("Groceries") == 0 && countText("Trip") > 0 } - } - - @Test - fun withNoOpenTasksTheCardOffersANewOne() { - show() - - waitForText(str(R.string.home_tasks_none_open), ignoreCase = true) - rule.onNodeWithText(str(R.string.task_add)).performClick() - - waitFor { currentRoute() == TestRoutes.TASKS } - assertEquals(true, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getBoolean(TASKS_ADD_ARG) }) - } - - @Test - fun openTasksArePreviewedOnTheCard() { - app.withTasks(Task(id = 1, title = "Pay rent", priority = 2)) - show() - - waitForText("Pay rent") - rule.onNodeWithText(str(R.string.task_filter_all)).assertIsDisplayed() - } - - @Test - fun theTagChipsFilterAndAnEmptyTagOffersShowAll() { - val work = Tag(id = 5, nameTag = "Work") - app.withTags(work) - app.withNotes(note(1, "Groceries")) - show() - waitForText("Groceries") - - rule.onNode(hasText("Work") and hasClickAction()).performClick() - - waitForText(str(R.string.empty_notes_tagged, "Work")) - rule.onNodeWithText(str(R.string.show_all_notes)).performClick() - waitForText("Groceries") - } - - @Test - fun theDrawerLeadsToEverySection() { - show() - waitForText(str(R.string.empty_notes)) - - rule.onNodeWithContentDescription(str(R.string.open_menu)).performClick() - - waitForText(str(R.string.main_drawer_trash)) - listOf(R.string.main_drawer_tasks, R.string.main_drawer_tags, R.string.main_drawer_faq, R.string.about) - .forEach { rule.onNodeWithText(str(it)).assertExists() } - rule.onNodeWithText(str(R.string.main_drawer_trash)).performClick() - waitFor { currentRoute() == NavRoutes.TrashRoute.name } - } - - @Test - fun theDrawerOffersArchiveAndImportAndLeadsToThem() { - show() - waitForText(str(R.string.empty_notes)) - - rule.onNodeWithContentDescription(str(R.string.open_menu)).performClick() - waitForText(str(R.string.main_drawer_archive)) - rule.onNodeWithText(str(R.string.import_title)).assertExists() - rule.onNodeWithText(str(R.string.main_drawer_archive)).performClick() - - waitFor { currentRoute() == NavRoutes.ArchiveRoute.name } - } - - @Test - fun theDrawerImportOpensTheImportScreen() { - show() - waitForText(str(R.string.empty_notes)) - - rule.onNodeWithContentDescription(str(R.string.open_menu)).performClick() - waitForText(str(R.string.import_title)) - rule.onNodeWithText(str(R.string.import_title)).performClick() - - waitFor { currentRoute() == NavRoutes.ImportNotesRoute.name } - } - - @Test - fun theDrawerShowsSupportAsAButtonOnlyWhereDonationsAreAllowed() { - show() - waitForText(str(R.string.empty_notes)) - - rule.onNodeWithContentDescription(str(R.string.open_menu)).performClick() - waitForText(str(R.string.main_drawer_faq)) - - val support = rule.onAllNodesWithText(str(R.string.main_drawer_support)) - if (BuildConfig.DONATIONS_ENABLED) { - support.assertCountEquals(1)[0].assertHasClickAction() - } else { - support.assertCountEquals(0) - } - } - - @Test - fun theSettingsButtonOpensSettings() { - show() - - rule.onNodeWithContentDescription(str(R.string.main_drawer_settings)).performClick() - - waitFor { currentRoute() == NavRoutes.SettingsRoute.name } - } -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/OnboardingScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/OnboardingScreenTest.kt deleted file mode 100644 index 11b2d028..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/OnboardingScreenTest.kt +++ /dev/null @@ -1,205 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTextReplacement -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.data.model.Note -import com.pasich.encly.presentation.screen.onboarding.OnboardingScreen -import com.pasich.encly.presentation.viewmodel.OnboardingStep -import com.pasich.encly.presentation.viewmodel.OnboardingViewModel -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class OnboardingScreenTest : ComposeScreenTest() { - private val words = String(MnemonicCode(WordCount.COUNT_12).chars) - private val app by lazy { TestApp(context).withWorkingVault(words) } - private lateinit var onboarding: OnboardingViewModel - private var completed = false - - private fun show() { - onboarding = app.onboarding() - setScreen(viewModels(onboarding, app.authSetup())) { OnboardingScreen(onComplete = { completed = true }) } - } - - private fun primary(id: Int) = rule.onNodeWithText(str(id)) - - private fun choosePin(pin: String = "246810") { - waitForText(str(R.string.onboarding_pin_title)) - primary(R.string.action_continue).assertIsNotEnabled() - typePin(pin) - primary(R.string.action_continue).assertIsEnabled().performClick() - waitForText(str(R.string.onboarding_pin_again_title)) - typePin(pin) - primary(R.string.action_continue).performClick() - } - - @Test - fun welcomeShowsTheFactsAndBothWaysIn() { - show() - - rule.onNodeWithText(str(R.string.onboarding_welcome_headline)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.onboarding_fact_offline_title)).assertIsDisplayed() - primary(R.string.onboarding_get_started).assertIsEnabled() - primary(R.string.onboarding_have_backup).assertIsEnabled() - assertBodyNotEmpty(minTexts = 8) - } - - @Test - fun theLanguagePillOpensTheLanguageChoice() { - show() - - rule.onNodeWithContentDescription(str(R.string.language_dialog_title), substring = true).performClick() - - waitForText(str(R.string.language_name_uk)) - rule.onNodeWithText(str(R.string.done)).assertIsDisplayed() - } - - @Test - fun theWholeCreatePathEndsReady() { - show() - primary(R.string.onboarding_get_started).performClick() - choosePin() - - waitForText(str(R.string.onboarding_recovery_title)) - primary(R.string.onboarding_recovery_create).performClick() - - waitForText(str(R.string.onboarding_phrase_title)) - words.split(' ').forEach { word -> assertTrue(word, countText(word) > 0) } - primary(R.string.onboarding_phrase_done).performClick() - - waitForText(str(R.string.onboarding_verify_title)) - primary(R.string.onboarding_verify_confirm).assertIsNotEnabled() - val fields = rule.onAllNodes(hasSetTextAction()) - onboarding.uiState.value.verificationWords.forEachIndexed { position, (_, word) -> - fields[position].performTextInput(word) - } - primary(R.string.onboarding_verify_confirm).assertIsEnabled().performClick() - - waitForText(str(R.string.onboarding_ready_title), timeoutMillis = LONG_WAIT_MS) - rule.onNodeWithText(str(R.string.onboarding_ready_phrase_title)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 5) - } - - @Test - fun aWrongVerificationWordIsFlagged() { - show() - primary(R.string.onboarding_get_started).performClick() - choosePin() - waitForText(str(R.string.onboarding_recovery_title)) - primary(R.string.onboarding_recovery_create).performClick() - waitForText(str(R.string.onboarding_phrase_title)) - primary(R.string.onboarding_phrase_done).performClick() - waitForText(str(R.string.onboarding_verify_title)) - - val (index, word) = onboarding.uiState.value.verificationWords.first() - rule.onAllNodes(hasSetTextAction())[0].performTextInput("z".repeat(word.length)) - - rule.onNodeWithText(str(R.string.onboarding_verify_wrong, index + 1)).assertIsDisplayed() - primary(R.string.onboarding_verify_confirm).assertIsNotEnabled() - } - - @Test - fun skippingThePhraseEndsReadyWithoutOne() { - show() - primary(R.string.onboarding_get_started).performClick() - choosePin() - waitForText(str(R.string.onboarding_recovery_skip)) - - primary(R.string.onboarding_recovery_skip).performClick() - - waitForText(str(R.string.onboarding_ready_no_phrase_title), timeoutMillis = LONG_WAIT_MS) - } - - @Test - fun mismatchedPinsStartThePinAgain() { - show() - primary(R.string.onboarding_get_started).performClick() - waitForText(str(R.string.onboarding_pin_title)) - typePin("111111") - primary(R.string.action_continue).performClick() - waitForText(str(R.string.onboarding_pin_again_title)) - - typePin("222222") - primary(R.string.action_continue).performClick() - - waitForText(str(R.string.onboarding_pin_title)) - } - - @Test - fun restoreHasTwelveCellsAndWaitsForAFileAndAValidPhrase() { - show() - primary(R.string.onboarding_have_backup).performClick() - - waitForText(str(R.string.onboarding_restore_title)) - (1..12).forEach { phraseCell(it).assertExists() } - primary(R.string.restore).assertIsNotEnabled() - - phraseCell(1).performTextReplacement(words) - assertEquals(words.split(' '), phraseCells()) - primary(R.string.restore).assertIsNotEnabled() - - pickBackup() - rule.onNodeWithText("notes.enclybak").assertIsDisplayed() - primary(R.string.restore).assertIsEnabled() - } - - @Test - fun restoreTypedWordByWordMovesAcrossTheCells() { - show() - primary(R.string.onboarding_have_backup).performClick() - waitForText(str(R.string.onboarding_restore_title)) - - phraseCell(1).performTextInput("${words.split(' ')[0]} ") - rule.waitForIdle() - - assertEquals(words.split(' ')[0], phraseCells()[0]) - assertTrue(phraseCells().drop(1).all(String::isEmpty)) - } - - @Test - fun aRestoredBackupGoesOnToThePin() { - show() - primary(R.string.onboarding_have_backup).performClick() - waitForText(str(R.string.onboarding_restore_title)) - pickBackup() - phraseCell(1).performTextReplacement(words) - - primary(R.string.restore).performClick() - - waitFor(LONG_WAIT_MS) { onboarding.uiState.value.step == OnboardingStep.PIN } - waitForText(str(R.string.onboarding_pin_title)) - } - - @Test - fun backFromThePinReturnsToWelcome() { - show() - primary(R.string.onboarding_get_started).performClick() - waitForText(str(R.string.onboarding_pin_title)) - - rule.runOnIdle { onboarding.back() } - - waitForText(str(R.string.onboarding_welcome_headline)) - assertTrue(!completed) - } - - private fun pickBackup() { - val file = runBlocking { app.backupFile(words, Note(title = "Plan", value = "[]", uid = "n-plan")) } - rule.runOnIdle { onboarding.onRestoreFilePicked(app.document("notes.enclybak", file)) } - waitFor(describe = { "${onboarding.uiState.value}" }) { onboarding.uiState.value.restoreFileReady } - } - - private companion object { - const val LONG_WAIT_MS = 15_000L - } -} 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 deleted file mode 100644 index 002c75b2..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt +++ /dev/null @@ -1,547 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isToggleable -import androidx.compose.ui.test.onAllNodesWithContentDescription -import androidx.compose.ui.test.onNodeWithText -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.NavRoutes -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 -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` - -class SecurityScreensTest : ComposeScreenTest() { - private val words = String(MnemonicCode(WordCount.COUNT_12).chars) - private val app by lazy { TestApp(context).withWorkingVault(words) } - private lateinit var vault: BackupViewModel - - private fun showSecurity(recoveryPhrase: Boolean, biometric: BiometricStatus = BiometricStatus.UNAVAILABLE) { - app.withAuth(recoveryPhrase, biometric) - vault = app.backup() - setNavScreen(viewModels(app.securitySettings(), vault), route = NavRoutes.SecuritySettingsRoute.name) { nav -> - SecuritySettingsScreen(nav) - } - waitForText(str(R.string.security_encryption_active)) - } - - private fun showPinChange(requiresCurrent: Boolean = true) { - `when`(app.security.canResetPinWithoutCurrent()).thenReturn(!requiresCurrent) - setNavScreen( - viewModels(app.securitySettings()), - route = NavRoutes.PinCodeConfig.name, - start = NavRoutes.SecuritySettingsRoute.name, - ) { nav -> PinCodeConfigScreen(nav) } - navigateTo(NavRoutes.PinCodeConfig.name) - } - - @Test - fun withoutAPhraseSecurityOffersToSetOneUp() { - showSecurity(recoveryPhrase = false) - - rule.onNodeWithText(str(R.string.auth_method_pin_title)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.security_recovery_missing)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.security_encryption_pin_plain)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 8) - } - - @Test - fun withAPhraseSecurityShowsItIsSetUp() { - showSecurity(recoveryPhrase = true) - - rule.onNodeWithText(str(R.string.security_recovery_status_set)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.security_encryption_seed_plain)).assertIsDisplayed() - assertEquals(0, countText(str(R.string.security_recovery_missing))) - } - - @Test - fun thePinRowOpensThePinChange() { - showSecurity(recoveryPhrase = true) - - rule.onNodeWithText(str(R.string.auth_method_pin_title)).performClick() - - waitFor { currentRoute() == NavRoutes.PinCodeConfig.name } - } - - @Test - fun settingUpAPhraseShowsTwelveWordsThenAsksForThree() { - 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 } - words.split(' ').forEach { word -> waitForText(word) } - rule.onNodeWithText(str(R.string.backup_phrase_written)).performClick() - - waitForText(str(R.string.onboarding_verify_title)) - val step = vault.uiState.value.step as BackupStep.CheckNewPhrase - step.positions.forEach { rule.onNodeWithText(str(R.string.onboarding_verify_label, it + 1)).assertExists() } - } - - @Test - fun aPhraseThatAlreadyExistsIsReported() { - showSecurity(recoveryPhrase = false) - `when`(app.security.hasRecoverySeed()).thenReturn(true) - - rule.onNodeWithText(str(R.string.security_recovery_missing)).performClick() - waitForText(str(R.string.backup_reauth_title)) - typePin("123456") - - waitForText(str(R.string.backup_phrase_exists)) - } - - @Test - fun theEraseRowNeedsThePinFirst() { - showSecurity(recoveryPhrase = true) - - scrollToText(str(R.string.security_erase_desc)) - rule.onNodeWithText(str(R.string.security_erase_desc)).assertIsDisplayed() - } - - @Test - fun changingThePinStartsWithTheCurrentOne() { - showPinChange() - - rule.onNodeWithText(str(R.string.pin_change_current_title)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.pin_change_bar_title)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 10) - } - - @Test - fun aWrongCurrentPinIsRefused() { - `when`(app.security.verifyPin(anyCharArray())).thenReturn(false) - showPinChange() - - typePin("000000") - - waitForText(str(R.string.pin_current_wrong)) - rule.onNodeWithText(str(R.string.pin_change_current_title)).assertIsDisplayed() - } - - @Test - fun aNewPinIsConfirmedAndSaved() { - val saved = mutableListOf() - val passed = mutableListOf() - `when`(app.security.changePin(anyCharArray())).thenAnswer { - val pin = it.getArgument(0) - passed += pin - saved += String(pin) - PinChange.SET - } - showPinChange() - - typePin("123456") - waitForText(str(R.string.pin_create_title)) - typePin("654321") - waitForText(str(R.string.pin_change_confirm_title)) - typePin("654321") - - waitFor { rule.onAllNodesWithContentDescription(str(R.string.pin_changed)).fetchSemanticsNodes().isNotEmpty() } - assertEquals(listOf("654321"), saved) - waitFor { currentRoute() == NavRoutes.SecuritySettingsRoute.name } - // The PIN handed over was wiped once stored. - assertTrue(passed.single().all { it == '\u0000' }) - } - - @Test - fun theBiometricSwitchIsOffLimitsWithoutUsableHardware() { - showSecurity(recoveryPhrase = true, biometric = BiometricStatus.UNAVAILABLE) - - biometricSwitch().assertIsNotEnabled() - rule.onNodeWithText(str(R.string.security_biometric_unavailable)).assertExists() - } - - @Test - fun theBiometricSwitchIsOffLimitsWithoutAnEnrolledFingerprint() { - showSecurity(recoveryPhrase = true, biometric = BiometricStatus.NOT_ENROLLED) - - biometricSwitch().assertIsNotEnabled() - rule.onNodeWithText(str(R.string.security_biometric_not_enrolled)).assertExists() - } - - @Test - fun theBiometricSwitchWorksWithUsableHardware() { - showSecurity(recoveryPhrase = true, biometric = BiometricStatus.AVAILABLE) - - biometricSwitch().assertIsEnabled() - } - - private fun biometricSwitch() = rule.onNode(isToggleable() and hasText(str(R.string.security_biometric_title))) - - @Test - fun aMismatchedConfirmationStartsTheNewPinAgain() { - showPinChange() - - typePin("123456") - waitForText(str(R.string.pin_create_title)) - typePin("111111") - waitForText(str(R.string.pin_change_confirm_title)) - typePin("222222") - - waitForText(str(R.string.pin_mismatch_retry)) - rule.onNodeWithText(str(R.string.pin_create_title)).assertIsDisplayed() - } - - @Test - fun afterARecoveryUnlockTheNewPinComesFirstWithNoWayBack() { - showPinChange(requiresCurrent = false) - - rule.onNodeWithText(str(R.string.pin_create_title)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.pin_reset_subtitle)).assertIsDisplayed() - assertEquals(0, countText(str(R.string.pin_change_bar_title))) - } - - // --- wipe PIN ------------------------------------------------------------------------- - - private fun showWipePin(pinIsRight: Boolean = true) { - `when`(app.security.verifyPin(anyCharArray())).thenReturn(pinIsRight) - setNavScreen( - viewModels(app.securitySettings()), - route = NavRoutes.WipePinRoute.name, - start = NavRoutes.SecuritySettingsRoute.name, - ) { nav -> WipePinScreen(nav) } - navigateTo(NavRoutes.WipePinRoute.name) - } - - @Test - fun theWipePinRowOpensTheWipePinPage() { - showSecurity(recoveryPhrase = true) - - scrollToText(str(R.string.wipe_pin_row_desc)) - rule.onNodeWithText(str(R.string.wipe_pin_row_desc)).performClick() - - waitFor { currentRoute() == NavRoutes.WipePinRoute.name } - } - - @Test - fun theWipePinPageAsksForThePinFirst() { - showWipePin(pinIsRight = false) - - rule.onNodeWithText(str(R.string.wipe_pin_current_subtitle)).assertIsDisplayed() - typePin("000000") - - waitForText(str(R.string.pin_current_wrong)) - assertEquals(0, countText(str(R.string.wipe_pin_set))) - } - - @Test - fun aWipePinIsConfirmedAndSaved() { - val saved = mutableListOf() - val passed = mutableListOf() - showWipePin() - `when`(app.security.configureWipePin(anyCharArray())).thenAnswer { - val pin = it.getArgument(0) - passed += pin - saved += String(pin) - pin.fill('\u0000') - WipePinChange.SET - } - - typePin("123456") - waitForText(str(R.string.wipe_pin_intro)) - rule.onNodeWithText(str(R.string.wipe_pin_backups)).assertIsDisplayed() - scrollToText(str(R.string.wipe_pin_set)) - rule.onNodeWithText(str(R.string.wipe_pin_set)).performClick() - waitForText(str(R.string.wipe_pin_new_title)) - typePin("864200") - waitForText(str(R.string.wipe_pin_confirm_title)) - typePin("864200") - - waitForText(str(R.string.wipe_pin_saved)) - assertEquals(listOf("864200"), saved) - assertTrue(passed.single().all { it == '\u0000' }) - } - - @Test - fun aWipePinEqualToThePinIsRefused() { - showWipePin() - `when`(app.security.configureWipePin(anyCharArray())).thenReturn(WipePinChange.SAME_AS_PIN) - - 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("123456") - waitForText(str(R.string.wipe_pin_confirm_title)) - typePin("123456") - - waitForText(str(R.string.wipe_pin_same_as_pin)) - rule.onNodeWithText(str(R.string.wipe_pin_new_title)).assertIsDisplayed() - } - - @Test - fun theWipePinCanBeTurnedOff() { - showWipePin() - `when`(app.security.removeWipePin()).thenReturn(true) - - 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_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 -------------------------------------------------------------------- - - /** 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 theDecoySecurityPageListsTheSameRowsAsTheRealVault() { - app.inDecoySession() - showSecurity(recoveryPhrase = true) - - assertSecurityRowsListed() - } - - @Test - fun aPinChangeInTheDecoyVaultFailsLikeAStorageError() { - app.inDecoySession() - 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) } - - 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 deleted file mode 100644 index 75e42b65..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt +++ /dev/null @@ -1,260 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.pasich.encly.R -import com.pasich.encly.core.locale.AppLanguage -import com.pasich.encly.core.locale.AppLocales -import com.pasich.encly.domain.model.FontStyleType -import com.pasich.encly.domain.model.SwipeAction -import com.pasich.encly.domain.model.ThemePalette -import com.pasich.encly.domain.model.ThemeType -import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.settings.AppearanceScreen -import com.pasich.encly.presentation.screen.settings.SettingsScreen -import com.pasich.encly.ui.theme.colorSchemeFor -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Test -import org.robolectric.annotation.Config - -class SettingsScreensTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - private var surface: Color = Color.Unspecified - - @After - fun resetLanguage() { - AppLocales.apply(AppLanguage.SYSTEM) - } - - private fun showSettings() { - setNavScreen(viewModels(app.settingsVm()), route = NavRoutes.SettingsRoute.name) { nav -> SettingsScreen(nav) } - } - - private fun showAppearance() { - setNavScreen( - viewModels(app.appearance()), - route = NavRoutes.AppearanceRoute.name, - liveTheme = app.settings.theme, - ) { nav -> - val shown = MaterialTheme.colorScheme.surface - SideEffect { surface = shown } - AppearanceScreen(nav) - } - } - - @Test - fun settingsListsEverySection() { - showSettings() - - 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.settings_appearance)) - scrollToText(str(R.string.settings_language)) - assertBodyNotEmpty(minTexts = 6) - } - - @Test - fun theDecoyVaultListsTheSameSectionsAsTheRealOne() { - app.inDecoySession() - showSettings() - - 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 - fun theAppearanceRowOpensAppearance() { - showSettings() - - rule.onNodeWithText(str(R.string.settings_appearance)).performClick() - - waitFor { currentRoute() == NavRoutes.AppearanceRoute.name } - } - - @Test - fun theSecurityRowOpensSecurity() { - showSettings() - scrollToText(str(R.string.security_title)) - - rule.onNodeWithText(str(R.string.security_title)).performClick() - - waitFor { currentRoute() == NavRoutes.SecuritySettingsRoute.name } - } - - @Test - fun theShowTasksSwitchIsSaved() { - showSettings() - scrollToText(str(R.string.settings_show_tasks)) - - rule.onNodeWithText(str(R.string.settings_show_tasks)).performClick() - - waitFor { !app.settings.showTasks.value } - } - - @Test - fun theSwipeSectionListsTheMasterSwitchAndBothDirections() { - showSettings() - - scrollToText(str(R.string.settings_swipe_section), ignoreCase = true) - scrollToText(str(R.string.settings_swipe_enabled)) - scrollToText(str(R.string.settings_swipe_right)) - scrollToText(str(R.string.settings_swipe_left)) - // The defaults: pin on the right, archive on the left. - scrollToText(str(R.string.swipe_choice_pin)) - } - - @Test - fun theSwipeSwitchIsSavedAndHidesTheDirectionRows() { - showSettings() - scrollToText(str(R.string.settings_swipe_enabled)) - - rule.onNodeWithText(str(R.string.settings_swipe_enabled)).performClick() - - waitFor { !app.settings.swipe.value.enabled } - waitFor { countText(str(R.string.settings_swipe_right)) == 0 } - } - - @Test - fun theActionOfADirectionIsChosenInADialogAndSaved() { - showSettings() - scrollToText(str(R.string.settings_swipe_left)) - - rule.onNodeWithText(str(R.string.settings_swipe_left)).performClick() - waitForText(str(R.string.swipe_choice_color)) - rule.onNodeWithText(str(R.string.swipe_choice_color)).performClick() - rule.onNodeWithText(str(R.string.done)).performClick() - - waitFor { app.settings.swipe.value.left == SwipeAction.NEXT_COLOR } - assertEquals(SwipeAction.PIN, app.settings.swipe.value.right) - } - - // Android 12L: AppCompat keeps the per-app language itself (13+ hands it to LocaleManager, - // which Robolectric does not persist). - @Config(sdk = [32]) - @Test - fun theLanguageIsSwitchedInTheApp() { - showSettings() - scrollToText(str(R.string.settings_language)) - - rule.onNodeWithText(str(R.string.settings_language)).performClick() - waitForText(str(R.string.language_dialog_title)) - rule.onNodeWithText(str(R.string.language_name_uk)).performClick() - rule.onNodeWithText(str(R.string.done)).performClick() - - waitFor { AppLocales.current() == AppLanguage.UKRAINIAN } - assertEquals(0, countText(str(R.string.language_dialog_title))) - } - - @Test - fun cancellingTheLanguageChoiceKeepsTheLanguage() { - showSettings() - scrollToText(str(R.string.settings_language)) - rule.onNodeWithText(str(R.string.settings_language)).performClick() - waitForText(str(R.string.language_dialog_title)) - - rule.onNodeWithText(str(R.string.language_name_uk)).performClick() - rule.onNodeWithText(str(R.string.cancel)).performClick() - - waitFor { countText(str(R.string.language_dialog_title)) == 0 } - assertEquals(AppLanguage.SYSTEM, AppLocales.current()) - } - - @Test - fun appearanceOffersTheDefaultTextSizeAndLineSpacingOfNotes() { - app.settings.fontSize.value = 22 - showAppearance() - - scrollToText(str(R.string.appearance_text_section), ignoreCase = true) - scrollToText(str(R.string.font_size_title)) - scrollToText(str(R.string.note_line_spacing_title)) - scrollToText(str(R.string.font_size_value, 22)) - scrollToText(str(R.string.appearance_text_desc)) - } - - @Test - fun appearanceShowsEveryThemeModeAndFont() { - showAppearance() - - listOf( - R.string.palette_paper, - R.string.palette_forest, - R.string.palette_ocean, - R.string.palette_graphite, - R.string.palette_midnight, - R.string.theme_type_system, - R.string.theme_type_light, - R.string.theme_type_dark, - ).forEach { rule.onNodeWithText(str(it)).assertExists() } - scrollToText(str(R.string.font_style_tech)) - assertBodyNotEmpty(minTexts = 10) - } - - @Test - fun choosingAThemeRecolorsTheApp() { - showAppearance() - assertEquals(colorSchemeFor(ThemePalette.PAPER, false).surface, surface) - - rule.onNodeWithText(str(R.string.palette_ocean)).performClick() - - waitFor { app.settings.theme.value.palette == ThemePalette.OCEAN } - assertEquals(colorSchemeFor(ThemePalette.OCEAN, false).surface, surface) - } - - @Test - fun darkModeRecolorsTheApp() { - showAppearance() - - rule.onNodeWithText(str(R.string.theme_type_dark)).performClick() - - waitFor { app.settings.theme.value.type == ThemeType.DARK } - assertEquals(colorSchemeFor(ThemePalette.PAPER, true).surface, surface) - rule.onNodeWithText(str(R.string.appearance_mode_note_dark)).assertIsDisplayed() - } - - @Test - fun midnightIsAlwaysDarkAndLocksTheMode() { - showAppearance() - - rule.onNodeWithText(str(R.string.palette_midnight)).performClick() - - waitFor { app.settings.theme.value.palette == ThemePalette.MIDNIGHT } - assertEquals(colorSchemeFor(ThemePalette.MIDNIGHT, true).surface, surface) - rule.onNodeWithText(str(R.string.appearance_mode_note_midnight)).assertIsDisplayed() - rule.onNodeWithText(str(R.string.theme_type_light)).assertIsNotEnabled() - } - - @Test - fun theFontChoiceIsSaved() { - showAppearance() - scrollToText(str(R.string.font_style_tech)) - - rule.onNodeWithText(str(R.string.font_style_tech)).performClick() - - waitFor { app.settings.theme.value.fontStyle == FontStyleType.TECH_MINIMAL } - assertFalse( - rule.onNodeWithText( - str(R.string.font_style_tech), - ).fetchSemanticsNode().config.contains(SemanticsProperties.Disabled), - ) - } -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TagsAndTrashTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/TagsAndTrashTest.kt deleted file mode 100644 index be35e550..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/TagsAndTrashTest.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.longClick -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTouchInput -import com.pasich.encly.R -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.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.screen.EditTagScreen -import com.pasich.encly.presentation.screen.trash.TrashScreen -import org.junit.Assert.assertEquals -import org.junit.Test - -class TagsAndTrashTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - - private fun showTags() { - setNavScreen(viewModels(app.tagList()), route = NavRoutes.EditTagRoute.name) { nav -> EditTagScreen(nav) } - } - - private fun showTrash() { - setNavScreen(viewModels(app.trash()), route = NavRoutes.TrashRoute.name) { nav -> TrashScreen(nav) } - } - - private fun trashed(vararg titles: String) { - app.notes.trashNotes.value = titles.mapIndexed { index, title -> - Note(id = index + 1L, title = title, value = "[]", isTrash = true, date = 1_700_000_000_000) - } - } - - @Test - fun noTagsShowsTheFieldAndTheEmptyState() { - showTags() - - waitForText(str(R.string.tags_empty_title)) - rule.onNodeWithText(str(R.string.tags_empty_desc)).assertIsDisplayed() - rule.onNode(hasSetTextAction()).assertExists() - assertBodyNotEmpty(minTexts = 3) - } - - @Test - fun tagsAreListedWithTheirNoteCounts() { - val work = Tag(id = 1, nameTag = "Work") - app.withTags(work, Tag(id = 2, nameTag = "Home", isVisible = false, position = 1)) - app.notes.allNotesWithTags.value = listOf( - NoteWithTag(Note(id = 1, title = "a", tagId = 1), work), - NoteWithTag(Note(id = 2, title = "b", tagId = 1), work), - ) - showTags() - - waitForText("Work") - rule.onNodeWithText("Home").assertIsDisplayed() - rule.onNodeWithText(str(R.string.tag_hidden)).assertIsDisplayed() - waitForText("2") - rule.onNodeWithText(str(R.string.tags_hint)).assertIsDisplayed() - } - - @Test - fun aNewTagIsCreatedFromTheField() { - showTags() - waitForText(str(R.string.tags_empty_title)) - - rule.onNode(hasSetTextAction()).performTextInput("Travel") - rule.onNodeWithText(str(R.string.tag_add)).assertIsEnabled().performClick() - - waitFor { app.tags.tags.value.any { it.nameTag == "Travel" } } - waitForText("Travel") - } - - @Test - fun aTakenNameIsFlaggedAsYouType() { - app.withTags(Tag(id = 1, nameTag = "Work")) - showTags() - waitForText("Work") - - rule.onNode(hasSetTextAction()).performTextInput("work") - - waitForText(str(R.string.tag_name_taken)) - rule.onNodeWithText(str(R.string.tag_add)).assertIsNotEnabled() - } - - @Test - fun tappingATagOpensItsSheet() { - app.withTags(Tag(id = 1, nameTag = "Work")) - showTags() - waitForText("Work") - - rule.onNode(hasText("Work") and hasClickAction()).performClick() - - waitForText(str(R.string.tag_edit)) - rule.onNodeWithText(str(R.string.tag_show_in_list)).assertExists() - rule.onNodeWithText(str(R.string.delete_tag)).assertExists() - } - - @Test - fun aTagMovesDownWithTheAccessibilityAction() { - app.withTags(Tag(id = 1, nameTag = "Work"), Tag(id = 2, nameTag = "Home", position = 1)) - showTags() - waitForText("Home") - - val actions = rule.onNode(hasText("Work") and hasClickAction()).fetchSemanticsNode() - .config[SemanticsActions.CustomActions] - rule.runOnUiThread { actions.single { it.label == str(R.string.tag_move_down) }.action() } - - waitFor { app.tags.tags.value.map { it.nameTag } == listOf("Home", "Work") } - } - - @Test - fun anEmptyTrashSaysSo() { - showTrash() - - waitForText(str(R.string.empty_trash_title)) - rule.onNodeWithText(str(R.string.empty_trash_desc)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 2) - } - - @Test - fun trashedNotesAreListedWithTheHint() { - trashed("Old plan", "Draft") - showTrash() - - waitForText("Old plan") - rule.onNodeWithText("Draft").assertIsDisplayed() - rule.onNodeWithText(str(R.string.trash_hint)).assertIsDisplayed() - } - - @Test - fun longPressSelectsAndOffersRestoreAndDeleteForever() { - trashed("Old plan", "Draft") - showTrash() - waitForText("Old plan") - - rule.onNode(hasText("Old plan") and hasClickAction()).performTouchInput { longClick() } - - waitForText(plural(R.plurals.trash_selected, 1, 1)) - rule.onNodeWithContentDescription(str(R.string.restore)).assertIsDisplayed() - rule.onNodeWithContentDescription(str(R.string.delete_forever)).assertIsDisplayed() - } - - @Test - fun restoringTheSelectionTakesTheNoteOutOfTheTrash() { - trashed("Old plan") - showTrash() - waitForText("Old plan") - rule.onNode(hasText("Old plan") and hasClickAction()).performTouchInput { longClick() } - waitForText(plural(R.plurals.trash_selected, 1, 1)) - - rule.onNodeWithContentDescription(str(R.string.restore)).performClick() - - waitFor { app.notes.updatedNotes.any { it.id == 1L && !it.isTrash } } - } - - @Test - fun tappingATrashedNoteOpensItReadOnly() { - trashed("Old plan") - showTrash() - waitForText("Old plan") - - rule.onNode(hasText("Old plan") and hasClickAction()).performClick() - - waitFor { currentRoute() == TestRoutes.EDIT_NOTE } - assertEquals(true, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getBoolean("isReadTrashOnly") }) - } -} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt deleted file mode 100644 index 84185d0f..00000000 --- a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt +++ /dev/null @@ -1,367 +0,0 @@ -package com.pasich.encly.ui.screens - -import androidx.activity.OnBackPressedDispatcher -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.getOrNull -import androidx.compose.ui.state.ToggleableState -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.onAllNodesWithContentDescription -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onFirst -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performImeAction -import androidx.compose.ui.test.performTextInput -import com.pasich.encly.R -import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.Task -import com.pasich.encly.presentation.screen.TasksScreen -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class TasksScreenTest : ComposeScreenTest() { - private val app by lazy { TestApp(context) } - - private lateinit var back: OnBackPressedDispatcher - - private fun show(openNewTask: Boolean = false) { - setNavScreen(viewModels(app.tasksVm()), route = TestRoutes.TASKS) { nav -> - back = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher - TasksScreen(nav, openNewTask = openNewTask) - } - } - - private fun pressBack() { - rule.runOnIdle { back.onBackPressed() } - rule.waitForIdle() - } - - @Test - fun noTasksShowsTheEmptyStateWithAnAction() { - show() - - waitForText(str(R.string.task_empty_title)) - rule.onNodeWithText(str(R.string.task_empty_desc)).assertIsDisplayed() - assertBodyNotEmpty(minTexts = 3) - } - - @Test - fun oneOpenTaskIsListedWithItsPriority() { - app.withTasks(Task(id = 1, title = "Buy milk", priority = 2)) - show() - - waitForText("Buy milk") - assertTrue(countText(str(R.string.priority_high)) >= 1) - assertBodyNotEmpty() - } - - @Test - fun manyTasksShowOpenOnesAndTheDoneSection() { - app.withTasks( - *Array(6) { Task(id = it + 1L, title = "Open $it") }, - Task(id = 20, title = "Finished", isCompleted = true, completedDate = 1_700_000_000_000), - ) - show() - - waitForText("Open 0") - scrollToText(str(R.string.task_done_section, 1), ignoreCase = true) - rule.onNodeWithText("Finished").assertIsDisplayed() - assertBodyNotEmpty(minTexts = 6) - } - - @Test - fun allOpenTasksDoneSaysSo() { - app.withTasks(Task(id = 1, title = "Finished", isCompleted = true, completedDate = 1L)) - show() - - waitForText(str(R.string.task_all_done_title)) - rule.onNodeWithText("Finished").assertIsDisplayed() - } - - @Test - fun aNewTaskIsAddedFromTheSheet() { - show() - waitForText(str(R.string.task_empty_title)) - - rule.onAllNodesWithText(str(R.string.task_add)).onFirst().performClick() - rule.waitForIdle() - rule.onNodeWithText(str(R.string.add)).assertIsNotEnabled() - rule.onAllNodes(hasSetTextAction())[0].performTextInput("Water the plants") - rule.onNodeWithText(str(R.string.add)).performClick() - - waitFor { app.tasks.tasks.value.any { it.title == "Water the plants" } } - waitForText("Water the plants") - assertTrue(rule.onAllNodesWithText(str(R.string.add)).fetchSemanticsNodes().isEmpty()) - } - - @Test - fun theHomeCardsNewTaskLinkOpensTheSheetOnArrival() { - show(openNewTask = true) - - waitForText(str(R.string.priority_select_title), ignoreCase = true) - assertTrue(rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty()) - } - - @Test - fun tickingATaskCompletesIt() { - app.withTasks(Task(id = 1, title = "Call mum")) - show() - waitForText("Call mum") - - rule.onNode(isTaskCheckbox).performClick() - - waitFor { app.tasks.tasks.value.single().isCompleted } - waitForText(str(R.string.task_done_section, 1), ignoreCase = true) - assertEquals( - ToggleableState.On, - rule.onNode(isTaskCheckbox).fetchSemanticsNode().config[SemanticsProperties.ToggleableState], - ) - } - - @Test - fun aDeletedTaskCanBeBroughtBackFromTheSnackbar() { - app.withTasks(Task(id = 1, title = "Call mum")) - show() - waitForText("Call mum") - - rule.onNodeWithContentDescription(str(R.string.task_edit_placeholder)).performClick() - waitForText(str(R.string.task_delete)) - rule.onNodeWithText(str(R.string.task_delete)).performClick() - - waitForText(str(R.string.task_deleted)) - assertTrue(app.tasks.tasks.value.isEmpty()) - rule.onNodeWithText(str(R.string.undo)).performClick() - waitFor { app.tasks.tasks.value.singleOrNull()?.title == "Call mum" } - } - - @Test - fun foldedATaskShowsOnlyItsNextStepAndTickingItMovesOn() { - withShoppingList(Subtask(id = 12, taskId = 1, title = "Bread", position = 2)) - show() - - waitForText("Shop") - // The first open one, not the done Milk or the later Bread; and no "1/3" text anywhere. - waitForText("Eggs") - assertEquals(0, countText("Milk")) - assertEquals(0, countText("Bread")) - assertEquals(0, countText("/3", substring = true)) - assertEquals(0, countText(str(R.string.subtask_placeholder))) - - rule.onNodeWithContentDescription(str(R.string.subtask_next_step, "Eggs")).performClick() - - waitFor { app.tasks.subtasks.value.single { it.id == 11L }.isCompleted } - waitForText("Bread") - waitFor { countText("Eggs") == 0 } - // Saved in place: no sheet opened, and no completion offer while one is still open. - assertTrue(rule.onAllNodesWithText(str(R.string.save)).fetchSemanticsNodes().isEmpty()) - assertEquals(0, countText(str(R.string.subtasks_all_done))) - } - - @Test - fun tickingTheLastOpenStepKeepsItStruckAndOffersToComplete() { - withShoppingList() - show() - waitForText("Eggs") - - rule.onNodeWithContentDescription(str(R.string.subtask_next_step, "Eggs")).performClick() - - waitFor { app.tasks.subtasks.value.all { it.isCompleted } } - waitForText(str(R.string.subtasks_all_done)) - rule.onNodeWithText("Eggs").assertIsDisplayed() - assertEquals(0, countText("Milk")) - assertTrue(!app.tasks.tasks.value.single().isCompleted) - } - - @Test - fun aTaskWithoutSubtasksHasNoLeafAndOpensToFirstStep() { - app.withTasks(Task(id = 1, title = "Call mum")) - show() - waitForText("Call mum") - assertEquals(0, countText(str(R.string.subtask_first_step))) - assertEquals(1, rule.onAllNodes(isTaskCheckbox).fetchSemanticsNodes().size) - - rule.onNodeWithText("Call mum").performClick() - - waitForText(str(R.string.subtask_first_step)) - // The task row and the add leaf: no sub-task checkboxes. - assertEquals(1, rule.onAllNodes(isTaskCheckbox).fetchSemanticsNodes().size) - } - - @Test - fun aTapOnTheRowShowsTheWholeTreeAndBackFoldsIt() { - withShoppingList(Subtask(id = 12, taskId = 1, title = "Bread", position = 2)) - show() - waitForText("Eggs") - - rule.onNodeWithText("Shop").performClick() - - waitForText("Milk") - rule.onNodeWithText("Bread").assertIsDisplayed() - rule.onNodeWithText(str(R.string.subtask_placeholder)).assertIsDisplayed() - - pressBack() - - waitFor { countText("Milk") == 0 } - rule.onNodeWithText("Eggs").assertIsDisplayed() - // The screen is still there. - rule.onNodeWithText("Shop").assertIsDisplayed() - } - - @Test - fun theLeafsChevronOpensTheTree() { - withShoppingList() - show() - waitForText("Eggs") - - rule.onNodeWithContentDescription(str(R.string.subtasks_show)).performClick() - - waitForText("Milk") - rule.onNodeWithContentDescription(str(R.string.subtasks_hide)).performClick() - waitFor { countText("Milk") == 0 } - } - - @Test - fun theRowReadsItsProgressAndOffersActions() { - withShoppingList() - show() - waitForText("Shop") - val row = rule.onNodeWithText("Shop") - val config = row.fetchSemanticsNode().config - - val summary = context.resources.getQuantityString(R.plurals.subtasks_done_summary, 2, 1, 2) - assertTrue(config[SemanticsProperties.StateDescription].startsWith(summary)) - assertEquals(str(R.string.subtasks_show), config[SemanticsActions.OnClick].label) - assertEquals( - listOf(str(R.string.subtasks_show), str(R.string.task_edit_placeholder), str(R.string.subtask_add)), - config[SemanticsActions.CustomActions].map { it.label }, - ) - row.performClick() - waitFor { - row.fetchSemanticsNode().config.getOrNull(SemanticsProperties.StateDescription) - ?.endsWith(str(R.string.subtasks_state_shown)) == true - } - assertEquals(str(R.string.subtasks_hide), row.fetchSemanticsNode().config[SemanticsActions.OnClick].label) - } - - @Test - fun theFirstStepTurnsIntoAFieldThatAddsOnDone() { - app.withTasks(Task(id = 1, title = "Call mum")) - show() - waitForText("Call mum") - rule.onNodeWithText("Call mum").performClick() - waitForText(str(R.string.subtask_first_step)) - - rule.onNodeWithText(str(R.string.subtask_first_step)).performClick() - rule.waitForIdle() - val field = rule.onNode(hasSetTextAction()) - field.assertIsFocused() - field.performTextInput("Birthday") - field.performImeAction() - - waitFor { app.tasks.subtasks.value.singleOrNull()?.title == "Birthday" } - waitForText("Birthday") - // Still open, empty and focused, for the next one; the FAB is out of the way meanwhile. - rule.onNode(hasSetTextAction()).assertIsFocused() - assertEquals(0, countText(str(R.string.task_add))) - - // Back closes the field first, and only then folds the tree. - pressBack() - waitFor { rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isEmpty() } - rule.onNodeWithText(str(R.string.subtask_placeholder)).assertIsDisplayed() - pressBack() - waitFor { countText(str(R.string.subtask_placeholder)) == 0 } - // Folded, it now shows its next step like any task with sub-tasks. - rule.onNodeWithText("Birthday").assertIsDisplayed() - } - - @Test - fun aDoneTaskShowsNoNextStepUntilOpened() { - app.withTasks(Task(id = 1, title = "Shop", isCompleted = true, completedDate = 1L)) - app.tasks.subtasks.value = listOf(Subtask(id = 11, taskId = 1, title = "Eggs", position = 0)) - show() - waitForText("Shop") - assertEquals(0, countText("Eggs")) - - rule.onNodeWithText("Shop").performClick() - - waitForText("Eggs") - rule.onNodeWithContentDescription("Eggs").performClick() - waitFor { app.tasks.subtasks.value.single().isCompleted } - } - - @Test - fun theEditButtonOpensTheSheet() { - withShoppingList() - show() - waitForText("Shop") - - rule.onNodeWithContentDescription(str(R.string.task_edit_placeholder)).performClick() - - waitForText(str(R.string.task_delete)) - // The sheet with the task's checklist. - waitForText("Milk") - } - - @Test - fun moveUpAndDownAreOnTheTitleTalkBackFocusesInTheTree() { - withShoppingList() - show() - waitForText("Shop") - rule.onNodeWithText("Shop").performClick() - waitForText("Milk") - - val milk = rule.onNode(hasText("Milk") and hasClickAction()) - val eggs = rule.onNode(hasText("Eggs") and hasClickAction()) - assertEquals(listOf(str(R.string.tag_move_down)), customActions(milk)) - assertEquals(listOf(str(R.string.tag_move_up)), customActions(eggs)) - - // And they move the row. - rule.runOnIdle { - milk.fetchSemanticsNode().config[SemanticsActions.CustomActions].single().action() - } - waitFor { app.tasks.subtasks.value.sortedBy { it.position }.map { it.title } == listOf("Eggs", "Milk") } - } - - @Test - fun moveUpAndDownAreOnTheDragHandlesInTheSheet() { - withShoppingList() - show() - waitForText("Shop") - rule.onNodeWithContentDescription(str(R.string.task_edit_placeholder)).performClick() - waitForText(str(R.string.task_delete)) - waitForText("Milk") - - val handles = rule.onAllNodesWithContentDescription(str(R.string.tag_drag_handle)) - assertEquals(listOf(str(R.string.tag_move_down)), customActions(handles[0])) - assertEquals(listOf(str(R.string.tag_move_up)), customActions(handles[1])) - } - - private fun customActions(node: SemanticsNodeInteraction): List = - node.fetchSemanticsNode().config.getOrNull(SemanticsActions.CustomActions).orEmpty().map { it.label } - - private fun withShoppingList(vararg more: Subtask) { - app.withTasks(Task(id = 1, title = "Shop")) - app.tasks.subtasks.value = listOf( - Subtask(id = 10, taskId = 1, title = "Milk", isCompleted = true, position = 0), - Subtask(id = 11, taskId = 1, title = "Eggs", position = 1), - ) + more - } - - private companion object { - /** A task's own checkbox; the filter chips are checkboxes too, but with a label. */ - val isTaskCheckbox = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) and - SemanticsMatcher.keyNotDefined(SemanticsProperties.Text) - } -} diff --git a/app/src/test/java/com/pasich/encly/utils/RelativeDayTest.kt b/app/src/test/java/com/pasich/encly/utils/RelativeDayTest.kt deleted file mode 100644 index a7f97b4b..00000000 --- a/app/src/test/java/com/pasich/encly/utils/RelativeDayTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.pasich.encly.utils - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.Calendar -import java.util.Date - -class RelativeDayTest { - - private val now: Date = Calendar.getInstance().apply { - set(2026, Calendar.MARCH, 10, 12, 0, 0) - }.time - - private fun at(day: Int, hour: Int): Date = Calendar.getInstance().apply { - set(2026, Calendar.MARCH, day, hour, 30, 0) - }.time - - @Test - fun sameDayIsToday() { - assertEquals(RelativeDay.TODAY, relativeDay(at(10, 0), now)) - assertEquals(RelativeDay.TODAY, relativeDay(at(10, 23), now)) - } - - @Test - fun previousDayIsYesterday() { - assertEquals(RelativeDay.YESTERDAY, relativeDay(at(9, 8), now)) - } - - /** Regression: the old formatter compared "now" with itself and called every date "today". */ - @Test - fun olderDatesAreNotToday() { - assertEquals(RelativeDay.OTHER, relativeDay(at(1, 8), now)) - assertEquals(RelativeDay.OTHER, relativeDay(at(11, 8), now)) - } - - @Test - fun yesterdayAcrossTheYearBoundary() { - val newYear = Calendar.getInstance().apply { set(2026, Calendar.JANUARY, 1, 9, 0, 0) }.time - val newYearsEve = Calendar.getInstance().apply { set(2025, Calendar.DECEMBER, 31, 22, 0, 0) }.time - assertEquals(RelativeDay.YESTERDAY, relativeDay(newYearsEve, newYear)) - } - - @Test - fun sameYearOnlyWithinTheCalendarYear() { - val newYearsEve = Calendar.getInstance().apply { set(2025, Calendar.DECEMBER, 31, 22, 0, 0) }.time - assertTrue(isSameYear(at(1, 8), now)) - assertFalse(isSameYear(newYearsEve, now)) - } -} diff --git a/build-logic/.gitignore b/build-logic/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/build-logic/.gitignore @@ -0,0 +1 @@ +/build diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 00000000..4e305234 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,16 @@ +// Convention plugins shared by the modules (kmp-library, compose, quality, spotless). The plugins they +// apply must be on this build's classpath; their versions come from the root gradle/libs.versions.toml. +plugins { + `kotlin-dsl` +} + +dependencies { + implementation(libs.kotlin.gradle.plugin) + implementation(libs.android.gradle.plugin) + implementation(libs.compose.gradle.plugin) + implementation(libs.kotlin.compose.gradle.plugin) + implementation(libs.spotless.gradle.plugin) + implementation(libs.detekt.gradle.plugin) + implementation(libs.kover.gradle.plugin) + implementation(libs.dokka.gradle.plugin) +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 00000000..1fdd1a2e --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,14 @@ +dependencyResolutionManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" diff --git a/build-logic/src/main/kotlin/ConvertAndroidStrings.kt b/build-logic/src/main/kotlin/ConvertAndroidStrings.kt new file mode 100644 index 00000000..83cc5542 --- /dev/null +++ b/build-logic/src/main/kotlin/ConvertAndroidStrings.kt @@ -0,0 +1,88 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Turns the translations, which stay in Android's `strings.xml` format because that is what Weblate + * reads and writes, into the files Compose Multiplatform resources read. + * + * The two formats differ in how a text is written, not in what it says: + * - Android escapes `'`, `"`, `@` and `?` with a backslash; Compose resources keep the backslash, so + * `Couldn\'t` would reach the screen as typed. The backslash is dropped. + * - Android formats `%d` and `%s` in order and `%%` as a percent sign; Compose resources fill in + * only the numbered `%1$d` and `%1$s` and have no `%%`. `%d` becomes `%$d` by its place in the + * text, and `%%` becomes `%`. + * + * Anything else a format could hold (`%.1f`, `%02d`, a stray `%`) has no meaning in Compose + * resources, so it fails the build instead of reaching the screen as typed. `\n`, `\t`, `\\` and + * `\uXXXX` are resolved by Compose resources themselves and pass through. + */ +@CacheableTask +abstract class ConvertAndroidStrings : DefaultTask() { + /** The folder with `values/`, `values-/`, ... in the Android format. */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sources: DirectoryProperty + + /** The same folders in the Compose resources format; set it as a `composeResources` directory. */ + @get:OutputDirectory + abstract val output: DirectoryProperty + + @TaskAction + fun convert() { + val from = sources.get().asFile + val to = output.get().asFile + to.deleteRecursively() + from.walkTopDown().filter { it.isFile && it.extension == "xml" }.forEach { file -> + val target = to.resolve(file.relativeTo(from).path) + target.parentFile.mkdirs() + target.writeText(convertAndroidStrings(file.readText(), file.relativeTo(from).path)) + } + } +} + +private val TEXT = Regex("""(<(?:string|item)\b[^>]*>)(.*?)()""", RegexOption.DOT_MATCHES_ALL) +private val ESCAPED = Regex("""\\(['"@?])""") +private val SPECIFIER = Regex("""%(?:(\d+)\$)?([-#+ 0,(]*\d*(?:\.\d+)?)([a-zA-Z%])""") + +/** [xml], an Android `strings.xml` read from [file], as a Compose resources file. */ +fun convertAndroidStrings(xml: String, file: String): String = + TEXT.replace(xml) { it.groupValues[1] + convertText(it.groupValues[2], file) + it.groupValues[3] } + +private fun convertText(raw: String, file: String): String { + val text = ESCAPED.replace(raw) { it.groupValues[1] } + val out = StringBuilder() + var last = 0 + var numbered = 0 + var positional = false + var sequential = false + for (match in SPECIFIER.findAll(text)) { + val (position, flags, conversion) = match.destructured + out.append(text, last, match.range.first) + last = match.range.last + 1 + when { + conversion == "%" && position.isEmpty() && flags.isEmpty() -> out.append('%') + + flags.isNotEmpty() || (conversion != "d" && conversion != "s") -> + error("$file: '${match.value}' cannot be filled in by Compose resources, use %d or %s: $raw") + + position.isNotEmpty() -> { + positional = true + out.append(match.value) + } + + else -> { + sequential = true + out.append('%').append(++numbered).append('$').append(conversion) + } + } + } + out.append(text, last, text.length) + check(!(positional && sequential)) { "$file: numbered and unnumbered format arguments in one text: $raw" } + return out.toString() +} diff --git a/build-logic/src/main/kotlin/GenerateChangelogs.kt b/build-logic/src/main/kotlin/GenerateChangelogs.kt new file mode 100644 index 00000000..232797b8 --- /dev/null +++ b/build-logic/src/main/kotlin/GenerateChangelogs.kt @@ -0,0 +1,92 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.util.Properties + +/** + * Bundles the store changelog of the version being built into the app, so "What's new" reads the same text the + * stores show and the app needs no network. + * + * The notes are `fastlane/metadata/android//changelogs/.txt`; [changelogs] is those files of + * the one version, and the locale is the name of the folder two levels above each. They become one Kotlin object + * (see [changelogSource]). A locale without the file is left out, and a version with none at all (the notes are + * written when the release is cut) gives an empty map: the app then has no notes to show. + */ +@CacheableTask +abstract class GenerateChangelogs : DefaultTask() { + /** The version's `.txt` of every locale. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val changelogs: ConfigurableFileCollection + + @get:Input + abstract val versionCode: Property + + /** The source root the generated Kotlin file is written under. */ + @get:OutputDirectory + abstract val output: DirectoryProperty + + @TaskAction + fun generate() { + val notes = changelogs.files + .filter { it.isFile && it.name == "${versionCode.get()}.txt" } + .associate { it.parentFile.parentFile.name to it.readText() } + val target = output.get().asFile.resolve(CHANGELOGS_FILE) + target.parentFile.mkdirs() + target.writeText(changelogSource(versionCode.get(), notes)) + } + + private companion object { + const val CHANGELOGS_FILE = "com/pasich/encly/whatsnew/BundledChangelogs.kt" + } +} + +/** The Kotlin source of `BundledChangelogs`: [notes] (locale folder to text) of the version [versionCode]. */ +fun changelogSource(versionCode: Int, notes: Map): String = buildString { + appendLine("package com.pasich.encly.whatsnew") + appendLine() + appendLine("// Generated by the GenerateChangelogs task from fastlane/metadata/android; do not edit.") + appendLine("internal object BundledChangelogs {") + appendLine(" const val VERSION_CODE: Int = $versionCode") + appendLine() + appendLine(" /** The store changelog of [VERSION_CODE] by locale folder (`en-US`, `uk`, ...). */") + appendLine(" val notes: Map = mapOf(") + notes.toSortedMap().forEach { (locale, text) -> + appendLine(" ${literal(locale)} to ${literal(text.trim())},") + } + appendLine(" )") + appendLine("}") +} + +/** [text] as a Kotlin string literal: quotes, backslashes, `$` and line breaks escaped. */ +private fun literal(text: String): String = buildString { + append('"') + text.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '$' -> append("\\$") + '\n' -> append("\\n") + '\r' -> Unit + '\t' -> append("\\t") + else -> append(char) + } + } + append('"') +} + +/** The version code of `version.properties`, derived like the app's: `MAJOR * 10000 + MINOR * 100 + PATCH`. */ +fun versionCodeOf(versionProperties: File): Int { + val properties = Properties().apply { versionProperties.inputStream().use { load(it) } } + fun part(name: String) = properties.getProperty(name).trim().toInt() + return part("VERSION_MAJOR") * 10000 + part("VERSION_MINOR") * 100 + part("VERSION_PATCH") +} diff --git a/build-logic/src/main/kotlin/SwiftStaticLibrary.kt b/build-logic/src/main/kotlin/SwiftStaticLibrary.kt new file mode 100644 index 00000000..1444bcbc --- /dev/null +++ b/build-logic/src/main/kotlin/SwiftStaticLibrary.kt @@ -0,0 +1,72 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +/** + * Compiles one Swift file into a static library for an Apple SDK, so Kotlin/Native can link it through + * cinterop. The file exports plain C functions (`@_cdecl`) declared in a hand-written header; there is no + * Objective-C header and no Swift module to import. `-runtime-compatibility-version none` keeps the + * back-deployment libraries of the toolchain out of the link line: everything else Swift needs is in the + * SDK. Needs Xcode (`swiftc`, `xcrun`), so it runs on macOS only. + */ +@CacheableTask +abstract class SwiftStaticLibrary +@Inject +constructor(private val exec: ExecOperations) : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val source: RegularFileProperty + + /** The SDK name `xcrun` knows: `iphonesimulator` or `iphoneos`. */ + @get:Input + abstract val sdk: Property + + /** The target triple, for example `arm64-apple-ios15.0-simulator`. */ + @get:Input + abstract val triple: Property + + /** The base name of the library; the file is `lib.a`. */ + @get:Input + abstract val libraryName: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun compile() { + val sdkPath = ByteArrayOutputStream().also { out -> + exec.exec { + commandLine("xcrun", "--sdk", sdk.get(), "--show-sdk-path") + standardOutput = out + } + }.toString().trim() + val out = outputDir.get().asFile + out.mkdirs() + exec.exec { + commandLine( + "swiftc", + "-emit-library", + "-static", + "-parse-as-library", + "-module-name", libraryName.get(), + "-O", + "-runtime-compatibility-version", "none", + "-target", triple.get(), + "-sdk", sdkPath, + "-o", out.resolve("lib${libraryName.get()}.a").path, + source.get().asFile.path, + ) + } + } +} diff --git a/build-logic/src/main/kotlin/encly.compose.gradle.kts b/build-logic/src/main/kotlin/encly.compose.gradle.kts new file mode 100644 index 00000000..40c40875 --- /dev/null +++ b/build-logic/src/main/kotlin/encly.compose.gradle.kts @@ -0,0 +1,28 @@ +// Convention plugin `encly.compose`: Compose Multiplatform for a KMP module (the Compose compiler +// plugin and the JetBrains artifacts). Apply after `encly.kmp-library`. + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") +} + +kotlin { + sourceSets { + // Public composables take Modifier, Color, TextStyle and the like, so these are `api`. + commonMain.dependencies { + api(compose.runtime) + api(compose.foundation) + api(compose.ui) + api(compose.material3) + } + // Compose on the desktop JVM draws with Skiko; currentOs picks its native part for this machine, + // which the desktop tests need. + getByName("desktopTest").dependencies { + implementation(compose.desktop.currentOs) + // runComposeUiTest: Compose UI tests with a mouse and a keyboard on the desktop JVM. + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + } + } +} diff --git a/build-logic/src/main/kotlin/encly.kmp-desktop-app.gradle.kts b/build-logic/src/main/kotlin/encly.kmp-desktop-app.gradle.kts new file mode 100644 index 00000000..a98407d1 --- /dev/null +++ b/build-logic/src/main/kotlin/encly.kmp-desktop-app.gradle.kts @@ -0,0 +1,14 @@ +// Convention plugin `encly.kmp-desktop-app`: a Kotlin Multiplatform module with the desktop JVM target only +// (`desktopMain`, `desktopTest`; no Android, no iOS) and Compose Multiplatform, for the desktop application. +// It declares the target first because `encly.compose` configures the `desktopTest` source set when it is applied. + +plugins { + id("org.jetbrains.kotlin.multiplatform") +} + +kotlin { + jvmToolchain(21) + jvm("desktop") +} + +apply(plugin = "encly.compose") diff --git a/build-logic/src/main/kotlin/encly.kmp-ios.gradle.kts b/build-logic/src/main/kotlin/encly.kmp-ios.gradle.kts new file mode 100644 index 00000000..fe2b559b --- /dev/null +++ b/build-logic/src/main/kotlin/encly.kmp-ios.gradle.kts @@ -0,0 +1,11 @@ +// Convention plugin `encly.kmp-ios`: adds the two iOS targets to a module that uses `encly.kmp-library`. +// A module applies it only when everything it depends on can run on iOS. + +plugins { + id("org.jetbrains.kotlin.multiplatform") +} + +kotlin { + iosArm64() + iosSimulatorArm64() +} diff --git a/build-logic/src/main/kotlin/encly.kmp-library.gradle.kts b/build-logic/src/main/kotlin/encly.kmp-library.gradle.kts new file mode 100644 index 00000000..175b483b --- /dev/null +++ b/build-logic/src/main/kotlin/encly.kmp-library.gradle.kts @@ -0,0 +1,57 @@ +// Convention plugin `encly.kmp-library`: a Kotlin Multiplatform library module. Targets are Android +// (the AGP KMP library plugin) and a desktop JVM; the two iOS ones come from `encly.kmp-ios`. Explicit +// API mode is on, so every public declaration is written out on purpose. The module sets its own +// `namespace`. +// +// Source sets: besides the default hierarchy (commonMain, iosMain, ...) the group `jvmAndAndroid` holds +// what the Android and the desktop target share because both run on the JVM (`jvmAndAndroidMain`, +// `jvmAndAndroidTest`): the JCA crypto, java.io, JUnit and Mockito tests. A module with no code for +// the group simply leaves the directory out. + +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") +} + +kotlin { + explicitApi() + jvmToolchain(21) + compilerOptions { + // `expect class`/`expect object` declarations are a stable way to hide a platform type. + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + android { + compileSdk = 37 + minSdk = 26 + // Unit tests of the Android target run on the JVM (task `testAndroidHostTest`), with Robolectric + // and Android resources where a module needs them. + withHostTest { + isIncludeAndroidResources = true + } + } + jvm("desktop") + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + applyDefaultHierarchyTemplate { + common { + group("jvmAndAndroid") { + withJvm() + // withAndroidTarget() matches only the legacy androidTarget(); the AGP KMP library target + // is a plain androidJvm target. + withCompilations { it.target.platformType == KotlinPlatformType.androidJvm } + } + } + } + + // The public binary API is tracked in /api/: `updateKotlinAbi` rewrites the dump and + // `checkKotlinAbi` (part of `check`) fails when the code and the dump differ. + @OptIn(ExperimentalAbiValidation::class) + abiValidation { + // Declaring the block enables the validation; the defaults are what we want. + } +} diff --git a/build-logic/src/main/kotlin/encly.quality.gradle.kts b/build-logic/src/main/kotlin/encly.quality.gradle.kts new file mode 100644 index 00000000..8974ab06 --- /dev/null +++ b/build-logic/src/main/kotlin/encly.quality.gradle.kts @@ -0,0 +1,32 @@ +// Convention plugin `encly.quality`: static analysis and coverage for a module. detekt runs the +// repository's config/detekt/detekt.yml (default rules plus the Compose rules); Kover measures the +// module, and the module declares its own coverage gates. Formatting is `encly.spotless` at the root. + +plugins { + id("io.gitlab.arturbosch.detekt") + id("org.jetbrains.kotlinx.kover") +} + +val libs = extensions.getByType().named("libs") + +detekt { + buildUponDefaultConfig = true + parallel = true + config.setFrom(rootProject.files("config/detekt/detekt.yml")) +} + +// detekt looks at src/main only; a multiplatform module keeps its code in src//kotlin. +pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { + detekt { + source.setFrom( + fileTree("src") { + include("*/kotlin/**/*.kt") + }, + ) + } +} + +dependencies { + // detekt rule set for Jetpack Compose (Apache-2.0); runs only inside the detekt task. + add("detektPlugins", libs.findLibrary("compose-rules-detekt").get()) +} diff --git a/build-logic/src/main/kotlin/encly.spotless.gradle.kts b/build-logic/src/main/kotlin/encly.spotless.gradle.kts new file mode 100644 index 00000000..9ffc447e --- /dev/null +++ b/build-logic/src/main/kotlin/encly.spotless.gradle.kts @@ -0,0 +1,30 @@ +// Convention plugin `encly.spotless`: ktlint formatting of every Kotlin source and Gradle script of the +// repository. Apply it to the root project only: `./gradlew spotlessCheck` / `spotlessApply`. + +plugins { + id("com.diffplug.spotless") +} + +val ktlintVersion = + extensions + .getByType() + .named("libs") + .findVersion("ktlint") + .get() + .requiredVersion + +spotless { + // ktlint reads its rules from .editorconfig, so the IDE and the build format the same way. It is + // declared explicitly so that an .editorconfig change invalidates Spotless's up-to-date cache. + val editorConfig = rootProject.file(".editorconfig") + kotlin { + target("**/*.kt") + targetExclude("**/build/**", "**/.gradle/**", "**/.kotlin/**", ".claude/**") + ktlint(ktlintVersion).setEditorConfigPath(editorConfig) + } + kotlinGradle { + target("**/*.gradle.kts") + targetExclude("**/build/**", "**/.gradle/**", "**/.kotlin/**", ".claude/**") + ktlint(ktlintVersion).setEditorConfigPath(editorConfig) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index a18cf670..93d394a8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,34 +1,19 @@ // Plugin versions live in gradle/libs.versions.toml (one Kotlin version for every Kotlin plugin). +// The convention plugins in build-logic/ apply AGP, Kotlin Multiplatform, Compose, detekt, Kover and +// Dokka for the library modules; the app still declares its own plugins below. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false - alias(libs.plugins.hilt) apply false - alias(libs.plugins.detekt) apply false - alias(libs.plugins.kover) apply false - alias(libs.plugins.spotless) + alias(libs.plugins.androidx.room) apply false + alias(libs.plugins.metro) apply false + // Formatting for the whole repository (ktlint through Spotless): `./gradlew spotlessCheck` (CI, + // pre-commit hook) / `./gradlew spotlessApply`. Defined in build-logic (ktlint reads .editorconfig). + id("encly.spotless") } -// Formatting: `./gradlew spotlessCheck` (CI, pre-commit hook) / `./gradlew spotlessApply`. -// ktlint reads its rules from .editorconfig, so the IDE and the build format the same way. -spotless { - val ktlintVersion = libs.versions.ktlint.get() - // Declared explicitly so an .editorconfig change invalidates Spotless's up-to-date cache. - val editorConfig = rootProject.file(".editorconfig") - kotlin { - target("**/*.kt") - targetExclude("**/build/**", "**/.gradle/**", "**/.kotlin/**") - ktlint(ktlintVersion).setEditorConfigPath(editorConfig) - } - kotlinGradle { - target("**/*.gradle.kts") - targetExclude("**/build/**", "**/.gradle/**", "**/.kotlin/**") - ktlint(ktlintVersion).setEditorConfigPath(editorConfig) - } -} - -// Opt-in git hooks (.githooks/): pre-commit runs spotlessCheck + detekt when Kotlin/Gradle files +// Opt-in hooks (.githooks/): pre-commit runs spotlessCheck + detekt when Kotlin/Gradle files // are staged, commit-msg enforces Conventional Commits. Same as `git config core.hooksPath .githooks`. tasks.register("installGitHooks") { group = "help" diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index d2cf8e79..b5b1f33d 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -5,13 +5,25 @@ naming: # Jetpack Compose @Composable functions are PascalCase by convention. ignoreAnnotated: ['Composable'] +# Tests are scenarios, not production code: the complexity and style rules (magic numbers in test data, +# one class with many test functions, long lines of expected bytes) do not apply to them. The Kotlin +# Multiplatform modules keep tests in these source sets, which detekt's own defaults do not know. complexity: + excludes: &tests + - '**/test/**' + - '**/commonTest/**' + - '**/jvmAndAndroidTest/**' + - '**/desktopTest/**' + - '**/androidHostTest/**' + - '**/androidUnitTest/**' + - '**/iosTest/**' LongParameterList: # Parameters with defaults (Compose `modifier`, injected ViewModels, optional flags) cost # callers nothing; count only the ones every call has to pass. ignoreDefaultParameters: true style: + excludes: *tests # Unused code. Stricter than the defaults: unused imports are reported too, and nothing is # whitelisted by name except the conventional `_`. Private @Preview functions are only used by # the IDE, so they are exempt. @@ -46,13 +58,32 @@ Compose: - LocalBaseFontSize - LocalFontStyle - LocalSimpleEdit - - LocalNoteLineSpacing - LocalFontStyles - # The slash picker's labels, built once for the editor (editor/blocks/SlashMenuPopup.kt). + # The theme's font set, reused by the editor instead of loading a second one (ui/theme/Type.kt). + - LocalThemeFonts + # App-wide switches provided once by App: the note previews setting, and the locked vault that nothing may + # be drawn over (presentation/navigation/EnclyApp.kt). + - LocalShowNotePreviews + - LocalVaultLocked + # The editor toolbar's tools and button size, provided once around the app and the toolbar + # (presentation/navigation/EnclyApp.kt, presentation/components/editNote/NoteBottomBar.kt). + - LocalEditorToolbar + - LocalToolSizes + # What the note editor needs from its host, provided once per screen (editor/EditorServices.kt, + # editor/theme/EditorTheme.kt) and the slash picker's labels built once for it + # (editor/ui/blocks/SlashMenuPopup.kt). + - LocalEditorTheme + - LocalEditorStrings + - LocalEditorClipboard + - LocalLinkOpener + - LocalEditorLogger - LocalSlashLabels # Theme tokens Material's theme does not carry (ui/theme), read through EnclyTheme. - LocalEnclyColors - LocalEnclyTypography + # The window size class and the spacing density, provided once by App (ui/theme/Adaptive.kt). + - LocalWidthClass + - LocalDensityProfile # The unlock reveal, provided once by App (presentation/effects/UnlockReveal.kt). - LocalUnlockReveal - LocalSpacing @@ -64,6 +95,34 @@ Compose: - LocalNavAnimatedVisibilityScope # Whether the open vault is the decoy one, provided once by App (presentation/navigation/DecoySession.kt). - LocalDecoySession + # What the host app (an Activity on Android) offers the shared UI, provided once around its content + # (platform/Documents.kt, platform/HostServices.kt): the document dialogs, the window, links, toasts, + # the sensitive clipboard, the biometric host, the in-app language and the build's facts. + - LocalDocumentPickers + - LocalAppWindow + - LocalExternalLinks + - LocalToaster + - LocalSensitiveClipboard + - LocalBiometricHost + - LocalScreenshotAllowance + - LocalAppLocales + - LocalAppInfo + - LocalNavigationRequests + # What a host with several windows adds (platform/NoteHostServices.kt): the PDF and print renderer, "open in + # new window", the window's claims on the notes it edits, and whether the windows share one lock. + - LocalNoteDocumentRenderer + - LocalNoteWindows + - LocalNoteEditClaims + - LocalSessionSharedAcrossWindows + # What a host offers or ships on top of the common set: whether it can restrain the keyboard's learning + # (the desktop cannot) and the libraries only it contains (platform/HostServices.kt). + - LocalKeyboardPrivacyOffered + - LocalExtraLicenses + # The window's pointer-first flag, key map and command router, provided once around the app + # (ui/theme/Adaptive.kt, presentation/designsystem/EnclyShortcuts.kt, presentation/navigation/AppCommandRouter.kt). + - LocalPointerFirst + - LocalEnclyShortcuts + - LocalAppCommands CompositionLocalNaming: active: true ContentEmitterReturningValues: diff --git a/core/common/.gitignore b/core/common/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/core/common/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/common/README.md b/core/common/README.md new file mode 100644 index 00000000..380042d6 --- /dev/null +++ b/core/common/README.md @@ -0,0 +1,19 @@ +# :core:common + +The bottom layer of the module graph. It depends on nothing of Encly and runs on every target. + +- `UiText`, `AppStrings`: text kept as a resource id and resolved by the UI (`UiText`) or stored in the in-app + language (`AppStrings`). +- `LoadState`, `suspendRunCatching`: the loading / ready / failed state of a screen. +- `KeyValueStore`, `MainThreadTimer`: what the platform provides for small non-secret flags and for the lock timers. +- `SensitiveDataCleaner`: wipes key material in place. +- `AppLogger`: the failure-only logger (its output is disabled on purpose; nothing sensitive reaches a log). + +| Target | Checked in | +|---|---| +| Android | `androidHostTest` (JVM) | +| Desktop JVM | `desktopTest` (also `CommonBoundaryTest`: no `android.`, `java.` or `R` in `commonMain`) | +| iOS (`iosArm64`, `iosSimulatorArm64`) | compiled in CI on macOS | + +The Android implementations of the interfaces (`SharedPreferencesKeyValueStore`, `HandlerMainThreadTimer`, +`AndroidAppStrings`, `asString`) are in `:app`. Line coverage is gated at 85 %. diff --git a/core/common/api/android/common.api b/core/common/api/android/common.api new file mode 100644 index 00000000..6787fec1 --- /dev/null +++ b/core/common/api/android/common.api @@ -0,0 +1,127 @@ +public final class com/pasich/encly/core/AppLogger { + public static final field INSTANCE Lcom/pasich/encly/core/AppLogger; + public final fun e (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun e$default (Lcom/pasich/encly/core/AppLogger;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V + public final fun w (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun w$default (Lcom/pasich/encly/core/AppLogger;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V +} + +public abstract interface class com/pasich/encly/core/common/AppStrings { + public abstract fun get (Lorg/jetbrains/compose/resources/StringResource;[Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/core/common/ComposeAppStrings : com/pasich/encly/core/common/AppStrings { + public fun ()V + public fun get (Lorg/jetbrains/compose/resources/StringResource;[Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/core/common/LoadError { + public fun (Lcom/pasich/encly/core/common/UiText;Lcom/pasich/encly/core/common/UiText;)V + public final fun component1 ()Lcom/pasich/encly/core/common/UiText; + public final fun component2 ()Lcom/pasich/encly/core/common/UiText; + public final fun copy (Lcom/pasich/encly/core/common/UiText;Lcom/pasich/encly/core/common/UiText;)Lcom/pasich/encly/core/common/LoadError; + public static synthetic fun copy$default (Lcom/pasich/encly/core/common/LoadError;Lcom/pasich/encly/core/common/UiText;Lcom/pasich/encly/core/common/UiText;ILjava/lang/Object;)Lcom/pasich/encly/core/common/LoadError; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Lcom/pasich/encly/core/common/UiText; + public final fun getTitle ()Lcom/pasich/encly/core/common/UiText; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/pasich/encly/core/common/LoadState { +} + +public final class com/pasich/encly/core/common/LoadState$Failed : com/pasich/encly/core/common/LoadState { + public fun (Lcom/pasich/encly/core/common/LoadError;)V + public final fun component1 ()Lcom/pasich/encly/core/common/LoadError; + public final fun copy (Lcom/pasich/encly/core/common/LoadError;)Lcom/pasich/encly/core/common/LoadState$Failed; + public static synthetic fun copy$default (Lcom/pasich/encly/core/common/LoadState$Failed;Lcom/pasich/encly/core/common/LoadError;ILjava/lang/Object;)Lcom/pasich/encly/core/common/LoadState$Failed; + public fun equals (Ljava/lang/Object;)Z + public final fun getError ()Lcom/pasich/encly/core/common/LoadError; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/common/LoadState$Loading : com/pasich/encly/core/common/LoadState { + public static final field INSTANCE Lcom/pasich/encly/core/common/LoadState$Loading; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/common/LoadState$Ready : com/pasich/encly/core/common/LoadState { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lcom/pasich/encly/core/common/LoadState$Ready; + public static synthetic fun copy$default (Lcom/pasich/encly/core/common/LoadState$Ready;Ljava/lang/Object;ILjava/lang/Object;)Lcom/pasich/encly/core/common/LoadState$Ready; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/common/LoadStateKt { + public static final fun asLoadState (Lkotlinx/coroutines/flow/Flow;Lcom/pasich/encly/core/common/LoadError;)Lkotlinx/coroutines/flow/Flow; + public static final fun reloadWith (Lcom/pasich/encly/core/common/LoadState;Lcom/pasich/encly/core/common/LoadState;)Lcom/pasich/encly/core/common/LoadState; + public static final fun valueOrNull (Lcom/pasich/encly/core/common/LoadState;)Ljava/lang/Object; +} + +public final class com/pasich/encly/core/common/SuspendRunCatchingKt { + public static final fun suspendRunCatching (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; +} + +public abstract interface class com/pasich/encly/core/common/UiText { + public static final field Companion Lcom/pasich/encly/core/common/UiText$Companion; +} + +public final class com/pasich/encly/core/common/UiText$Companion { + public final fun of (Lorg/jetbrains/compose/resources/StringResource;)Lcom/pasich/encly/core/common/UiText; +} + +public final class com/pasich/encly/core/common/UiText$Resource : com/pasich/encly/core/common/UiText { + public fun (Lorg/jetbrains/compose/resources/StringResource;)V + public final fun component1 ()Lorg/jetbrains/compose/resources/StringResource; + public final fun copy (Lorg/jetbrains/compose/resources/StringResource;)Lcom/pasich/encly/core/common/UiText$Resource; + public static synthetic fun copy$default (Lcom/pasich/encly/core/common/UiText$Resource;Lorg/jetbrains/compose/resources/StringResource;ILjava/lang/Object;)Lcom/pasich/encly/core/common/UiText$Resource; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Lorg/jetbrains/compose/resources/StringResource; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/common/WallClockKt { + public static final fun currentTimeMillis ()J +} + +public abstract interface class com/pasich/encly/core/platform/KeyValueStore { + public abstract fun commit (Lkotlin/jvm/functions/Function1;)Z + public abstract fun contains (Ljava/lang/String;)Z + public abstract fun edit (Lkotlin/jvm/functions/Function1;)V + public abstract fun getBoolean (Ljava/lang/String;Z)Z + public abstract fun getLong (Ljava/lang/String;J)J + public abstract fun getString (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; +} + +public abstract interface class com/pasich/encly/core/platform/KeyValueStore$Editor { + public abstract fun clear ()V + public abstract fun putBoolean (Ljava/lang/String;Z)V + public abstract fun putLong (Ljava/lang/String;J)V + public abstract fun putString (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun remove (Ljava/lang/String;)V +} + +public abstract interface class com/pasich/encly/core/platform/MainThreadTimer { + public abstract fun schedule (JLkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/platform/MainThreadTimer$None : com/pasich/encly/core/platform/MainThreadTimer { + public static final field INSTANCE Lcom/pasich/encly/core/platform/MainThreadTimer$None; + public fun schedule (JLkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/SensitiveDataCleaner { + public static final field INSTANCE Lcom/pasich/encly/core/security/SensitiveDataCleaner; + public final fun clear ([B)V + public final fun clear ([C)V +} + diff --git a/core/common/api/common.klib.api b/core/common/api/common.klib.api new file mode 100644 index 00000000..5801849a --- /dev/null +++ b/core/common/api/common.klib.api @@ -0,0 +1,127 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract fun interface com.pasich.encly.core.platform/MainThreadTimer { // com.pasich.encly.core.platform/MainThreadTimer|null[0] + abstract fun schedule(kotlin/Long, kotlin/Function0): kotlin/Function0 // com.pasich.encly.core.platform/MainThreadTimer.schedule|schedule(kotlin.Long;kotlin.Function0){}[0] + + final object None : com.pasich.encly.core.platform/MainThreadTimer { // com.pasich.encly.core.platform/MainThreadTimer.None|null[0] + final fun schedule(kotlin/Long, kotlin/Function0): kotlin/Function0 // com.pasich.encly.core.platform/MainThreadTimer.None.schedule|schedule(kotlin.Long;kotlin.Function0){}[0] + } +} + +abstract interface com.pasich.encly.core.common/AppStrings { // com.pasich.encly.core.common/AppStrings|null[0] + abstract suspend fun get(org.jetbrains.compose.resources/StringResource, kotlin/Array...): kotlin/String // com.pasich.encly.core.common/AppStrings.get|get(org.jetbrains.compose.resources.StringResource;kotlin.Array...){}[0] +} + +abstract interface com.pasich.encly.core.platform/KeyValueStore { // com.pasich.encly.core.platform/KeyValueStore|null[0] + abstract fun commit(kotlin/Function1): kotlin/Boolean // com.pasich.encly.core.platform/KeyValueStore.commit|commit(kotlin.Function1){}[0] + abstract fun contains(kotlin/String): kotlin/Boolean // com.pasich.encly.core.platform/KeyValueStore.contains|contains(kotlin.String){}[0] + abstract fun edit(kotlin/Function1) // com.pasich.encly.core.platform/KeyValueStore.edit|edit(kotlin.Function1){}[0] + abstract fun getBoolean(kotlin/String, kotlin/Boolean): kotlin/Boolean // com.pasich.encly.core.platform/KeyValueStore.getBoolean|getBoolean(kotlin.String;kotlin.Boolean){}[0] + abstract fun getLong(kotlin/String, kotlin/Long): kotlin/Long // com.pasich.encly.core.platform/KeyValueStore.getLong|getLong(kotlin.String;kotlin.Long){}[0] + abstract fun getString(kotlin/String, kotlin/String?): kotlin/String? // com.pasich.encly.core.platform/KeyValueStore.getString|getString(kotlin.String;kotlin.String?){}[0] + + abstract interface Editor { // com.pasich.encly.core.platform/KeyValueStore.Editor|null[0] + abstract fun clear() // com.pasich.encly.core.platform/KeyValueStore.Editor.clear|clear(){}[0] + abstract fun putBoolean(kotlin/String, kotlin/Boolean) // com.pasich.encly.core.platform/KeyValueStore.Editor.putBoolean|putBoolean(kotlin.String;kotlin.Boolean){}[0] + abstract fun putLong(kotlin/String, kotlin/Long) // com.pasich.encly.core.platform/KeyValueStore.Editor.putLong|putLong(kotlin.String;kotlin.Long){}[0] + abstract fun putString(kotlin/String, kotlin/String) // com.pasich.encly.core.platform/KeyValueStore.Editor.putString|putString(kotlin.String;kotlin.String){}[0] + abstract fun remove(kotlin/String) // com.pasich.encly.core.platform/KeyValueStore.Editor.remove|remove(kotlin.String){}[0] + } +} + +sealed interface <#A: out kotlin/Any?> com.pasich.encly.core.common/LoadState { // com.pasich.encly.core.common/LoadState|null[0] + final class <#A1: kotlin/Any?> Ready : com.pasich.encly.core.common/LoadState<#A1> { // com.pasich.encly.core.common/LoadState.Ready|null[0] + constructor (#A1) // com.pasich.encly.core.common/LoadState.Ready.|(1:0){}[0] + + final val value // com.pasich.encly.core.common/LoadState.Ready.value|{}value[0] + final fun (): #A1 // com.pasich.encly.core.common/LoadState.Ready.value.|(){}[0] + + final fun component1(): #A1 // com.pasich.encly.core.common/LoadState.Ready.component1|component1(){}[0] + final fun copy(#A1 = ...): com.pasich.encly.core.common/LoadState.Ready<#A1> // com.pasich.encly.core.common/LoadState.Ready.copy|copy(1:0){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.common/LoadState.Ready.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.common/LoadState.Ready.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.common/LoadState.Ready.toString|toString(){}[0] + } + + final class Failed : com.pasich.encly.core.common/LoadState { // com.pasich.encly.core.common/LoadState.Failed|null[0] + constructor (com.pasich.encly.core.common/LoadError) // com.pasich.encly.core.common/LoadState.Failed.|(com.pasich.encly.core.common.LoadError){}[0] + + final val error // com.pasich.encly.core.common/LoadState.Failed.error|{}error[0] + final fun (): com.pasich.encly.core.common/LoadError // com.pasich.encly.core.common/LoadState.Failed.error.|(){}[0] + + final fun component1(): com.pasich.encly.core.common/LoadError // com.pasich.encly.core.common/LoadState.Failed.component1|component1(){}[0] + final fun copy(com.pasich.encly.core.common/LoadError = ...): com.pasich.encly.core.common/LoadState.Failed // com.pasich.encly.core.common/LoadState.Failed.copy|copy(com.pasich.encly.core.common.LoadError){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.common/LoadState.Failed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.common/LoadState.Failed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.common/LoadState.Failed.toString|toString(){}[0] + } + + final object Loading : com.pasich.encly.core.common/LoadState { // com.pasich.encly.core.common/LoadState.Loading|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.common/LoadState.Loading.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.common/LoadState.Loading.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.common/LoadState.Loading.toString|toString(){}[0] + } +} + +sealed interface com.pasich.encly.core.common/UiText { // com.pasich.encly.core.common/UiText|null[0] + final class Resource : com.pasich.encly.core.common/UiText { // com.pasich.encly.core.common/UiText.Resource|null[0] + constructor (org.jetbrains.compose.resources/StringResource) // com.pasich.encly.core.common/UiText.Resource.|(org.jetbrains.compose.resources.StringResource){}[0] + + final val id // com.pasich.encly.core.common/UiText.Resource.id|{}id[0] + final fun (): org.jetbrains.compose.resources/StringResource // com.pasich.encly.core.common/UiText.Resource.id.|(){}[0] + + final fun component1(): org.jetbrains.compose.resources/StringResource // com.pasich.encly.core.common/UiText.Resource.component1|component1(){}[0] + final fun copy(org.jetbrains.compose.resources/StringResource = ...): com.pasich.encly.core.common/UiText.Resource // com.pasich.encly.core.common/UiText.Resource.copy|copy(org.jetbrains.compose.resources.StringResource){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.common/UiText.Resource.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.common/UiText.Resource.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.common/UiText.Resource.toString|toString(){}[0] + } + + final object Companion { // com.pasich.encly.core.common/UiText.Companion|null[0] + final fun of(org.jetbrains.compose.resources/StringResource): com.pasich.encly.core.common/UiText // com.pasich.encly.core.common/UiText.Companion.of|of(org.jetbrains.compose.resources.StringResource){}[0] + } +} + +final class com.pasich.encly.core.common/ComposeAppStrings : com.pasich.encly.core.common/AppStrings { // com.pasich.encly.core.common/ComposeAppStrings|null[0] + constructor () // com.pasich.encly.core.common/ComposeAppStrings.|(){}[0] + + final suspend fun get(org.jetbrains.compose.resources/StringResource, kotlin/Array...): kotlin/String // com.pasich.encly.core.common/ComposeAppStrings.get|get(org.jetbrains.compose.resources.StringResource;kotlin.Array...){}[0] +} + +final class com.pasich.encly.core.common/LoadError { // com.pasich.encly.core.common/LoadError|null[0] + constructor (com.pasich.encly.core.common/UiText, com.pasich.encly.core.common/UiText) // com.pasich.encly.core.common/LoadError.|(com.pasich.encly.core.common.UiText;com.pasich.encly.core.common.UiText){}[0] + + final val message // com.pasich.encly.core.common/LoadError.message|{}message[0] + final fun (): com.pasich.encly.core.common/UiText // com.pasich.encly.core.common/LoadError.message.|(){}[0] + final val title // com.pasich.encly.core.common/LoadError.title|{}title[0] + final fun (): com.pasich.encly.core.common/UiText // com.pasich.encly.core.common/LoadError.title.|(){}[0] + + final fun component1(): com.pasich.encly.core.common/UiText // com.pasich.encly.core.common/LoadError.component1|component1(){}[0] + final fun component2(): com.pasich.encly.core.common/UiText // com.pasich.encly.core.common/LoadError.component2|component2(){}[0] + final fun copy(com.pasich.encly.core.common/UiText = ..., com.pasich.encly.core.common/UiText = ...): com.pasich.encly.core.common/LoadError // com.pasich.encly.core.common/LoadError.copy|copy(com.pasich.encly.core.common.UiText;com.pasich.encly.core.common.UiText){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.common/LoadError.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.common/LoadError.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.common/LoadError.toString|toString(){}[0] +} + +final object com.pasich.encly.core.security/SensitiveDataCleaner { // com.pasich.encly.core.security/SensitiveDataCleaner|null[0] + final fun clear(kotlin/ByteArray) // com.pasich.encly.core.security/SensitiveDataCleaner.clear|clear(kotlin.ByteArray){}[0] + final fun clear(kotlin/CharArray) // com.pasich.encly.core.security/SensitiveDataCleaner.clear|clear(kotlin.CharArray){}[0] +} + +final object com.pasich.encly.core/AppLogger { // com.pasich.encly.core/AppLogger|null[0] + final fun e(kotlin/String, kotlin/String, kotlin/Throwable? = ...) // com.pasich.encly.core/AppLogger.e|e(kotlin.String;kotlin.String;kotlin.Throwable?){}[0] + final fun w(kotlin/String, kotlin/String, kotlin/Throwable? = ...) // com.pasich.encly.core/AppLogger.w|w(kotlin.String;kotlin.String;kotlin.Throwable?){}[0] +} + +final fun <#A: kotlin/Any?> (com.pasich.encly.core.common/LoadState<#A>).com.pasich.encly.core.common/reloadWith(com.pasich.encly.core.common/LoadState<#A>): com.pasich.encly.core.common/LoadState<#A> // com.pasich.encly.core.common/reloadWith|reloadWith@com.pasich.encly.core.common.LoadState<0:0>(com.pasich.encly.core.common.LoadState<0:0>){0§}[0] +final fun <#A: kotlin/Any?> (com.pasich.encly.core.common/LoadState<#A>).com.pasich.encly.core.common/valueOrNull(): #A? // com.pasich.encly.core.common/valueOrNull|valueOrNull@com.pasich.encly.core.common.LoadState<0:0>(){0§}[0] +final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/Flow<#A>).com.pasich.encly.core.common/asLoadState(com.pasich.encly.core.common/LoadError): kotlinx.coroutines.flow/Flow> // com.pasich.encly.core.common/asLoadState|asLoadState@kotlinx.coroutines.flow.Flow<0:0>(com.pasich.encly.core.common.LoadError){0§}[0] +final fun com.pasich.encly.core.common/currentTimeMillis(): kotlin/Long // com.pasich.encly.core.common/currentTimeMillis|currentTimeMillis(){}[0] +final inline fun <#A: kotlin/Any?> com.pasich.encly.core.common/suspendRunCatching(kotlin/Function0<#A>): kotlin/Result<#A> // com.pasich.encly.core.common/suspendRunCatching|suspendRunCatching(kotlin.Function0<0:0>){0§}[0] diff --git a/core/common/api/desktop/common.api b/core/common/api/desktop/common.api new file mode 100644 index 00000000..6787fec1 --- /dev/null +++ b/core/common/api/desktop/common.api @@ -0,0 +1,127 @@ +public final class com/pasich/encly/core/AppLogger { + public static final field INSTANCE Lcom/pasich/encly/core/AppLogger; + public final fun e (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun e$default (Lcom/pasich/encly/core/AppLogger;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V + public final fun w (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun w$default (Lcom/pasich/encly/core/AppLogger;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V +} + +public abstract interface class com/pasich/encly/core/common/AppStrings { + public abstract fun get (Lorg/jetbrains/compose/resources/StringResource;[Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/core/common/ComposeAppStrings : com/pasich/encly/core/common/AppStrings { + public fun ()V + public fun get (Lorg/jetbrains/compose/resources/StringResource;[Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/core/common/LoadError { + public fun (Lcom/pasich/encly/core/common/UiText;Lcom/pasich/encly/core/common/UiText;)V + public final fun component1 ()Lcom/pasich/encly/core/common/UiText; + public final fun component2 ()Lcom/pasich/encly/core/common/UiText; + public final fun copy (Lcom/pasich/encly/core/common/UiText;Lcom/pasich/encly/core/common/UiText;)Lcom/pasich/encly/core/common/LoadError; + public static synthetic fun copy$default (Lcom/pasich/encly/core/common/LoadError;Lcom/pasich/encly/core/common/UiText;Lcom/pasich/encly/core/common/UiText;ILjava/lang/Object;)Lcom/pasich/encly/core/common/LoadError; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Lcom/pasich/encly/core/common/UiText; + public final fun getTitle ()Lcom/pasich/encly/core/common/UiText; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/pasich/encly/core/common/LoadState { +} + +public final class com/pasich/encly/core/common/LoadState$Failed : com/pasich/encly/core/common/LoadState { + public fun (Lcom/pasich/encly/core/common/LoadError;)V + public final fun component1 ()Lcom/pasich/encly/core/common/LoadError; + public final fun copy (Lcom/pasich/encly/core/common/LoadError;)Lcom/pasich/encly/core/common/LoadState$Failed; + public static synthetic fun copy$default (Lcom/pasich/encly/core/common/LoadState$Failed;Lcom/pasich/encly/core/common/LoadError;ILjava/lang/Object;)Lcom/pasich/encly/core/common/LoadState$Failed; + public fun equals (Ljava/lang/Object;)Z + public final fun getError ()Lcom/pasich/encly/core/common/LoadError; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/common/LoadState$Loading : com/pasich/encly/core/common/LoadState { + public static final field INSTANCE Lcom/pasich/encly/core/common/LoadState$Loading; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/common/LoadState$Ready : com/pasich/encly/core/common/LoadState { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lcom/pasich/encly/core/common/LoadState$Ready; + public static synthetic fun copy$default (Lcom/pasich/encly/core/common/LoadState$Ready;Ljava/lang/Object;ILjava/lang/Object;)Lcom/pasich/encly/core/common/LoadState$Ready; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/common/LoadStateKt { + public static final fun asLoadState (Lkotlinx/coroutines/flow/Flow;Lcom/pasich/encly/core/common/LoadError;)Lkotlinx/coroutines/flow/Flow; + public static final fun reloadWith (Lcom/pasich/encly/core/common/LoadState;Lcom/pasich/encly/core/common/LoadState;)Lcom/pasich/encly/core/common/LoadState; + public static final fun valueOrNull (Lcom/pasich/encly/core/common/LoadState;)Ljava/lang/Object; +} + +public final class com/pasich/encly/core/common/SuspendRunCatchingKt { + public static final fun suspendRunCatching (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; +} + +public abstract interface class com/pasich/encly/core/common/UiText { + public static final field Companion Lcom/pasich/encly/core/common/UiText$Companion; +} + +public final class com/pasich/encly/core/common/UiText$Companion { + public final fun of (Lorg/jetbrains/compose/resources/StringResource;)Lcom/pasich/encly/core/common/UiText; +} + +public final class com/pasich/encly/core/common/UiText$Resource : com/pasich/encly/core/common/UiText { + public fun (Lorg/jetbrains/compose/resources/StringResource;)V + public final fun component1 ()Lorg/jetbrains/compose/resources/StringResource; + public final fun copy (Lorg/jetbrains/compose/resources/StringResource;)Lcom/pasich/encly/core/common/UiText$Resource; + public static synthetic fun copy$default (Lcom/pasich/encly/core/common/UiText$Resource;Lorg/jetbrains/compose/resources/StringResource;ILjava/lang/Object;)Lcom/pasich/encly/core/common/UiText$Resource; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Lorg/jetbrains/compose/resources/StringResource; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/common/WallClockKt { + public static final fun currentTimeMillis ()J +} + +public abstract interface class com/pasich/encly/core/platform/KeyValueStore { + public abstract fun commit (Lkotlin/jvm/functions/Function1;)Z + public abstract fun contains (Ljava/lang/String;)Z + public abstract fun edit (Lkotlin/jvm/functions/Function1;)V + public abstract fun getBoolean (Ljava/lang/String;Z)Z + public abstract fun getLong (Ljava/lang/String;J)J + public abstract fun getString (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; +} + +public abstract interface class com/pasich/encly/core/platform/KeyValueStore$Editor { + public abstract fun clear ()V + public abstract fun putBoolean (Ljava/lang/String;Z)V + public abstract fun putLong (Ljava/lang/String;J)V + public abstract fun putString (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun remove (Ljava/lang/String;)V +} + +public abstract interface class com/pasich/encly/core/platform/MainThreadTimer { + public abstract fun schedule (JLkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/platform/MainThreadTimer$None : com/pasich/encly/core/platform/MainThreadTimer { + public static final field INSTANCE Lcom/pasich/encly/core/platform/MainThreadTimer$None; + public fun schedule (JLkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/SensitiveDataCleaner { + public static final field INSTANCE Lcom/pasich/encly/core/security/SensitiveDataCleaner; + public final fun clear ([B)V + public final fun clear ([C)V +} + diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 00000000..232febee --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,50 @@ +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit + +// The smallest shared layer: text that is stored as a resource id (UiText, AppStrings), the loading state +// of a screen, the flags store and timer interfaces that platform code implements, the sensitive-data +// wipe and the failure-only logger. It depends on nothing of Encly; the Android implementations of its +// interfaces live in :app. +plugins { + id("encly.kmp-library") + id("encly.kmp-ios") + id("encly.quality") +} + +// Minimum line coverage (%) of the whole module, enforced by koverVerify. +val commonMinCoverage = 85 + +kotlin { + android { + namespace = "com.pasich.encly.core.common" + } + + sourceSets { + commonMain.dependencies { + // LoadState is built on Flow; the other interfaces name no library. + api(libs.kotlinx.coroutines.core) + // UiText and AppStrings name StringResource; the strings themselves are in :core:resources. + api(libs.compose.components.resources) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +kover { + reports { + total { + xml { onCheck.set(false) } + html { onCheck.set(false) } + verify { + rule("Common line coverage") { + bound { + minValue = commonMinCoverage + coverageUnits = CoverageUnit.LINE + } + } + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/core/AppLogger.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/AppLogger.kt similarity index 77% rename from app/src/main/java/com/pasich/encly/core/AppLogger.kt rename to core/common/src/commonMain/kotlin/com/pasich/encly/core/AppLogger.kt index 44ce94d7..af2fa683 100644 --- a/app/src/main/java/com/pasich/encly/core/AppLogger.kt +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/AppLogger.kt @@ -13,7 +13,7 @@ package com.pasich.encly.core * Re-introduce output only through a structured, explicitly non-sensitive event schema. */ @Suppress("UNUSED_PARAMETER") -object AppLogger { - fun w(tag: String, event: String, throwable: Throwable? = null) = Unit - fun e(tag: String, event: String, throwable: Throwable? = null) = Unit +public object AppLogger { + public fun w(tag: String, event: String, throwable: Throwable? = null): Unit = Unit + public fun e(tag: String, event: String, throwable: Throwable? = null): Unit = Unit } diff --git a/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/AppStrings.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/AppStrings.kt new file mode 100644 index 00000000..346d8d49 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/AppStrings.kt @@ -0,0 +1,12 @@ +package com.pasich.encly.core.common + +import org.jetbrains.compose.resources.StringResource + +/** + * Resolves text that is stored rather than shown (for example a duplicated note's title), in + * the in-app language. Text that is only shown is [UiText]. The implementation is + * [ComposeAppStrings]. + */ +public interface AppStrings { + public suspend fun get(id: StringResource, vararg args: Any): String +} diff --git a/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/ComposeAppStrings.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/ComposeAppStrings.kt new file mode 100644 index 00000000..6dabbbd1 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/ComposeAppStrings.kt @@ -0,0 +1,12 @@ +package com.pasich.encly.core.common + +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString + +/** + * [AppStrings] on the Compose resources, which read the platform's current locale. On Android the app + * keeps that locale on the in-app language (AppLocales), so this follows the language picked in the app. + */ +public class ComposeAppStrings : AppStrings { + override suspend fun get(id: StringResource, vararg args: Any): String = getString(id, *args) +} diff --git a/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/LoadState.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/LoadState.kt new file mode 100644 index 00000000..ae8b3f87 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/LoadState.kt @@ -0,0 +1,50 @@ +package com.pasich.encly.core.common + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +/** + * What a list screen shows while its data is read. [Failed] is a state of its own: a screen + * renders it as an error, never as the "nothing here" empty state, so a read failure cannot + * look like an empty (or wiped) vault. + */ +public sealed interface LoadState { + public data object Loading : LoadState + public data class Ready(val value: T) : LoadState + public data class Failed(val error: LoadError) : LoadState +} + +/** A read failure as the user sees it. Never carries `Throwable.message`. */ +public data class LoadError(val title: UiText, val message: UiText) + +/** The loaded value, or null while loading or after a failure. */ +public fun LoadState.valueOrNull(): T? = (this as? LoadState.Ready)?.value + +/** + * The state after [next] arrives, except that a reload keeps showing the data already loaded + * until the new data (or a failure) replaces it, instead of blinking back to a placeholder. + */ +public fun LoadState.reloadWith(next: LoadState): LoadState = if (next is LoadState.Loading && + this is LoadState.Ready +) { + this +} else { + next +} + +/** + * Wraps a data flow for a screen: [LoadState.Loading] first, then every value as + * [LoadState.Ready], and an upstream failure as [LoadState.Failed] with [error]. + * + * `catch` only sees upstream exceptions, so a collector's own exception is never turned into + * an error state, and cancellation always propagates. + */ +public fun Flow.asLoadState(error: LoadError): Flow> = map> { LoadState.Ready(it) } + .onStart { emit(LoadState.Loading) } + .catch { cause -> + if (cause is CancellationException) throw cause + emit(LoadState.Failed(error)) + } diff --git a/app/src/main/java/com/pasich/encly/core/common/SuspendRunCatching.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/SuspendRunCatching.kt similarity index 87% rename from app/src/main/java/com/pasich/encly/core/common/SuspendRunCatching.kt rename to core/common/src/commonMain/kotlin/com/pasich/encly/core/common/SuspendRunCatching.kt index 5ccd5b1f..ad0edc6c 100644 --- a/app/src/main/java/com/pasich/encly/core/common/SuspendRunCatching.kt +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/SuspendRunCatching.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.CancellationException * [CancellationException], which is rethrown so a cancelled coroutine stays cancelled. */ @Suppress("TooGenericExceptionCaught") // The single place storage failures become a Result. -inline fun suspendRunCatching(block: () -> T): Result = try { +public inline fun suspendRunCatching(block: () -> T): Result = try { Result.success(block()) } catch (cancellation: CancellationException) { throw cancellation diff --git a/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/UiText.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/UiText.kt new file mode 100644 index 00000000..2b26f1b5 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/UiText.kt @@ -0,0 +1,20 @@ +package com.pasich.encly.core.common + +import org.jetbrains.compose.resources.StringResource + +/** + * User-visible text produced outside the UI layer (ViewModels, managers, validators). + * + * It carries a string resource instead of resolved text, so the UI resolves it when it is + * shown and the string follows the in-app language, including after a language switch recreates + * the activity while the ViewModel survives. The resolvers are `asString` and `resolve` in + * AndroidUiText.kt. + */ +public sealed interface UiText { + + public data class Resource(val id: StringResource) : UiText + + public companion object { + public fun of(id: StringResource): UiText = Resource(id) + } +} diff --git a/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/WallClock.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/WallClock.kt new file mode 100644 index 00000000..74725e73 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/common/WallClock.kt @@ -0,0 +1,6 @@ +package com.pasich.encly.core.common + +import kotlin.time.Clock + +/** Milliseconds since the Unix epoch, from the system clock (it can be set by the user; for dates, not intervals). */ +public fun currentTimeMillis(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/core/common/src/commonMain/kotlin/com/pasich/encly/core/platform/KeyValueStore.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/platform/KeyValueStore.kt new file mode 100644 index 00000000..5f2639bb --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/platform/KeyValueStore.kt @@ -0,0 +1,41 @@ +package com.pasich.encly.core.platform + +/** + * Small non-secret flags that survive restarts (onboarding finished, last export time, auto-lock + * delay, strict keyboard privacy). The platform decides where they live; on Android it is the + * `encly_flags_v3` SharedPreferences file ([SharedPreferencesKeyValueStore]). Nothing secret + * belongs here: it is plain storage. + * + * The two write forms keep the platform's two durability levels apart, because callers rely on + * them: [edit] returns at once and persists in the background, [commit] returns only after the + * change is persisted and says whether it was. + */ +public interface KeyValueStore { + public fun getString(key: String, default: String?): String? + + public fun getBoolean(key: String, default: Boolean): Boolean + + public fun getLong(key: String, default: Long): Long + + public fun contains(key: String): Boolean + + /** Applies [block]'s changes in one batch; persisted in the background. */ + public fun edit(block: Editor.() -> Unit) + + /** Applies [block]'s changes in one batch and waits until they are persisted; false if they were not. */ + public fun commit(block: Editor.() -> Unit): Boolean + + /** The changes of one [edit] or [commit]. */ + public interface Editor { + public fun putString(key: String, value: String) + + public fun putBoolean(key: String, value: Boolean) + + public fun putLong(key: String, value: Long) + + public fun remove(key: String) + + /** Removes every key. */ + public fun clear() + } +} diff --git a/core/common/src/commonMain/kotlin/com/pasich/encly/core/platform/MainThreadTimer.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/platform/MainThreadTimer.kt new file mode 100644 index 00000000..7ebbf643 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/platform/MainThreadTimer.kt @@ -0,0 +1,12 @@ +package com.pasich.encly.core.platform + +/** Runs an action on the main thread after a delay (the lock timers; the UI thread of the platform). */ +public fun interface MainThreadTimer { + /** Schedules [action] after [delayMs]; the returned function cancels it. */ + public fun schedule(delayMs: Long, action: () -> Unit): () -> Unit + + /** Never runs anything (unit tests that drive the callers by hand). */ + public object None : MainThreadTimer { + override fun schedule(delayMs: Long, action: () -> Unit): () -> Unit = {} + } +} diff --git a/core/common/src/commonMain/kotlin/com/pasich/encly/core/security/SensitiveDataCleaner.kt b/core/common/src/commonMain/kotlin/com/pasich/encly/core/security/SensitiveDataCleaner.kt new file mode 100644 index 00000000..09651be0 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/pasich/encly/core/security/SensitiveDataCleaner.kt @@ -0,0 +1,11 @@ +package com.pasich.encly.core.security + +public object SensitiveDataCleaner { + public fun clear(data: ByteArray) { + data.fill(0) + } + + public fun clear(data: CharArray) { + data.fill(0.toChar()) + } +} diff --git a/core/common/src/commonTest/kotlin/com/pasich/encly/core/AppLoggerTest.kt b/core/common/src/commonTest/kotlin/com/pasich/encly/core/AppLoggerTest.kt new file mode 100644 index 00000000..39037f28 --- /dev/null +++ b/core/common/src/commonTest/kotlin/com/pasich/encly/core/AppLoggerTest.kt @@ -0,0 +1,11 @@ +package com.pasich.encly.core + +import kotlin.test.Test + +class AppLoggerTest { + @Test + fun loggingIsDisabledSoNothingIsEverEmitted() { + AppLogger.w("tag", "event") + AppLogger.e("tag", "event", IllegalStateException("secret detail")) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/common/LoadStateTest.kt b/core/common/src/commonTest/kotlin/com/pasich/encly/core/common/LoadStateTest.kt similarity index 80% rename from app/src/test/java/com/pasich/encly/core/common/LoadStateTest.kt rename to core/common/src/commonTest/kotlin/com/pasich/encly/core/common/LoadStateTest.kt index 28a45ae0..3ab055b3 100644 --- a/app/src/test/java/com/pasich/encly/core/common/LoadStateTest.kt +++ b/core/common/src/commonTest/kotlin/com/pasich/encly/core/common/LoadStateTest.kt @@ -5,13 +5,12 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Test -import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame class LoadStateTest { - private val error = LoadError(UiText.of(TITLE_ID), UiText.of(MESSAGE_ID)) + private val error = LoadError(UiText.of(resource("title")), UiText.of(resource("message"))) @Test fun loadingComesFirstThenEveryValue() = runTest { @@ -22,7 +21,7 @@ class LoadStateTest { @Test fun anUpstreamFailureBecomesTheTypedErrorNotAnEmptyValue() = runTest { - val states = flow> { throw IOException("secret detail") }.asLoadState(error).toList() + val states = flow> { throw IllegalStateException("secret detail") }.asLoadState(error).toList() assertEquals(listOf(LoadState.Loading, LoadState.Failed(error)), states) } @@ -65,11 +64,6 @@ class LoadStateTest { val thrown = runCatching { suspendRunCatching { throw cancellation } }.exceptionOrNull() assertSame(cancellation, thrown) - assertEquals(true, suspendRunCatching { throw IOException() }.isFailure) - } - - private companion object { - const val TITLE_ID = 1 - const val MESSAGE_ID = 2 + assertEquals(true, suspendRunCatching { throw IllegalStateException() }.isFailure) } } diff --git a/core/common/src/commonTest/kotlin/com/pasich/encly/core/common/UiTextTest.kt b/core/common/src/commonTest/kotlin/com/pasich/encly/core/common/UiTextTest.kt new file mode 100644 index 00000000..cef74fed --- /dev/null +++ b/core/common/src/commonTest/kotlin/com/pasich/encly/core/common/UiTextTest.kt @@ -0,0 +1,19 @@ +package com.pasich.encly.core.common + +import org.jetbrains.compose.resources.InternalResourceApi +import org.jetbrains.compose.resources.StringResource +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(InternalResourceApi::class) +class UiTextTest { + @Test + fun aResourceTextCarriesItsResourceAndIsComparedByIt() { + assertEquals(UiText.Resource(resource("a")), UiText.of(resource("a"))) + assertEquals("a", UiText.Resource(resource("a")).id.key) + } +} + +/** A string resource named [key], as the generated `Res.string` accessors build them. */ +@OptIn(InternalResourceApi::class) +internal fun resource(key: String) = StringResource("string:$key", key, emptySet()) diff --git a/core/common/src/commonTest/kotlin/com/pasich/encly/core/common/WallClockTest.kt b/core/common/src/commonTest/kotlin/com/pasich/encly/core/common/WallClockTest.kt new file mode 100644 index 00000000..0780a79f --- /dev/null +++ b/core/common/src/commonTest/kotlin/com/pasich/encly/core/common/WallClockTest.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.core.common + +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Clock + +class WallClockTest { + @Test + fun currentTimeMillisIsTheSystemClockInMilliseconds() { + val before = Clock.System.now().toEpochMilliseconds() + val now = currentTimeMillis() + val after = Clock.System.now().toEpochMilliseconds() + + assertTrue(now in before..after, "$before <= $now <= $after") + } +} diff --git a/core/common/src/commonTest/kotlin/com/pasich/encly/core/platform/MainThreadTimerTest.kt b/core/common/src/commonTest/kotlin/com/pasich/encly/core/platform/MainThreadTimerTest.kt new file mode 100644 index 00000000..ededcebe --- /dev/null +++ b/core/common/src/commonTest/kotlin/com/pasich/encly/core/platform/MainThreadTimerTest.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.core.platform + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MainThreadTimerTest { + @Test + fun theNullTimerRunsNothingAndItsCancelIsHarmless() { + var ran = 0 + + val cancel = MainThreadTimer.None.schedule(0L) { ran++ } + cancel() + + assertEquals(0, ran) + } +} diff --git a/core/common/src/commonTest/kotlin/com/pasich/encly/core/security/SensitiveDataCleanerTest.kt b/core/common/src/commonTest/kotlin/com/pasich/encly/core/security/SensitiveDataCleanerTest.kt new file mode 100644 index 00000000..e21e1545 --- /dev/null +++ b/core/common/src/commonTest/kotlin/com/pasich/encly/core/security/SensitiveDataCleanerTest.kt @@ -0,0 +1,24 @@ +package com.pasich.encly.core.security + +import kotlin.test.Test +import kotlin.test.assertContentEquals + +class SensitiveDataCleanerTest { + @Test + fun clearZeroesBytesInPlace() { + val secret = byteArrayOf(1, 2, 3) + + SensitiveDataCleaner.clear(secret) + + assertContentEquals(ByteArray(3), secret) + } + + @Test + fun clearZeroesCharactersInPlace() { + val secret = "123456".toCharArray() + + SensitiveDataCleaner.clear(secret) + + assertContentEquals(CharArray(6), secret) + } +} diff --git a/core/common/src/desktopTest/kotlin/com/pasich/encly/core/CommonBoundaryTest.kt b/core/common/src/desktopTest/kotlin/com/pasich/encly/core/CommonBoundaryTest.kt new file mode 100644 index 00000000..b14456ff --- /dev/null +++ b/core/common/src/desktopTest/kotlin/com/pasich/encly/core/CommonBoundaryTest.kt @@ -0,0 +1,116 @@ +package com.pasich.encly.core + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * `:core:common` is the bottom of the module graph and runs on every target, so its shared sources may + * depend on nothing of the app, of another module, of the JVM or of Android: no `android.`, `java.` or + * `javax.` import, no `R`, no `System`, `javaClass` or `SecurityException` in code. The libraries it may + * use are `kotlin` and `kotlinx`. The Android implementations of its interfaces + * are in `:app`. The module may not depend on another module either (see [theModuleDependsOnNoModule]). + */ +class CommonBoundaryTest { + // Tests run with the module directory as the working directory. + private val moduleRoot = listOf(File("."), File("core/common")).first { File(it, "build.gradle.kts").isFile } + private val sourceRoot = File(moduleRoot, "src/commonMain/kotlin") + + private val sources = sourceRoot.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + + private fun File.relative() = relativeTo(sourceRoot).invariantSeparatorsPath + + /** Source lines without comments (line, block and KDoc) and string literals' contents. */ + private fun File.codeLines(): List> { + // A block comment becomes as many line breaks as it had, so line numbers stay right. + val text = BLOCK_COMMENT.replace(readText()) { "\n".repeat(it.value.count { c -> c == '\n' }) } + return text.lines().mapIndexed { index, raw -> + (index + 1) to raw.replace(STRING, "\"\"").substringBefore("//") + } + } + + @Test + fun theSourcesWereFound() { + assertTrue(sources.size >= 8, "no sources under $sourceRoot") + } + + @Test + fun noImportReachesOutOfTheModuleOrOntoThePlatform() { + val offenders = sources.flatMap { file -> + file.codeLines().mapNotNull { (number, line) -> + val import = IMPORT.matchEntire(line.trim())?.groupValues?.get(1) ?: return@mapNotNull null + "${file.relative()}:$number import $import".takeUnless { ALLOWED_PREFIXES.any(import::startsWith) } + } + } + assertEquals(emptyList(), offenders, "the common layer may not import these") + } + + @Test + fun noCodeNamesThePlatformWithoutAnImport() { + val offenders = sources.flatMap { file -> + file.codeLines().filter { (_, line) -> !line.trim().startsWith("import ") } + .filter { (_, line) -> PLATFORM_USE.containsMatchIn(line) } + .map { (number, line) -> "${file.relative()}:$number ${line.trim()}" } + } + assertEquals(emptyList(), offenders, "the common layer may not use the JVM or Android directly") + } + + @Test + fun theModuleDependsOnNoModule() { + val build = File(moduleRoot, "build.gradle.kts").readText() + assertFalse(Regex("""project\(\s*":""").containsMatchIn(build), "the common layer depends on a module") + } + + @Test + fun theCheckSeesWhatItShouldReject() { + assertTrue(ALLOWED_PREFIXES.none { "android.content.Context".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "java.io.File".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "javax.crypto.Cipher".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "androidx.fragment.app.FragmentActivity".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "com.pasich.encly.R".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "androidx.compose.ui.res.stringResource".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "com.pasich.encly.core.security.Crypto".startsWith(it) }) + assertTrue(PLATFORM_USE.containsMatchIn("val x = java.util.UUID.randomUUID()")) + assertTrue(PLATFORM_USE.containsMatchIn("val t = System.currentTimeMillis()")) + assertTrue(PLATFORM_USE.containsMatchIn("} catch (_: SecurityException) {")) + assertFalse(PLATFORM_USE.containsMatchIn("val t = Clock.System.now()")) + assertTrue(PLATFORM_USE.containsMatchIn("e.javaClass.simpleName")) + assertFalse(PLATFORM_USE.containsMatchIn("val language = Locale.javascript")) + } + + private companion object { + val IMPORT = Regex("""import ([\w.]+)(?: as \w+)?""") + val STRING = Regex(""""(?:[^"\\]|\\.)*"""") + val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""") + + // The Kotlin libraries and the module's own packages (the sensitive-data wipe + // lives in core.security for the packages' sake, nothing else of core.security is allowed here). + val ALLOWED_PREFIXES = listOf( + "com.pasich.encly.core.common.", + "com.pasich.encly.core.platform.", + "com.pasich.encly.core.AppLogger", + "com.pasich.encly.core.security.SensitiveDataCleaner", + "kotlin.", + "kotlinx.", + // The type of a string resource, which UiText and AppStrings carry; the strings are not here. + "org.jetbrains.compose.resources.", + ) + + // Direct uses of the platform that need no import: java.*, android.*, System, javaClass, R. + val PLATFORM_USE = Regex( + listOf( + """\b(?:java|javax|android)\.\w""", + """(? (Ljava/lang/Throwable;)V +} + +public final class com/pasich/encly/core/security/bip39/Bip39 { + public final fun decode ([C)[B + public final fun encode ([B)[C + public final fun isValid ([C)Z +} + +public final class com/pasich/encly/core/security/bip39/Bip39Wordlist { + public static final field INSTANCE Lcom/pasich/encly/core/security/bip39/Bip39Wordlist; + public final fun getWords ()Ljava/util/List; + public final fun indexOf ([CII)I +} + diff --git a/core/crypto/api/crypto.klib.api b/core/crypto/api/crypto.klib.api new file mode 100644 index 00000000..aaae5b7a --- /dev/null +++ b/core/crypto/api/crypto.klib.api @@ -0,0 +1,56 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final class com.pasich.encly.core.crypto/CryptoException : kotlin/RuntimeException { // com.pasich.encly.core.crypto/CryptoException|null[0] + constructor (kotlin/Throwable) // com.pasich.encly.core.crypto/CryptoException.|(kotlin.Throwable){}[0] +} + +final class com.pasich.encly.core.security.bip39/Bip39 { // com.pasich.encly.core.security.bip39/Bip39|null[0] + constructor () // com.pasich.encly.core.security.bip39/Bip39.|(){}[0] + + final fun decode(kotlin/CharArray): kotlin/ByteArray? // com.pasich.encly.core.security.bip39/Bip39.decode|decode(kotlin.CharArray){}[0] + final fun encode(kotlin/ByteArray): kotlin/CharArray // com.pasich.encly.core.security.bip39/Bip39.encode|encode(kotlin.ByteArray){}[0] + final fun isValid(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security.bip39/Bip39.isValid|isValid(kotlin.CharArray){}[0] +} + +final object com.pasich.encly.core.crypto/Crypto { // com.pasich.encly.core.crypto/Crypto|null[0] + final const val KEY_BYTES // com.pasich.encly.core.crypto/Crypto.KEY_BYTES|{}KEY_BYTES[0] + final fun (): kotlin/Int // com.pasich.encly.core.crypto/Crypto.KEY_BYTES.|(){}[0] + final const val NONCE_BYTES // com.pasich.encly.core.crypto/Crypto.NONCE_BYTES|{}NONCE_BYTES[0] + final fun (): kotlin/Int // com.pasich.encly.core.crypto/Crypto.NONCE_BYTES.|(){}[0] + final const val SHA256_BYTES // com.pasich.encly.core.crypto/Crypto.SHA256_BYTES|{}SHA256_BYTES[0] + final fun (): kotlin/Int // com.pasich.encly.core.crypto/Crypto.SHA256_BYTES.|(){}[0] + final const val TAG_BYTES // com.pasich.encly.core.crypto/Crypto.TAG_BYTES|{}TAG_BYTES[0] + final fun (): kotlin/Int // com.pasich.encly.core.crypto/Crypto.TAG_BYTES.|(){}[0] + + final fun aesGcmOpen(kotlin/ByteArray, kotlin/ByteArray, kotlin/ByteArray, kotlin/ByteArray): kotlin/ByteArray? // com.pasich.encly.core.crypto/Crypto.aesGcmOpen|aesGcmOpen(kotlin.ByteArray;kotlin.ByteArray;kotlin.ByteArray;kotlin.ByteArray){}[0] + final fun aesGcmSeal(kotlin/ByteArray, kotlin/ByteArray, kotlin/ByteArray, kotlin/ByteArray): kotlin/ByteArray // com.pasich.encly.core.crypto/Crypto.aesGcmSeal|aesGcmSeal(kotlin.ByteArray;kotlin.ByteArray;kotlin.ByteArray;kotlin.ByteArray){}[0] + final fun constantTimeEquals(kotlin/ByteArray, kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.crypto/Crypto.constantTimeEquals|constantTimeEquals(kotlin.ByteArray;kotlin.ByteArray){}[0] + final fun hkdfSha256(kotlin/ByteArray, kotlin/ByteArray, kotlin/ByteArray, kotlin/Int): kotlin/ByteArray // com.pasich.encly.core.crypto/Crypto.hkdfSha256|hkdfSha256(kotlin.ByteArray;kotlin.ByteArray;kotlin.ByteArray;kotlin.Int){}[0] + final fun pbkdf2HmacSha256(kotlin/ByteArray, kotlin/ByteArray, kotlin/Int, kotlin/Int): kotlin/ByteArray // com.pasich.encly.core.crypto/Crypto.pbkdf2HmacSha256|pbkdf2HmacSha256(kotlin.ByteArray;kotlin.ByteArray;kotlin.Int;kotlin.Int){}[0] + final fun pbkdf2HmacSha256(kotlin/CharArray, kotlin/ByteArray, kotlin/Int, kotlin/Int): kotlin/ByteArray // com.pasich.encly.core.crypto/Crypto.pbkdf2HmacSha256|pbkdf2HmacSha256(kotlin.CharArray;kotlin.ByteArray;kotlin.Int;kotlin.Int){}[0] + final fun randomBytes(kotlin/Int): kotlin/ByteArray // com.pasich.encly.core.crypto/Crypto.randomBytes|randomBytes(kotlin.Int){}[0] + final fun randomLong(): kotlin/Long // com.pasich.encly.core.crypto/Crypto.randomLong|randomLong(){}[0] + final fun sha256(kotlin/ByteArray, kotlin/Int = ..., kotlin/Int = ...): kotlin/ByteArray // com.pasich.encly.core.crypto/Crypto.sha256|sha256(kotlin.ByteArray;kotlin.Int;kotlin.Int){}[0] +} + +final object com.pasich.encly.core.crypto/P256Points { // com.pasich.encly.core.crypto/P256Points|null[0] + final const val POINT_BYTES // com.pasich.encly.core.crypto/P256Points.POINT_BYTES|{}POINT_BYTES[0] + final fun (): kotlin/Int // com.pasich.encly.core.crypto/P256Points.POINT_BYTES.|(){}[0] + final const val X_BYTES // com.pasich.encly.core.crypto/P256Points.X_BYTES|{}X_BYTES[0] + final fun (): kotlin/Int // com.pasich.encly.core.crypto/P256Points.X_BYTES.|(){}[0] + + final fun fromX(kotlin/ByteArray): kotlin/ByteArray? // com.pasich.encly.core.crypto/P256Points.fromX|fromX(kotlin.ByteArray){}[0] +} + +final object com.pasich.encly.core.security.bip39/Bip39Wordlist { // com.pasich.encly.core.security.bip39/Bip39Wordlist|null[0] + final val words // com.pasich.encly.core.security.bip39/Bip39Wordlist.words|{}words[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.security.bip39/Bip39Wordlist.words.|(){}[0] + + final fun indexOf(kotlin/CharArray, kotlin/Int, kotlin/Int): kotlin/Int // com.pasich.encly.core.security.bip39/Bip39Wordlist.indexOf|indexOf(kotlin.CharArray;kotlin.Int;kotlin.Int){}[0] +} diff --git a/core/crypto/api/desktop/crypto.api b/core/crypto/api/desktop/crypto.api new file mode 100644 index 00000000..3b604729 --- /dev/null +++ b/core/crypto/api/desktop/crypto.api @@ -0,0 +1,34 @@ +public final class com/pasich/encly/core/crypto/Crypto { + public static final field INSTANCE Lcom/pasich/encly/core/crypto/Crypto; + public static final field KEY_BYTES I + public static final field NONCE_BYTES I + public static final field SHA256_BYTES I + public static final field TAG_BYTES I + public final fun aesGcmOpen ([B[B[B[B)[B + public final fun aesGcmSeal ([B[B[B[B)[B + public final fun constantTimeEquals ([B[B)Z + public final fun hkdfSha256 ([B[B[BI)[B + public final fun pbkdf2HmacSha256 ([B[BII)[B + public final fun pbkdf2HmacSha256 ([C[BII)[B + public final fun randomBytes (I)[B + public final fun randomLong ()J + public final fun sha256 ([BII)[B + public static synthetic fun sha256$default (Lcom/pasich/encly/core/crypto/Crypto;[BIIILjava/lang/Object;)[B +} + +public final class com/pasich/encly/core/crypto/CryptoException : java/lang/RuntimeException { + public fun (Ljava/lang/Throwable;)V +} + +public final class com/pasich/encly/core/security/bip39/Bip39 { + public final fun decode ([C)[B + public final fun encode ([B)[C + public final fun isValid ([C)Z +} + +public final class com/pasich/encly/core/security/bip39/Bip39Wordlist { + public static final field INSTANCE Lcom/pasich/encly/core/security/bip39/Bip39Wordlist; + public final fun getWords ()Ljava/util/List; + public final fun indexOf ([CII)I +} + diff --git a/core/crypto/build.gradle.kts b/core/crypto/build.gradle.kts new file mode 100644 index 00000000..ffd5915f --- /dev/null +++ b/core/crypto/build.gradle.kts @@ -0,0 +1,87 @@ +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit +import org.gradle.internal.os.OperatingSystem +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +// The one door to cryptographic primitives (Crypto: AES-256-GCM, HKDF, PBKDF2, SHA-256, the CSPRNG) and +// BIP-39 mnemonics. The API is common code; the implementation is JCA on the JVM and Android +// (jvmAndAndroidMain) and Apple's own libraries on iOS (iosMain): CommonCrypto for SHA-256, HMAC and PBKDF2, +// SecRandomCopyBytes for randomness, and CryptoKit for AES-GCM, which CommonCrypto has no public API for. +// CryptoKit is Swift-only, so src/iosMain/swift/EnclyAesGcm.swift exports two C functions over it; the +// SwiftStaticLibrary tasks below compile that file for each SDK and cinterop links it into the klib +// (src/nativeInterop/cinterop/enclyaesgcm.def). Nothing is implemented by hand. +plugins { + id("encly.kmp-library") + id("encly.kmp-ios") + id("encly.quality") +} + +// Minimum line and branch coverage (%) of the whole module, enforced by koverVerify. +val cryptoMinCoverage = 100 + +kotlin { + android { + namespace = "com.pasich.encly.core.crypto" + } + + sourceSets { + commonMain.dependencies { + // SensitiveDataCleaner wipes the intermediate key material. + api(project(":core:common")) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} + +// The Swift bridge exists only where Xcode does; other hosts do not build the Apple targets (gradle.properties). +if (OperatingSystem.current().isMacOsX) { + val swiftTargets = mapOf( + "iosArm64" to ("iphoneos" to "arm64-apple-ios15.0"), + "iosSimulatorArm64" to ("iphonesimulator" to "arm64-apple-ios15.0-simulator"), + ) + val cinteropDir = layout.projectDirectory.dir("src/nativeInterop/cinterop") + kotlin.targets.withType().configureEach { + val (sdkName, tripleName) = swiftTargets[name] ?: return@configureEach + val swiftLibrary = tasks.register("compileSwift${name.replaceFirstChar(Char::uppercase)}") { + source.set(layout.projectDirectory.file("src/iosMain/swift/EnclyAesGcm.swift")) + sdk.set(sdkName) + triple.set(tripleName) + libraryName.set("enclyaesgcm") + outputDir.set(layout.buildDirectory.dir("swift/$name")) + } + compilations.getByName("main").cinterops.create("enclyaesgcm") { + definitionFile.set(cinteropDir.file("enclyaesgcm.def")) + includeDirs(cinteropDir) + extraOpts("-libraryPath", swiftLibrary.flatMap { it.outputDir }.get().asFile.path) + // The library is copied into the klib, so a change to it must run cinterop again. + tasks.named(interopProcessingTaskName) { + dependsOn(swiftLibrary) + inputs.dir(swiftLibrary.flatMap { it.outputDir }).withPropertyName("swiftLibrary") + } + } + } +} + +kover { + reports { + total { + xml { onCheck.set(false) } + html { onCheck.set(false) } + verify { + rule("Crypto line coverage") { + bound { + minValue = cryptoMinCoverage + coverageUnits = CoverageUnit.LINE + } + } + rule("Crypto branch coverage") { + bound { + minValue = cryptoMinCoverage + coverageUnits = CoverageUnit.BRANCH + } + } + } + } + } +} diff --git a/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/Crypto.kt b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/Crypto.kt new file mode 100644 index 00000000..db2bc450 --- /dev/null +++ b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/Crypto.kt @@ -0,0 +1,83 @@ +package com.pasich.encly.core.crypto + +/** + * The one door to cryptographic primitives: everything the app needs, nothing else. Callers + * have no `javax.crypto` / `java.security` in them; the platform implementation is `PlatformCrypto` + * (JCA on the JVM and Android), the only place to replace when another platform needs another one. + * + * Fixed here, for every caller: AES-256-GCM with a 12-byte nonce and a 16-byte tag, HKDF and + * PBKDF2 over HMAC-SHA-256, SHA-256. Randomness is only ever the platform CSPRNG. + * + * Keys and passwords are handed in as arrays and never retained. The arrays that cross this API + * in either direction stay the caller's to wipe. + */ +public object Crypto { + public const val KEY_BYTES: Int = 32 + public const val NONCE_BYTES: Int = 12 + public const val TAG_BYTES: Int = 16 + public const val SHA256_BYTES: Int = 32 + + /** [size] bytes from the platform CSPRNG. */ + public fun randomBytes(size: Int): ByteArray = PlatformCrypto.randomBytes(size) + + /** A uniformly random `Long` from the platform CSPRNG. */ + public fun randomLong(): Long { + var value = 0L + for (byte in randomBytes(Long.SIZE_BYTES)) value = (value shl Byte.SIZE_BITS) or (byte.toLong() and BYTE_MASK) + return value + } + + /** SHA-256 of `data[startIndex until endIndex]`, without copying the range. */ + public fun sha256(data: ByteArray, startIndex: Int = 0, endIndex: Int = data.size): ByteArray = + PlatformCrypto.sha256(data, startIndex, endIndex) + + /** + * HKDF-SHA-256 (RFC 5869) of [ikm]. [salt] must not be empty (RFC 5869 would substitute zeros; + * every key in the hierarchy has a fixed, non-empty one) and [length] is at most one hash block. + */ + public fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + require(salt.isNotEmpty()) + require(length in 1..SHA256_BYTES) + return PlatformCrypto.hkdfSha256(ikm, salt, info, length) + } + + /** + * PBKDF2-HMAC-SHA-256 (RFC 8018) of [password]. The characters go to the KDF as they are and + * no `String` is made from them; the caller wipes [password]. + */ + public fun pbkdf2HmacSha256(password: CharArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + require(iterations > 0 && length > 0) + return PlatformCrypto.pbkdf2HmacSha256(password, salt, iterations, length) + } + + /** [pbkdf2HmacSha256] of [password] read as UTF-8 text (the characters made from it are wiped). */ + public fun pbkdf2HmacSha256(password: ByteArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + require(iterations > 0 && length > 0) + return PlatformCrypto.pbkdf2HmacSha256(password, salt, iterations, length) + } + + /** AES-256-GCM: `ciphertext ‖ tag` of [plaintext] under [key] and [nonce], with [aad] authenticated. */ + public fun aesGcmSeal(key: ByteArray, nonce: ByteArray, aad: ByteArray, plaintext: ByteArray): ByteArray { + requireKeyAndNonce(key, nonce) + return PlatformCrypto.aesGcmSeal(key, nonce, aad, plaintext) + } + + /** + * Opens `ciphertext ‖ tag` sealed by [aesGcmSeal]: the plaintext, which the caller wipes, or + * null when it does not authenticate (wrong key, nonce or [aad], changed or truncated data). + */ + public fun aesGcmOpen(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray? { + requireKeyAndNonce(key, nonce) + return PlatformCrypto.aesGcmOpen(key, nonce, aad, ciphertext) + } + + /** True when [a] and [b] are equal; the time taken depends on their lengths, not on where they differ. */ + public fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean = PlatformCrypto.constantTimeEquals(a, b) + + private fun requireKeyAndNonce(key: ByteArray, nonce: ByteArray) { + require(key.size == KEY_BYTES) + require(nonce.size == NONCE_BYTES) + } + + private const val BYTE_MASK = 0xFFL +} diff --git a/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/CryptoException.kt b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/CryptoException.kt new file mode 100644 index 00000000..c6f5c184 --- /dev/null +++ b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/CryptoException.kt @@ -0,0 +1,4 @@ +package com.pasich.encly.core.crypto + +/** The provider could not seal: callers fail closed (nothing is written) instead of crashing. */ +public class CryptoException(cause: Throwable) : RuntimeException(cause) diff --git a/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/PlatformCrypto.kt b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/PlatformCrypto.kt new file mode 100644 index 00000000..883626a8 --- /dev/null +++ b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/crypto/PlatformCrypto.kt @@ -0,0 +1,23 @@ +package com.pasich.encly.core.crypto + +/** + * What [Crypto] needs from the platform's crypto provider: the primitives themselves, without the + * argument checks [Crypto] makes first. The JVM and Android actual is `JcaCrypto`. + */ +internal expect object PlatformCrypto { + fun randomBytes(size: Int): ByteArray + + fun sha256(data: ByteArray, startIndex: Int, endIndex: Int): ByteArray + + fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray + + fun pbkdf2HmacSha256(password: CharArray, salt: ByteArray, iterations: Int, length: Int): ByteArray + + fun pbkdf2HmacSha256(password: ByteArray, salt: ByteArray, iterations: Int, length: Int): ByteArray + + fun aesGcmSeal(key: ByteArray, nonce: ByteArray, aad: ByteArray, plaintext: ByteArray): ByteArray + + fun aesGcmOpen(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray? + + fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean +} diff --git a/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Bip39.kt b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Bip39.kt new file mode 100644 index 00000000..1daa462b --- /dev/null +++ b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Bip39.kt @@ -0,0 +1,117 @@ +package com.pasich.encly.core.security.bip39 + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.security.SensitiveDataCleaner + +/** + * BIP-39 English mnemonics: entropy to words and the checksum check, nothing else. Encly never + * turns the words into a BIP-39 seed (no PBKDF2): its keys come from SHA-256 of the normalized + * words (see `Mnemonic`), so that step is deliberately absent. + * + * Plain common Kotlin plus a SHA-256 (the [Crypto] one by default). + * Phrases are char arrays and every intermediate array is wiped; arrays handed back belong to + * the caller. + */ +public class Bip39 internal constructor(private val sha256: Sha256) { + public constructor() : this(Sha256 { Crypto.sha256(it) }) + + /** + * The phrase for [entropy] (16, 20, 24, 28 or 32 bytes: 12 to 24 words), words joined by one + * space, lower case. + */ + public fun encode(entropy: ByteArray): CharArray { + require(entropy.size in ENTROPY_BYTES) { "BIP-39 entropy is 16 to 32 bytes in steps of 4" } + val hash = sha256.digest(entropy) + // Entropy followed by the first hash byte; only its top entropy/32 bits are the checksum. + val bits = entropy + hash[0] + val wordCount = (entropy.size * BITS_PER_BYTE + entropy.size / CHECKSUM_DIVISOR) / BITS_PER_WORD + return try { + val words = List(wordCount) { Bip39Wordlist.words[readWord(bits, it)] } + val phrase = CharArray(words.sumOf { it.length } + wordCount - 1) + var next = 0 + words.forEachIndexed { index, word -> + if (index > 0) phrase[next++] = ' ' + word.forEach { phrase[next++] = it } + } + phrase + } finally { + SensitiveDataCleaner.clear(bits) + SensitiveDataCleaner.clear(hash) + } + } + + /** + * The entropy behind [phrase] (lower case, words separated by exactly one space), or null when + * the word count is not 12/15/18/21/24, a word is not in the list, or the checksum is wrong. + */ + @Suppress("ReturnCount") // Each rejection ends the decode at once; nothing is left to clean up but `bits`. + public fun decode(phrase: CharArray): ByteArray? { + val wordCount = phrase.count { it == ' ' } + 1 + if (wordCount !in WORD_COUNTS) return null + val entropyBytes = wordCount / WORDS_PER_GROUP * BYTES_PER_GROUP + val bits = ByteArray(entropyBytes + 1) + var start = 0 + for (word in 0 until wordCount) { + var end = start + while (end < phrase.size && phrase[end] != ' ') end++ + val index = Bip39Wordlist.indexOf(phrase, start, end) + if (index < 0) { + SensitiveDataCleaner.clear(bits) + return null + } + writeWord(bits, word, index) + start = end + 1 + } + val entropy = bits.copyOf(entropyBytes) + val hash = sha256.digest(entropy) + // One compare after the work, with no early exit on which bit differs. + val checksumMask = (BYTE_MASK shl (BITS_PER_BYTE - entropyBytes / CHECKSUM_DIVISOR)) and BYTE_MASK + val difference = (bits[entropyBytes].toInt() xor hash[0].toInt()) and checksumMask + SensitiveDataCleaner.clear(bits) + SensitiveDataCleaner.clear(hash) + if (difference == 0) return entropy + SensitiveDataCleaner.clear(entropy) + return null + } + + /** Whether [phrase] decodes, i.e. the word count, every word and the checksum are right. */ + public fun isValid(phrase: CharArray): Boolean { + val entropy = decode(phrase) ?: return false + SensitiveDataCleaner.clear(entropy) + return true + } + + private fun readWord(bits: ByteArray, word: Int): Int { + var value = 0 + for (position in word * BITS_PER_WORD until (word + 1) * BITS_PER_WORD) { + val bit = (bits[position / BITS_PER_BYTE].toInt() shr (BITS_PER_BYTE - 1 - position % BITS_PER_BYTE)) and 1 + value = (value shl 1) or bit + } + return value + } + + private fun writeWord(bits: ByteArray, word: Int, index: Int) { + for (bit in 0 until BITS_PER_WORD) { + if ((index shr (BITS_PER_WORD - 1 - bit)) and 1 == 0) continue + val position = word * BITS_PER_WORD + bit + val mask = TOP_BIT shr (position % BITS_PER_BYTE) + bits[position / BITS_PER_BYTE] = (bits[position / BITS_PER_BYTE].toInt() or mask).toByte() + } + } + + private companion object { + const val BITS_PER_WORD = 11 + const val BITS_PER_BYTE = 8 + const val BYTE_MASK = 0xFF + const val TOP_BIT = 0x80 + + /** Every 3 words carry 4 bytes of entropy: 33 bits, 32 of entropy and one of checksum. */ + const val WORDS_PER_GROUP = 3 + const val BYTES_PER_GROUP = 4 + + /** The checksum is entropy bits / 32, i.e. entropy bytes / 4 bits. */ + const val CHECKSUM_DIVISOR = 4 + private val ENTROPY_BYTES = listOf(16, 20, 24, 28, 32) + private val WORD_COUNTS = listOf(12, 15, 18, 21, 24) + } +} diff --git a/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Bip39Wordlist.kt b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Bip39Wordlist.kt new file mode 100644 index 00000000..dcdc7ef1 --- /dev/null +++ b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Bip39Wordlist.kt @@ -0,0 +1,214 @@ +package com.pasich.encly.core.security.bip39 + +/** + * The English BIP-39 wordlist, 2048 words in the published (sorted) order. + * + * Source: https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt (MIT licence, part + * of BIP-39). Bip39WordlistTest pins its SHA-256, so a stray edit cannot change a recovery phrase. + * A compiled constant rather than a resource, so common code can hold it unchanged. + */ +public object Bip39Wordlist { + public val words: List by lazy { TEXT.split(' ', '\n').filter { it.isNotEmpty() } } + + /** + * Index of the word spelled by `chars[start until end]`, or -1. Binary search over the + * sorted list, comparing in place so the typed word is never copied into a String. + */ + public fun indexOf(chars: CharArray, start: Int, end: Int): Int { + var low = 0 + var high = words.size - 1 + while (low <= high) { + val mid = (low + high) ushr 1 + val order = compare(words[mid], chars, start, end) + when { + order < 0 -> low = mid + 1 + order > 0 -> high = mid - 1 + else -> return mid + } + } + return -1 + } + + private fun compare(word: String, chars: CharArray, start: Int, end: Int): Int { + val length = end - start + for (i in 0 until minOf(word.length, length)) { + val difference = word[i] - chars[start + i] + if (difference != 0) return difference + } + return word.length - length + } + + private const val TEXT = """ +abandon ability able about above absent absorb abstract absurd abuse access accident +account accuse achieve acid acoustic acquire across act action actor actress actual +adapt add addict address adjust admit adult advance advice aerobic affair afford +afraid again age agent agree ahead aim air airport aisle alarm album +alcohol alert alien all alley allow almost alone alpha already also alter +always amateur amazing among amount amused analyst anchor ancient anger angle angry +animal ankle announce annual another answer antenna antique anxiety any apart apology +appear apple approve april arch arctic area arena argue arm armed armor +army around arrange arrest arrive arrow art artefact artist artwork ask aspect +assault asset assist assume asthma athlete atom attack attend attitude attract auction +audit august aunt author auto autumn average avocado avoid awake aware away +awesome awful awkward axis baby bachelor bacon badge bag balance balcony ball +bamboo banana banner bar barely bargain barrel base basic basket battle beach +bean beauty because become beef before begin behave behind believe below belt +bench benefit best betray better between beyond bicycle bid bike bind biology +bird birth bitter black blade blame blanket blast bleak bless blind blood +blossom blouse blue blur blush board boat body boil bomb bone bonus +book boost border boring borrow boss bottom bounce box boy bracket brain +brand brass brave bread breeze brick bridge brief bright bring brisk broccoli +broken bronze broom brother brown brush bubble buddy budget buffalo build bulb +bulk bullet bundle bunker burden burger burst bus business busy butter buyer +buzz cabbage cabin cable cactus cage cake call calm camera camp can +canal cancel candy cannon canoe canvas canyon capable capital captain car carbon +card cargo carpet carry cart case cash casino castle casual cat catalog +catch category cattle caught cause caution cave ceiling celery cement census century +cereal certain chair chalk champion change chaos chapter charge chase chat cheap +check cheese chef cherry chest chicken chief child chimney choice choose chronic +chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify +claw clay clean clerk clever click client cliff climb clinic clip clock +clog close cloth cloud clown club clump cluster clutch coach coast coconut +code coffee coil coin collect color column combine come comfort comic common +company concert conduct confirm congress connect consider control convince cook cool copper +copy coral core corn correct cost cotton couch country couple course cousin +cover coyote crack cradle craft cram crane crash crater crawl crazy cream +credit creek crew cricket crime crisp critic crop cross crouch crowd crucial +cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious +current curtain curve cushion custom cute cycle dad damage damp dance danger +daring dash daughter dawn day deal debate debris decade december decide decline +decorate decrease deer defense define defy degree delay deliver demand demise denial +dentist deny depart depend deposit depth deputy derive describe desert design desk +despair destroy detail detect develop device devote diagram dial diamond diary dice +diesel diet differ digital dignity dilemma dinner dinosaur direct dirt disagree discover +disease dish dismiss disorder display distance divert divide divorce dizzy doctor document +dog doll dolphin domain donate donkey donor door dose double dove draft +dragon drama drastic draw dream dress drift drill drink drip drive drop +drum dry duck dumb dune during dust dutch duty dwarf dynamic eager +eagle early earn earth easily east easy echo ecology economy edge edit +educate effort egg eight either elbow elder electric elegant element elephant elevator +elite else embark embody embrace emerge emotion employ empower empty enable enact +end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough +enrich enroll ensure enter entire entry envelope episode equal equip era erase +erode erosion error erupt escape essay essence estate eternal ethics evidence evil +evoke evolve exact example excess exchange excite exclude excuse execute exercise exhaust +exhibit exile exist exit exotic expand expect expire explain expose express extend +extra eye eyebrow fabric face faculty fade faint faith fall false fame +family famous fan fancy fantasy farm fashion fat fatal father fatigue fault +favorite feature february federal fee feed feel female fence festival fetch fever +few fiber fiction field figure file film filter final find fine finger +finish fire firm first fiscal fish fit fitness fix flag flame flash +flat flavor flee flight flip float flock floor flower fluid flush fly +foam focus fog foil fold follow food foot force forest forget fork +fortune forum forward fossil foster found fox fragile frame frequent fresh friend +fringe frog front frost frown frozen fruit fuel fun funny furnace fury +future gadget gain galaxy gallery game gap garage garbage garden garlic garment +gas gasp gate gather gauge gaze general genius genre gentle genuine gesture +ghost giant gift giggle ginger giraffe girl give glad glance glare glass +glide glimpse globe gloom glory glove glow glue goat goddess gold good +goose gorilla gospel gossip govern gown grab grace grain grant grape grass +gravity great green grid grief grit grocery group grow grunt guard guess +guide guilt guitar gun gym habit hair half hammer hamster hand happy +harbor hard harsh harvest hat have hawk hazard head health heart heavy +hedgehog height hello helmet help hen hero hidden high hill hint hip +hire history hobby hockey hold hole holiday hollow home honey hood hope +horn horror horse hospital host hotel hour hover hub huge human humble +humor hundred hungry hunt hurdle hurry hurt husband hybrid ice icon idea +identify idle ignore ill illegal illness image imitate immense immune impact impose +improve impulse inch include income increase index indicate indoor industry infant inflict +inform inhale inherit initial inject injury inmate inner innocent input inquiry insane +insect inside inspire install intact interest into invest invite involve iron island +isolate issue item ivory jacket jaguar jar jazz jealous jeans jelly jewel +job join joke journey joy judge juice jump jungle junior junk just +kangaroo keen keep ketchup key kick kid kidney kind kingdom kiss kit +kitchen kite kitten kiwi knee knife knock know lab label labor ladder +lady lake lamp language laptop large later latin laugh laundry lava law +lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal +legend leisure lemon lend length lens leopard lesson letter level liar liberty +library license life lift light like limb limit link lion liquid list +little live lizard load loan lobster local lock logic lonely long loop +lottery loud lounge love loyal lucky luggage lumber lunar lunch luxury lyrics +machine mad magic magnet maid mail main major make mammal man manage +mandate mango mansion manual maple marble march margin marine market marriage mask +mass master match material math matrix matter maximum maze meadow mean measure +meat mechanic medal media melody melt member memory mention menu mercy merge +merit merry mesh message metal method middle midnight milk million mimic mind +minimum minor minute miracle mirror misery miss mistake mix mixed mixture mobile +model modify mom moment monitor monkey monster month moon moral more morning +mosquito mother motion motor mountain mouse move movie much muffin mule multiply +muscle museum mushroom music must mutual myself mystery myth naive name napkin +narrow nasty nation nature near neck need negative neglect neither nephew nerve +nest net network neutral never news next nice night noble noise nominee +noodle normal north nose notable note nothing notice novel now nuclear number +nurse nut oak obey object oblige obscure observe obtain obvious occur ocean +october odor off offer office often oil okay old olive olympic omit +once one onion online only open opera opinion oppose option orange orbit +orchard order ordinary organ orient original orphan ostrich other outdoor outer output +outside oval oven over own owner oxygen oyster ozone pact paddle page +pair palace palm panda panel panic panther paper parade parent park parrot +party pass patch path patient patrol pattern pause pave payment peace peanut +pear peasant pelican pen penalty pencil people pepper perfect permit person pet +phone photo phrase physical piano picnic picture piece pig pigeon pill pilot +pink pioneer pipe pistol pitch pizza place planet plastic plate play please +pledge pluck plug plunge poem poet point polar pole police pond pony +pool popular portion position possible post potato pottery poverty powder power practice +praise predict prefer prepare present pretty prevent price pride primary print priority +prison private prize problem process produce profit program project promote proof property +prosper protect proud provide public pudding pull pulp pulse pumpkin punch pupil +puppy purchase purity purpose purse push put puzzle pyramid quality quantum quarter +question quick quit quiz quote rabbit raccoon race rack radar radio rail +rain raise rally ramp ranch random range rapid rare rate rather raven +raw razor ready real reason rebel rebuild recall receive recipe record recycle +reduce reflect reform refuse region regret regular reject relax release relief rely +remain remember remind remove render renew rent reopen repair repeat replace report +require rescue resemble resist resource response result retire retreat return reunion reveal +review reward rhythm rib ribbon rice rich ride ridge rifle right rigid +ring riot ripple risk ritual rival river road roast robot robust rocket +romance roof rookie room rose rotate rough round route royal rubber rude +rug rule run runway rural sad saddle sadness safe sail salad salmon +salon salt salute same sample sand satisfy satoshi sauce sausage save say +scale scan scare scatter scene scheme school science scissors scorpion scout scrap +screen script scrub sea search season seat second secret section security seed +seek segment select sell seminar senior sense sentence series service session settle +setup seven shadow shaft shallow share shed shell sheriff shield shift shine +ship shiver shock shoe shoot shop short shoulder shove shrimp shrug shuffle +shy sibling sick side siege sight sign silent silk silly silver similar +simple since sing siren sister situate six size skate sketch ski skill +skin skirt skull slab slam sleep slender slice slide slight slim slogan +slot slow slush small smart smile smoke smooth snack snake snap sniff +snow soap soccer social sock soda soft solar soldier solid solution solve +someone song soon sorry sort soul sound soup source south space spare +spatial spawn speak special speed spell spend sphere spice spider spike spin +spirit split spoil sponsor spoon sport spot spray spread spring spy square +squeeze squirrel stable stadium staff stage stairs stamp stand start state stay +steak steel stem step stereo stick still sting stock stomach stone stool +story stove strategy street strike strong struggle student stuff stumble style subject +submit subway success such sudden suffer sugar suggest suit summer sun sunny +sunset super supply supreme sure surface surge surprise surround survey suspect sustain +swallow swamp swap swarm swear sweet swift swim swing switch sword symbol +symptom syrup system table tackle tag tail talent talk tank tape target +task taste tattoo taxi teach team tell ten tenant tennis tent term +test text thank that theme then theory there they thing this thought +three thrive throw thumb thunder ticket tide tiger tilt timber time tiny +tip tired tissue title toast tobacco today toddler toe together toilet token +tomato tomorrow tone tongue tonight tool tooth top topic topple torch tornado +tortoise toss total tourist toward tower town toy track trade traffic tragic +train transfer trap trash travel tray treat tree trend trial tribe trick +trigger trim trip trophy trouble truck true truly trumpet trust truth try +tube tuition tumble tuna tunnel turkey turn turtle twelve twenty twice twin +twist two type typical ugly umbrella unable unaware uncle uncover under undo +unfair unfold unhappy uniform unique unit universe unknown unlock until unusual unveil +update upgrade uphold upon upper upset urban urge usage use used useful +useless usual utility vacant vacuum vague valid valley valve van vanish vapor +various vast vault vehicle velvet vendor venture venue verb verify version very +vessel veteran viable vibrant vicious victory video view village vintage violin virtual +virus visa visit visual vital vivid vocal voice void volcano volume vote +voyage wage wagon wait walk wall walnut want warfare warm warrior wash +wasp waste water wave way wealth weapon wear weasel weather web wedding +weekend weird welcome west wet whale what wheat wheel when where whip +whisper wide width wife wild will win window wine wing wink winner +winter wire wisdom wise wish witness wolf woman wonder wood wool word +work world worry worth wrap wreck wrestle wrist write wrong yard year +yellow you young youth zebra zero zone zoo +""" +} diff --git a/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Sha256.kt b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Sha256.kt new file mode 100644 index 00000000..0fea53c6 --- /dev/null +++ b/core/crypto/src/commonMain/kotlin/com/pasich/encly/core/security/bip39/Sha256.kt @@ -0,0 +1,6 @@ +package com.pasich.encly.core.security.bip39 + +/** The one primitive BIP-39 needs from the platform: the checksum is the start of a SHA-256. */ +internal fun interface Sha256 { + fun digest(input: ByteArray): ByteArray +} diff --git a/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/crypto/CryptoTest.kt b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/crypto/CryptoTest.kt new file mode 100644 index 00000000..b096ed82 --- /dev/null +++ b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/crypto/CryptoTest.kt @@ -0,0 +1,180 @@ +package com.pasich.encly.core.crypto + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The crypto API against published vectors (RFC 5869, RFC 7914, McGrew and Viega AES-GCM test cases 15 + * and 16, FIPS 180 SHA-256). The formats built on it are pinned in :core:security. + */ +class CryptoTest { + private val dek = seq(32, 0, 1) + private val kek = seq(32, 100, 1) + private val salt = seq(16, 0, 3) + private val iv = seq(12, 0x20, 1) + + // --- Published vectors ---------------------------------------------------------------- + + @Test + fun hkdfMatchesRfc5869() { + val tc1 = Crypto.hkdfSha256(ByteArray(22) { 0x0b }, seq(13, 0, 1), seq(10, 0xf0, 1), 32) + val tc2 = Crypto.hkdfSha256(seq(80, 0x00, 1), seq(80, 0x60, 1), seq(80, 0xb0, 1), 32) + + assertContentEquals(GoldenVectors.rfc5869Tc1Okm32, tc1) + assertContentEquals(GoldenVectors.rfc5869Tc2Okm32, tc2) + } + + @Test + fun hkdfRefusesWhatTheAppNeverUses() { + assertFailsWith { Crypto.hkdfSha256(dek, ByteArray(0), salt, 32) } + assertFailsWith { Crypto.hkdfSha256(dek, salt, salt, 33) } + assertFailsWith { Crypto.hkdfSha256(dek, salt, salt, 0) } + } + + @Test + fun pbkdf2MatchesRfc7914() { + // RFC 7914 section 11, first 32 bytes of the 64-byte outputs. + assertContentEquals( + GoldenVectors.rfc7914C1, + Crypto.pbkdf2HmacSha256("passwd".encodeToByteArray(), "salt".encodeToByteArray(), 1, 32), + ) + assertContentEquals( + GoldenVectors.rfc7914C80000, + Crypto.pbkdf2HmacSha256("Password".encodeToByteArray(), "NaCl".encodeToByteArray(), 80_000, 32), + ) + } + + @Test + fun pbkdf2KeepsTheAppsPinParameters() { + val stretched = Crypto.pbkdf2HmacSha256("123456".encodeToByteArray(), salt, 600_000, 32) + + assertContentEquals(GoldenVectors.pinSw, stretched) + } + + @Test + fun pbkdf2OfCharactersMatchesPbkdf2OfTheirUtf8Bytes() { + // PBKDF2-HMAC-SHA-256, "password" and "salt", one iteration (a published vector). + val expected = GoldenVectors.hex("120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b") + + assertContentEquals( + expected, + Crypto.pbkdf2HmacSha256("password".toCharArray(), "salt".encodeToByteArray(), 1, 32), + ) + assertContentEquals( + expected, + Crypto.pbkdf2HmacSha256("password".encodeToByteArray(), "salt".encodeToByteArray(), 1, 32), + ) + // Characters beyond ASCII go through UTF-8: the same bytes give the same key. + assertContentEquals( + Crypto.pbkdf2HmacSha256("pin é汉".toCharArray(), salt, 2, 32), + Crypto.pbkdf2HmacSha256("pin é汉".encodeToByteArray(), salt, 2, 32), + ) + } + + @Test + fun pbkdf2NeedsAPositiveIterationCountAndLength() { + val chars = "123456".toCharArray() + val bytes = "123456".encodeToByteArray() + + assertFailsWith { Crypto.pbkdf2HmacSha256(chars, salt, 0, 32) } + assertFailsWith { Crypto.pbkdf2HmacSha256(chars, salt, 1, 0) } + assertFailsWith { Crypto.pbkdf2HmacSha256(bytes, salt, 0, 32) } + assertFailsWith { Crypto.pbkdf2HmacSha256(bytes, salt, 1, 0) } + assertFailsWith { Crypto.pbkdf2HmacSha256(bytes, salt, -1, 32) } + assertFailsWith { Crypto.pbkdf2HmacSha256(bytes, salt, 1, -1) } + } + + @Test + fun aesGcmMatchesTheMcGrewAndViegaVectors() { + val key = GoldenVectors.hex("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308") + val nonce = GoldenVectors.hex("cafebabefacedbaddecaf888") + val plaintext = GoldenVectors.hex( + "d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e24" + + "49a6b525b16aedf5aa0de657ba637b391aafd255", + ) + val aad = GoldenVectors.hex("feedfacedeadbeeffeedfacedeadbeefabaddad2") + + // Test case 15: no AAD (the tag is the last 16 bytes). + val tc15 = Crypto.aesGcmSeal(key, nonce, ByteArray(0), plaintext) + assertContentEquals(GoldenVectors.gcmTc15, tc15) + assertContentEquals(plaintext, Crypto.aesGcmOpen(key, nonce, ByteArray(0), tc15)) + // Test case 16: 60 bytes of plaintext and 20 of AAD. + val tc16 = Crypto.aesGcmSeal(key, nonce, aad, plaintext.copyOf(60)) + assertContentEquals(GoldenVectors.gcmTc16, tc16) + assertContentEquals(plaintext.copyOf(60), Crypto.aesGcmOpen(key, nonce, aad, tc16)) + } + + @Test + fun sha256MatchesFips180() { + assertContentEquals(GoldenVectors.sha256Abc, Crypto.sha256("abc".encodeToByteArray())) + assertContentEquals(GoldenVectors.sha256Abc, Crypto.sha256("xxabcyy".encodeToByteArray(), 2, 5)) + assertEquals(32, Crypto.sha256(ByteArray(0)).size) + } + + // --- AES-GCM behaviour ----------------------------------------------------------------- + + @Test + fun aGcmOpenIsNullForAnythingThatDoesNotAuthenticate() { + val sealed = Crypto.aesGcmSeal(kek, iv, salt, dek) + + assertContentEquals(dek, Crypto.aesGcmOpen(kek, iv, salt, sealed)) + assertNull(Crypto.aesGcmOpen(seq(32, 101, 1), iv, salt, sealed)) + assertNull(Crypto.aesGcmOpen(kek, seq(12, 0x21, 1), salt, sealed)) + assertNull(Crypto.aesGcmOpen(kek, iv, seq(16, 1, 3), sealed)) + assertNull(Crypto.aesGcmOpen(kek, iv, salt, sealed.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() })) + assertNull(Crypto.aesGcmOpen(kek, iv, salt, sealed.copyOf().also { it[it.size - 1] = 0 })) + assertNull(Crypto.aesGcmOpen(kek, iv, salt, sealed.copyOf(sealed.size - 1))) + assertNull(Crypto.aesGcmOpen(kek, iv, salt, sealed.copyOf(15))) + assertNull(Crypto.aesGcmOpen(kek, iv, salt, ByteArray(0))) + } + + @Test + fun aGcmSealOfNothingIsJustATag() { + val sealed = Crypto.aesGcmSeal(kek, iv, salt, ByteArray(0)) + + assertEquals(16, sealed.size) + assertContentEquals(ByteArray(0), Crypto.aesGcmOpen(kek, iv, salt, sealed)) + } + + @Test + fun theKeyAndTheNonceMustHaveTheSizesTheFormatsUse() { + assertFailsWith { Crypto.aesGcmSeal(ByteArray(16), iv, salt, dek) } + assertFailsWith { Crypto.aesGcmSeal(kek, ByteArray(16), salt, dek) } + assertFailsWith { Crypto.aesGcmOpen(ByteArray(31), iv, salt, dek) } + assertFailsWith { Crypto.aesGcmOpen(kek, ByteArray(11), salt, dek) } + } + + // --- Randomness and comparison ------------------------------------------------------------ + + @Test + fun randomBytesHaveTheRequestedSizeAndDoNotRepeat() { + assertEquals(0, Crypto.randomBytes(0).size) + assertEquals(16, Crypto.randomBytes(16).size) + assertFalse(Crypto.randomBytes(32).contentEquals(Crypto.randomBytes(32))) + assertFalse(Crypto.randomBytes(32).all { it == 0.toByte() }) + assertNotEquals(Crypto.randomLong(), Crypto.randomLong()) + } + + @Test + fun constantTimeEqualsComparesEveryByteAndTheLength() { + val a = seq(32, 0, 1) + + assertTrue(Crypto.constantTimeEquals(a, a.copyOf())) + assertTrue(Crypto.constantTimeEquals(ByteArray(0), ByteArray(0))) + assertFalse(Crypto.constantTimeEquals(a, a.copyOf(31))) + assertFalse(Crypto.constantTimeEquals(a.copyOf(31), a)) + assertFalse(Crypto.constantTimeEquals(a, ByteArray(0))) + for (index in a.indices) { + val other = a.copyOf().also { it[index] = (it[index].toInt() xor 0x80).toByte() } + assertFalse(Crypto.constantTimeEquals(a, other)) + } + } + + private fun seq(size: Int, offset: Int, step: Int) = ByteArray(size) { (it * step + offset).toByte() } +} diff --git a/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/crypto/GoldenVectors.kt b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/crypto/GoldenVectors.kt new file mode 100644 index 00000000..d5cf12c4 --- /dev/null +++ b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/crypto/GoldenVectors.kt @@ -0,0 +1,34 @@ +package com.pasich.encly.core.crypto + +/** + * Expected bytes for the tests of the crypto API: RFC 5869 (HKDF), RFC 7914 (PBKDF2), the McGrew and Viega + * AES-GCM test cases, FIPS 180 (SHA-256) and the PBKDF2 stretch of the app's PIN parameters, computed with + * the platform JCA. :core:security pins the formats built on them. + */ +internal object GoldenVectors { + val rfc5869Tc1Okm32 = hex("3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf") + + val rfc5869Tc2Okm32 = hex("b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c") + + val rfc7914C1 = hex("55ac046e56e3089fec1691c22544b605f94185216dde0465e68b9d57c20dacbc") + + val rfc7914C80000 = hex("4ddcd8f60b98be21830cee5ef22701f9641a4418d04c0414aeff08876b34ab56") + + val gcmTc15 = hex( + "522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838" + + "c5f61e6393ba7a0abcc9f662898015adb094dac5d93471bdec1a502270e3cc6c", + ) + + val gcmTc16 = hex( + "522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838" + + "c5f61e6393ba7a0abcc9f66276fc6ece0f4e1768cddf8853bb2d551b", + ) + + val sha256Abc = hex("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + + val pinSw = hex("4f5705502cd1802172fbf3eecd6a1b14fb647976595714bce7c922b66c30a4e7") + + fun hex(text: String): ByteArray = ByteArray(text.length / 2) { + text.substring(2 * it, 2 * it + 2).toInt(16).toByte() + } +} diff --git a/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39Test.kt b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39Test.kt new file mode 100644 index 00000000..3a957304 --- /dev/null +++ b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39Test.kt @@ -0,0 +1,92 @@ +package com.pasich.encly.core.security.bip39 + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** BIP-39 against the official vectors, and the rules of its checksum and word checks. */ +class Bip39Test { + private val abandon = List(11) { "abandon" }.joinToString(" ") + " about" + + private fun encode(entropy: ByteArray): String = Bip39().encode(entropy).concatToString() + + @Test + fun officialVectorsEncode() { + assertEquals(24, Bip39Vectors.all.size) + Bip39Vectors.all.forEach { (entropy, mnemonic) -> assertEquals(mnemonic, encode(entropy)) } + } + + @Test + fun officialVectorsDecode() { + Bip39Vectors.all.forEach { (entropy, mnemonic) -> + assertContentEquals(entropy, Bip39().decode(mnemonic.toCharArray()), mnemonic) + assertTrue(Bip39().isValid(mnemonic.toCharArray()), mnemonic) + } + } + + @Test + fun entropyOfEverySizeRoundTrips() { + listOf(16, 20, 24, 28, 32).forEach { size -> + val entropy = ByteArray(size) { (it * 7 + size).toByte() } + val phrase = Bip39().encode(entropy) + assertEquals(size * 3 / 4, phrase.concatToString().split(' ').size) + assertContentEquals(entropy, Bip39().decode(phrase)) + } + } + + @Test + fun oddEntropySizesAreRefused() { + listOf(0, 15, 17, 33).forEach { size -> + assertFailsWith { Bip39().encode(ByteArray(size)) } + } + } + + @Test + fun decodeWipesNothingItWasGiven() { + val phrase = abandon.toCharArray() + Bip39().decode(phrase) + assertEquals(abandon, phrase.concatToString()) + } + + @Test + fun aWrongChecksumDecodesToNothing() { + assertNull(Bip39().decode(abandon.replaceAfterLast(' ', "abandon").toCharArray())) + assertFalse(Bip39().isValid(abandon.replaceAfterLast(' ', "abandon").toCharArray())) + } + + @Test + fun anUnknownWordDecodesToNothing() { + assertNull(Bip39().decode(abandon.replaceAfterLast(' ', "aboot").toCharArray())) + assertNull(Bip39().decode(abandon.replaceAfterLast(' ', "abo").toCharArray())) + assertNull(Bip39().decode(abandon.replace("abandon", "abandonx").toCharArray())) + } + + @Test + fun aWrongWordCountDecodesToNothing() { + val words = abandon.split(' ') + listOf(0, 1, 11, 13, 14, 25).forEach { count -> + val phrase = List(count) { words[it % 11] }.joinToString(" ") + assertNull(Bip39().decode(phrase.toCharArray()), "$count") + } + } + + @Test + fun theChecksumOfEveryLengthIsChecked() { + listOf(16, 20, 24, 28, 32).forEach { size -> + val phrase = Bip39().encode(ByteArray(size) { it.toByte() }) + assertNotNull(Bip39().decode(phrase)) + // The last word carries the checksum bits: another word of the same entropy bits fails. + val words = phrase.concatToString().split(' ').toMutableList() + val last = words.last() + words[words.lastIndex] = Bip39Wordlist.words.first { it != last && it.take(1) == last.take(1) } + val changed = words.joinToString(" ").toCharArray() + // Some neighbours still check out (the checksum is only a few bits); none may equal the original. + assertTrue(Bip39().decode(changed)?.contentEquals(ByteArray(size) { it.toByte() }) != true) + } + } +} diff --git a/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39Vectors.kt b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39Vectors.kt new file mode 100644 index 00000000..c359198c --- /dev/null +++ b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39Vectors.kt @@ -0,0 +1,93 @@ +package com.pasich.encly.core.security.bip39 + +/** + * The official BIP-39 English test vectors (entropy as hex, then the mnemonic). + * Source: https://github.com/trezor/python-mnemonic/blob/master/vectors.json + */ +internal object Bip39Vectors { + val all: List> = listOf( + "00000000000000000000000000000000" to ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "about" + ), + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f" to + "legal winner thank year wave sausage worth useful legal winner thank yellow", + "80808080808080808080808080808080" to + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + "ffffffffffffffffffffffffffffffff" to + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "000000000000000000000000000000000000000000000000" to ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon abandon agent" + ), + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f" to ( + "legal winner thank year wave sausage worth useful legal winner thank year wave sausage " + + "worth useful legal will" + ), + "808080808080808080808080808080808080808080808080" to ( + "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount " + + "doctor acoustic avoid letter always" + ), + "ffffffffffffffffffffffffffffffffffffffffffffffff" to + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when", + "0000000000000000000000000000000000000000000000000000000000000000" to ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon art" + ), + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f" to ( + "legal winner thank year wave sausage worth useful legal winner thank year wave sausage " + + "worth useful legal winner thank year wave sausage worth title" + ), + "8080808080808080808080808080808080808080808080808080808080808080" to ( + "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount " + + "doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless" + ), + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" to ( + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " + + "zoo vote" + ), + "9e885d952ad362caeb4efe34a8e91bd2" to + "ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic", + "6610b25967cdcca9d59875f5cb50b0ea75433311869e930b" to ( + "gravity machine north sort system female filter attitude volume fold club stay feature " + + "office ecology stable narrow fog" + ), + "68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c" to ( + "hamster diagram private dutch cause delay private meat slide toddler razor book happy " + + "fancy gospel tennis maple dilemma loan word shrug inflict delay length" + ), + "c0ba5a8e914111210f2bd131f3d5e08d" to + "scheme spot photo card baby mountain device kick cradle pact join borrow", + "6d9be1ee6ebd27a258115aad99b7317b9c8d28b6d76431c3" to ( + "horn tenant knee talent sponsor spell gate clip pulse soap slush warm silver nephew swap " + + "uncle crack brave" + ), + "9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863" to ( + "panda eyebrow bullet gorilla call smoke muffin taste mesh discover soft ostrich alcohol " + + "speed nation flash devote level hobby quick inner drive ghost inside" + ), + "23db8160a31d3e0dca3688ed941adbf3" to + "cat swing flag economy stadium alone churn speed unique patch report train", + "8197a4a47f0425faeaa69deebc05ca29c0a5b5cc76ceacc0" to ( + "light rule cinnamon wrap drastic word pride squirrel upgrade then income fatal apart " + + "sustain crack supply proud access" + ), + "066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad" to ( + "all hour make first leader extend hole alien behind guard gospel lava path output census " + + "museum junior mass reopen famous sing advance salt reform" + ), + "f30f8c1da665478f49b001d94c5fc452" to + "vessel ladder alter error federal sibling chat ability sun glass valve picture", + "c10ec20dc3cd9f652c7fac2f1230f7a3c828389a14392f05" to ( + "scissors invite lock maple supreme raw rapid void congress muscle digital elegant little " + + "brisk hair mango congress clump" + ), + "f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f" to ( + "void come effort suffer camp survey warrior heavy shoot primary clutch crush open " + + "amazing screen patrol group space point ten exist slush involve unfold" + ), + ).map { (entropy, mnemonic) -> + entropy.chunked(2).map { it.toInt(16).toByte() }.toByteArray() to mnemonic + } +} diff --git a/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39WordlistTest.kt b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39WordlistTest.kt new file mode 100644 index 00000000..8932879d --- /dev/null +++ b/core/crypto/src/commonTest/kotlin/com/pasich/encly/core/security/bip39/Bip39WordlistTest.kt @@ -0,0 +1,45 @@ +package com.pasich.encly.core.security.bip39 + +import com.pasich.encly.core.crypto.Crypto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class Bip39WordlistTest { + private val words = Bip39Wordlist.words + + @Test + fun isTheCanonicalEnglishList() { + // SHA-256 of bitcoin/bips bip-0039/english.txt: the 2048 words, one per line, final newline. + val canonical = "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda" + val digest = Crypto.sha256((words.joinToString("\n") + "\n").encodeToByteArray()) + + assertEquals(canonical, digest.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }) + } + + @Test + fun hasTheShapeBip39Promises() { + assertEquals(2048, words.size) + assertEquals("abandon", words.first()) + assertEquals("zoo", words.last()) + assertEquals(words.sorted(), words) + assertEquals(2048, words.toSet().size) + assertTrue(words.all { word -> word.length in 3..8 && word.all { it in 'a'..'z' } }) + assertEquals(2048, words.map { it.take(4) }.toSet().size) + } + + @Test + fun lookupFindsEveryWordInPlace() { + words.forEachIndexed { index, word -> + val padded = "x $word y".toCharArray() + assertEquals(index, Bip39Wordlist.indexOf(padded, 2, 2 + word.length), word) + } + } + + @Test + fun lookupRejectsNearMisses() { + listOf("", "a", "aban", "abandonn", "Abandon", "zoos", "zo", "~").forEach { + assertEquals(-1, Bip39Wordlist.indexOf(it.toCharArray(), 0, it.length), it) + } + } +} diff --git a/core/crypto/src/desktopTest/kotlin/com/pasich/encly/core/crypto/CryptoBoundaryTest.kt b/core/crypto/src/desktopTest/kotlin/com/pasich/encly/core/crypto/CryptoBoundaryTest.kt new file mode 100644 index 00000000..40f0f4c8 --- /dev/null +++ b/core/crypto/src/desktopTest/kotlin/com/pasich/encly/core/crypto/CryptoBoundaryTest.kt @@ -0,0 +1,127 @@ +package com.pasich.encly.core.crypto + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The crypto API (`Crypto`) and BIP-39 are common code: the shared sources may import nothing of Android or + * the JVM, and nothing of the app or of a module above `:core:common`. The one place that may name `javax.crypto` + * and `java.security` is the JCA actual in `jvmAndAndroidMain`, which is checked to be the only platform source + * set. The module may depend on `:core:common` and no other module (see [theModuleDependsOnlyOnCommon]). + */ +class CryptoBoundaryTest { + // Tests run with the module directory as the working directory. + private val moduleRoot = listOf(File("."), File("core/crypto")).first { File(it, "build.gradle.kts").isFile } + private val sourceRoot = File(moduleRoot, "src/commonMain/kotlin") + + private val sources = sourceRoot.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + + private fun File.relative() = relativeTo(sourceRoot).invariantSeparatorsPath + + /** Source lines without comments (line, block and KDoc) and string literals' contents. */ + private fun File.codeLines(): List> { + // A block comment becomes as many line breaks as it had, so line numbers stay right. + val text = BLOCK_COMMENT.replace(readText()) { "\n".repeat(it.value.count { c -> c == '\n' }) } + return text.lines().mapIndexed { index, raw -> + (index + 1) to raw.replace(STRING, "\"\"").substringBefore("//") + } + } + + @Test + fun theSourcesWereFound() { + assertTrue(sources.size >= 6, "no sources under $sourceRoot") + } + + @Test + fun noImportReachesOutOfTheModuleOrOntoThePlatform() { + val offenders = sources.flatMap { file -> + file.codeLines().mapNotNull { (number, line) -> + val import = IMPORT.matchEntire(line.trim())?.groupValues?.get(1) ?: return@mapNotNull null + "${file.relative()}:$number import $import".takeUnless { ALLOWED_PREFIXES.any(import::startsWith) } + } + } + assertEquals(emptyList(), offenders, "the shared crypto sources may not import these") + } + + @Test + fun noCodeNamesThePlatformWithoutAnImport() { + val offenders = sources.flatMap { file -> + file.codeLines().filter { (_, line) -> !line.trim().startsWith("import ") } + .filter { (_, line) -> PLATFORM_USE.containsMatchIn(line) } + .map { (number, line) -> "${file.relative()}:$number ${line.trim()}" } + } + assertEquals(emptyList(), offenders, "the shared crypto sources may not use the JVM or Android directly") + } + + @Test + fun onlyTheJvmSourceSetHoldsPlatformCode() { + val platformSets = File(moduleRoot, "src").listFiles().orEmpty().map { it.name }.filter { it.endsWith("Main") } + assertEquals(listOf("commonMain", "iosMain", "jvmAndAndroidMain"), platformSets.sorted()) + } + + @Test + fun theModuleDependsOnlyOnCommon() { + val build = File(moduleRoot, "build.gradle.kts").readText() + val projects = Regex("""project\(\s*"(:[\w:]+)"""").findAll(build).map { it.groupValues[1] }.toList() + assertEquals(listOf(":core:common"), projects) + } + + @Test + fun theIosSourcesImportOnlyAppleLibrariesAndTheModulesOwnCode() { + val iosSources = File(moduleRoot, "src/iosMain/kotlin").walkTopDown().filter { + it.isFile && it.extension == "kt" + } + val offenders = iosSources.flatMap { file -> + file.codeLines().mapNotNull { (number, line) -> + val import = IMPORT.matchEntire(line.trim())?.groupValues?.get(1) ?: return@mapNotNull null + "${file.name}:$number import $import".takeUnless { IOS_ALLOWED_PREFIXES.any(import::startsWith) } + } + }.toList() + assertEquals(emptyList(), offenders, "the iOS sources may import Apple libraries and the module itself") + } + + @Test + fun theCheckSeesWhatItShouldReject() { + assertTrue(ALLOWED_PREFIXES.none { "javax.crypto.Cipher".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "java.security.MessageDigest".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "android.os.Build".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "com.pasich.encly.core.security.SecurityManager".startsWith(it) }) + assertTrue(PLATFORM_USE.containsMatchIn("val x = java.util.UUID.randomUUID()")) + assertTrue(PLATFORM_USE.containsMatchIn("val t = System.currentTimeMillis()")) + assertFalse(PLATFORM_USE.containsMatchIn("val t = Clock.System.now()")) + assertTrue(PLATFORM_USE.containsMatchIn("e.javaClass.simpleName")) + } + + private companion object { + val IMPORT = Regex("""import ([\w.]+)(?: as \w+)?""") + val STRING = Regex(""""(?:[^"\\]|\\.)*"""") + val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""") + + val ALLOWED_PREFIXES = listOf( + "com.pasich.encly.core.crypto.", + "com.pasich.encly.core.security.bip39.", + "com.pasich.encly.core.security.SensitiveDataCleaner", + "kotlin.", + "kotlinx.", + ) + + val IOS_ALLOWED_PREFIXES = ALLOWED_PREFIXES + listOf("platform.", "kotlinx.cinterop.") + + // Direct uses of the platform that need no import: java.*, android.*, System, javaClass. + val PLATFORM_USE = Regex( + listOf( + """\b(?:java|javax|android)\.\w""", + """(? + point.usePinned { + encly_p256_point_from_x( + xPinned.addressOf(0).reinterpret(), + X_BYTES.convert(), + it.addressOf(0).reinterpret(), + ) + } + } + return if (status == 0) point else null + } +} diff --git a/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/PlatformCrypto.kt b/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/PlatformCrypto.kt new file mode 100644 index 00000000..af815ac3 --- /dev/null +++ b/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/PlatformCrypto.kt @@ -0,0 +1,189 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.core.crypto + +import com.pasich.encly.core.crypto.swift.encly_aes_gcm_open +import com.pasich.encly.core.crypto.swift.encly_aes_gcm_seal +import com.pasich.encly.core.security.SensitiveDataCleaner +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.CoreCrypto.CCHmac +import platform.CoreCrypto.CCKeyDerivationPBKDF +import platform.CoreCrypto.CC_SHA256 +import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH +import platform.CoreCrypto.kCCHmacAlgSHA256 +import platform.CoreCrypto.kCCPBKDF2 +import platform.CoreCrypto.kCCPRFHmacAlgSHA256 +import platform.Security.SecRandomCopyBytes +import platform.Security.errSecSuccess +import platform.Security.kSecRandomDefault +import platform.posix.timingsafe_bcmp + +/** + * The iOS implementation behind [Crypto], the only file here that imports Apple's libraries. Every + * primitive is the platform's own: CommonCrypto (SHA-256, HMAC, PBKDF2), `SecRandomCopyBytes` (the CSPRNG) + * and CryptoKit's `AES.GCM` through the C functions of EnclyAesGcm.swift. HKDF is RFC 5869 over CommonCrypto's + * HMAC, exactly as the JCA side builds it. Nothing is cached, and each intermediate array that holds key + * material is zeroized before returning, as on the JVM. + * + * The one thing the Swift side cannot do is wipe CryptoKit's own copy of the key (`SymmetricKey` keeps it in + * storage CryptoKit releases and zeroizes itself); the arrays this file owns are wiped by their callers. + */ +internal actual object PlatformCrypto { + private const val TAG_BYTES = Crypto.TAG_BYTES + private const val SINGLE_BLOCK_COUNTER: Byte = 0x01 + + actual fun randomBytes(size: Int): ByteArray { + val bytes = ByteArray(size) + val status = bytes.withPointer { SecRandomCopyBytes(kSecRandomDefault, size.convert(), it) } + if (status != errSecSuccess) throw CryptoException(IllegalStateException("SecRandomCopyBytes: $status")) + return bytes + } + + actual fun sha256(data: ByteArray, startIndex: Int, endIndex: Int): ByteArray { + val digest = ByteArray(CC_SHA256_DIGEST_LENGTH) + data.withPointer(startIndex) { input -> + digest.withPointer { CC_SHA256(input, (endIndex - startIndex).convert(), it) } + } + return digest + } + + /** RFC 5869 for one output block: `T(1) = HMAC(PRK, info ‖ 0x01)`, PRK and T wiped. */ + actual fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + val prk = hmacSha256(salt, ikm) + return try { + val block = hmacSha256(prk, info + SINGLE_BLOCK_COUNTER) + try { + block.copyOf(length) + } finally { + SensitiveDataCleaner.clear(block) + } + } finally { + SensitiveDataCleaner.clear(prk) + } + } + + /** [password] goes to the KDF as its UTF-8 bytes; they are wiped, and no String is made. */ + actual fun pbkdf2HmacSha256(password: CharArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + val bytes = encodeUtf8(password) + return try { + pbkdf2HmacSha256(bytes, salt, iterations, length) + } finally { + SensitiveDataCleaner.clear(bytes) + } + } + + /** + * [password] as UTF-8 text, handed to CommonCrypto as it is. For well-formed UTF-8 (all the app produces) + * this is what the JVM computes; the JVM first decodes the bytes to characters, which turns malformed + * input into U+FFFD, and that conversion is not repeated here. + */ + actual fun pbkdf2HmacSha256(password: ByteArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + val derived = ByteArray(length) + // CommonCrypto rejects a null password pointer even with a length of 0; the JCA accepts an empty password. + val status = password.withPointer(emptyAs = EMPTY_INPUT) { passwordPointer -> + salt.withPointer(emptyAs = EMPTY_INPUT) { saltPointer -> + derived.withPointer { + CCKeyDerivationPBKDF( + kCCPBKDF2, + passwordPointer?.reinterpret(), + password.size.convert(), + saltPointer, + salt.size.convert(), + kCCPRFHmacAlgSHA256, + iterations.convert(), + it, + length.convert(), + ) + } + } + } + if (status != 0) { + SensitiveDataCleaner.clear(derived) + throw CryptoException(IllegalStateException("CCKeyDerivationPBKDF: $status")) + } + return derived + } + + actual fun aesGcmSeal(key: ByteArray, nonce: ByteArray, aad: ByteArray, plaintext: ByteArray): ByteArray { + val sealed = ByteArray(plaintext.size + TAG_BYTES) + val status = key.withPointer { k -> + nonce.withPointer { n -> + aad.withPointer { a -> + plaintext.withPointer { p -> + sealed.withPointer { + encly_aes_gcm_seal( + k, key.size.convert(), n, nonce.size.convert(), a, aad.size.convert(), + p, plaintext.size.convert(), it, + ) + } + } + } + } + } + if (status != 0) throw CryptoException(IllegalStateException("AES-GCM seal refused")) + return sealed + } + + /** Null when the data does not authenticate (or is shorter than a tag). */ + actual fun aesGcmOpen(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray? = + if (ciphertext.size < TAG_BYTES) null else openSealed(key, nonce, aad, ciphertext) + + private fun openSealed(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray? { + val plaintext = ByteArray(ciphertext.size - TAG_BYTES) + val status = key.withPointer { k -> + nonce.withPointer { n -> + aad.withPointer { a -> + ciphertext.withPointer { c -> + plaintext.withPointer { + encly_aes_gcm_open( + k, key.size.convert(), n, nonce.size.convert(), a, aad.size.convert(), + c, ciphertext.size.convert(), it, + ) + } + } + } + } + } + if (status == 0) return plaintext + SensitiveDataCleaner.clear(plaintext) + return null + } + + actual fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + return a.withPointer { pa -> b.withPointer { pb -> timingsafe_bcmp(pa, pb, a.size.convert()) == 0 } } + } + + private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { + val mac = ByteArray(CC_SHA256_DIGEST_LENGTH) + key.withPointer { k -> + data.withPointer { d -> + mac.withPointer { CCHmac(kCCHmacAlgSHA256, k, key.size.convert(), d, data.size.convert(), it) } + } + } + return mac + } +} + +/** + * Runs [block] with a pointer to the array's bytes, pinned for the call. An empty array has no first element + * to point at, so the callee gets null (or, for a callee that refuses null, the first byte of [emptyAs]) together + * with a length of 0. + */ +private inline fun ByteArray.withPointer( + offset: Int = 0, + emptyAs: ByteArray? = null, + block: (CPointer?) -> R, +): R = when { + offset < size -> usePinned { block(it.addressOf(offset).reinterpret()) } + emptyAs != null -> emptyAs.usePinned { block(it.addressOf(0).reinterpret()) } + else -> block(null) +} + +private val EMPTY_INPUT = ByteArray(1) diff --git a/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/Utf8Chars.kt b/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/Utf8Chars.kt new file mode 100644 index 00000000..d76639d6 --- /dev/null +++ b/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/Utf8Chars.kt @@ -0,0 +1,88 @@ +package com.pasich.encly.core.crypto + +private const val ASCII_MAX = 0x7F +private const val TWO_BYTE_MAX = 0x7FF +private const val SIX_BITS = 0x3F +private const val SIX = 6 +private const val TWELVE = 12 +private const val EIGHTEEN = 18 +private const val TEN = 10 +private const val LEAD_2 = 0xC0 +private const val LEAD_3 = 0xE0 +private const val LEAD_4 = 0xF0 +private const val CONTINUATION = 0x80 +private const val SUPPLEMENTARY_BASE = 0x10000 +private const val THREE_BYTES = 3 +private const val FOUR_BYTES = 4 +private const val REPLACEMENT = '?'.code.toByte() + +/** + * [chars] in UTF-8, in a fresh array the caller wipes: what the JVM's `PBEKeySpec` feeds PBKDF2. No String + * and no intermediate buffer is made, so nothing but the returned array holds the encoding. An unpaired + * surrogate becomes `?`, as the JDK's encoder does. (:core:security has the same encoder for its own use; this + * module cannot depend on it.) + */ +internal fun encodeUtf8(chars: CharArray): ByteArray { + val bytes = ByteArray(utf8Size(chars)) + var out = 0 + var index = 0 + while (index < chars.size) { + val char = chars[index] + val code = char.code + val paired = char.isHighSurrogate() && index + 1 < chars.size && chars[index + 1].isLowSurrogate() + when { + code <= ASCII_MAX -> bytes[out++] = code.toByte() + + code <= TWO_BYTE_MAX -> { + bytes[out++] = (LEAD_2 or (code shr SIX)).toByte() + bytes[out++] = (CONTINUATION or (code and SIX_BITS)).toByte() + } + + paired -> { + val point = SUPPLEMENTARY_BASE + + ((code - Char.MIN_HIGH_SURROGATE.code) shl TEN) + + (chars[index + 1].code - Char.MIN_LOW_SURROGATE.code) + bytes[out++] = (LEAD_4 or (point shr EIGHTEEN)).toByte() + bytes[out++] = (CONTINUATION or ((point shr TWELVE) and SIX_BITS)).toByte() + bytes[out++] = (CONTINUATION or ((point shr SIX) and SIX_BITS)).toByte() + bytes[out++] = (CONTINUATION or (point and SIX_BITS)).toByte() + index++ + } + + char.isSurrogate() -> bytes[out++] = REPLACEMENT + + else -> { + bytes[out++] = (LEAD_3 or (code shr TWELVE)).toByte() + bytes[out++] = (CONTINUATION or ((code shr SIX) and SIX_BITS)).toByte() + bytes[out++] = (CONTINUATION or (code and SIX_BITS)).toByte() + } + } + index++ + } + return bytes +} + +private fun utf8Size(chars: CharArray): Int { + var size = 0 + var index = 0 + while (index < chars.size) { + val char = chars[index] + val code = char.code + size += when { + code <= ASCII_MAX -> 1 + + code <= TWO_BYTE_MAX -> 2 + + char.isHighSurrogate() && index + 1 < chars.size && chars[index + 1].isLowSurrogate() -> { + index++ + FOUR_BYTES + } + + char.isSurrogate() -> 1 + + else -> THREE_BYTES + } + index++ + } + return size +} diff --git a/core/crypto/src/iosMain/swift/EnclyAesGcm.swift b/core/crypto/src/iosMain/swift/EnclyAesGcm.swift new file mode 100644 index 00000000..c718021f --- /dev/null +++ b/core/crypto/src/iosMain/swift/EnclyAesGcm.swift @@ -0,0 +1,98 @@ +import CryptoKit +import Foundation + +// AES-256-GCM and P-256 point decoding for Kotlin/Native. CommonCrypto has no public GCM and CryptoKit is +// Swift-only, so this file is the one Swift seam of the module: three C-callable functions (declared in +// enclyaesgcm.h) over CryptoKit's AES.GCM and P256. Everything else is Kotlin. Keys, nonces and data arrive as raw pointers into the +// caller's arrays and results are written into caller-owned buffers, so the Kotlin side can wipe them. +// Fixed: 32-byte key, 12-byte nonce, 16-byte tag. + +private let keyBytes = 32 +private let nonceBytes = 12 +private let tagBytes = 16 + +// Kotlin passes no pointer for an empty array. A buffer view with a nil base traps inside Foundation's +// ContiguousBytes on iOS, so an empty input is a zero-length view of this byte instead. +private let emptyInput = UnsafeMutablePointer.allocate(capacity: 1) + +private func view(_ pointer: UnsafePointer?, _ count: Int) -> UnsafeRawBufferPointer { + UnsafeRawBufferPointer(start: pointer ?? UnsafePointer(emptyInput), count: count) +} + +/// Writes `ciphertext || tag` (plaintextLength + 16 bytes) into `out`. Returns 0, or -1 when CryptoKit refuses. +@_cdecl("encly_aes_gcm_seal") +public func enclyAesGcmSeal( + _ key: UnsafePointer?, _ keyLength: Int, + _ nonce: UnsafePointer?, _ nonceLength: Int, + _ aad: UnsafePointer?, _ aadLength: Int, + _ plaintext: UnsafePointer?, _ plaintextLength: Int, + _ out: UnsafeMutablePointer? +) -> Int32 { + guard keyLength == keyBytes, nonceLength == nonceBytes, let out = out else { return -1 } // out holds at least the tag + do { + let symmetricKey = SymmetricKey(data: view(key, keyLength)) + let gcmNonce = try AES.GCM.Nonce(data: view(nonce, nonceLength)) + let box = try AES.GCM.seal( + view(plaintext, plaintextLength), using: symmetricKey, nonce: gcmNonce, + authenticating: view(aad, aadLength)) + guard box.ciphertext.count == plaintextLength, box.tag.count == tagBytes else { return -1 } + box.ciphertext.copyBytes(to: out, count: plaintextLength) + box.tag.copyBytes(to: out + plaintextLength, count: tagBytes) + return 0 + } catch { + return -1 + } +} + +/// Writes the plaintext (ciphertextLength - 16 bytes) into `out`. Returns 0, or -1 when the data does not +/// authenticate (wrong key, nonce or aad, changed or truncated data). +@_cdecl("encly_aes_gcm_open") +public func enclyAesGcmOpen( + _ key: UnsafePointer?, _ keyLength: Int, + _ nonce: UnsafePointer?, _ nonceLength: Int, + _ aad: UnsafePointer?, _ aadLength: Int, + _ ciphertext: UnsafePointer?, _ ciphertextLength: Int, + _ out: UnsafeMutablePointer? +) -> Int32 { + guard keyLength == keyBytes, nonceLength == nonceBytes, ciphertextLength >= tagBytes else { return -1 } + let bodyLength = ciphertextLength - tagBytes + guard bodyLength == 0 || out != nil else { return -1 } + do { + let symmetricKey = SymmetricKey(data: view(key, keyLength)) + let gcmNonce = try AES.GCM.Nonce(data: view(nonce, nonceLength)) + let sealed = view(ciphertext, ciphertextLength) + let box = try AES.GCM.SealedBox( + nonce: gcmNonce, + ciphertext: UnsafeRawBufferPointer(rebasing: sealed[0..?, _ xLength: Int, + _ out: UnsafeMutablePointer? +) -> Int32 { + guard xLength == 32, let x = x, let out = out else { return -1 } + do { + let key = try P256.KeyAgreement.PublicKey(compactRepresentation: view(x, xLength)) + let point = key.x963Representation + guard point.count == 65 else { return -1 } + point.copyBytes(to: out, count: 65) + return 0 + } catch { + return -1 + } +} diff --git a/core/crypto/src/iosTest/kotlin/com/pasich/encly/core/crypto/P256PointsTest.kt b/core/crypto/src/iosTest/kotlin/com/pasich/encly/core/crypto/P256PointsTest.kt new file mode 100644 index 00000000..02c89140 --- /dev/null +++ b/core/crypto/src/iosTest/kotlin/com/pasich/encly/core/crypto/P256PointsTest.kt @@ -0,0 +1,48 @@ +package com.pasich.encly.core.crypto + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The P-256 point decoding behind the iOS PIN factor. */ +class P256PointsTest { + private val gx = GoldenVectors.hex("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296") + private val gy = GoldenVectors.hex("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5") + + @Test + fun theGeneratorsAbscissaDecodesToTheGeneratorOrItsMirror() { + val point = assertNotNull(P256Points.fromX(gx)) + + assertEquals(P256Points.POINT_BYTES, point.size) + assertEquals(4, point[0].toInt()) + assertContentEquals(gx, point.copyOfRange(1, 33)) + // Either y of the pair: the generator's, or p - y, whose last byte is 0xFF minus the generator's (p ends in 0xFF). + val y = point.copyOfRange(33, 65) + assertTrue(y.contentEquals(gy) || y.last() == (0xFF - (gy.last().toInt() and 0xFF)).toByte()) + } + + @Test + fun anXWithoutAPointIsRefused() { + // About half of all x have no point; of sixteen small ones both outcomes occur, and ff..ff is above the prime. + val refused = (0 until 16).count { P256Points.fromX(ByteArray(32).also { x -> x[31] = it.toByte() }) == null } + assertTrue(refused in 1..15) + assertNull(P256Points.fromX(ByteArray(32) { 0xFF.toByte() })) + } + + @Test + fun theDecodingIsDeterministic() { + val x = ByteArray(32) { (it * 7 + 3).toByte() } + val first = P256Points.fromX(x) + val second = P256Points.fromX(x) + assertContentEquals(first, second) + } + + @Test + fun aWrongLengthIsAProgrammingError() { + assertFailsWith { P256Points.fromX(ByteArray(31)) } + } +} diff --git a/core/crypto/src/iosTest/kotlin/com/pasich/encly/core/crypto/PlatformCryptoTest.kt b/core/crypto/src/iosTest/kotlin/com/pasich/encly/core/crypto/PlatformCryptoTest.kt new file mode 100644 index 00000000..5178734a --- /dev/null +++ b/core/crypto/src/iosTest/kotlin/com/pasich/encly/core/crypto/PlatformCryptoTest.kt @@ -0,0 +1,111 @@ +package com.pasich.encly.core.crypto + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The iOS seams that [CryptoTest] reaches only through [Crypto]: empty inputs, short data and the encoder. */ +class PlatformCryptoTest { + private val key = ByteArray(32) { it.toByte() } + private val nonce = ByteArray(12) { (it + 1).toByte() } + + @Test + fun anEmptyPlaintextAndAnEmptyAadSealToJustTheTagAndOpenAgain() { + val sealed = PlatformCrypto.aesGcmSeal(key, nonce, ByteArray(0), ByteArray(0)) + + assertEquals(Crypto.TAG_BYTES, sealed.size) + assertContentEquals(ByteArray(0), PlatformCrypto.aesGcmOpen(key, nonce, ByteArray(0), sealed)) + assertNull(PlatformCrypto.aesGcmOpen(key, nonce, byteArrayOf(1), sealed)) + } + + @Test + fun dataShorterThanATagIsRefusedNotCrashed() { + assertNull(PlatformCrypto.aesGcmOpen(key, nonce, ByteArray(0), ByteArray(0))) + assertNull(PlatformCrypto.aesGcmOpen(key, nonce, ByteArray(0), ByteArray(Crypto.TAG_BYTES - 1))) + } + + @Test + fun aChangedByteOrAWrongKeyDoesNotAuthenticate() { + val sealed = PlatformCrypto.aesGcmSeal(key, nonce, byteArrayOf(7), "secret".encodeToByteArray()) + val changed = sealed.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + + assertNull(PlatformCrypto.aesGcmOpen(key, nonce, byteArrayOf(7), changed)) + assertNull(PlatformCrypto.aesGcmOpen(ByteArray(32), nonce, byteArrayOf(7), sealed)) + assertContentEquals("secret".encodeToByteArray(), PlatformCrypto.aesGcmOpen(key, nonce, byteArrayOf(7), sealed)) + } + + @Test + fun aProviderRefusalToSealIsACryptoExceptionNotACrash() { + // Crypto only passes 32-byte keys and 12-byte nonces; the Swift side refuses anything else. + kotlin.test.assertFailsWith { + PlatformCrypto.aesGcmSeal(ByteArray(17), nonce, ByteArray(0), ByteArray(8)) + } + assertNull(PlatformCrypto.aesGcmOpen(ByteArray(17), nonce, ByteArray(0), ByteArray(32))) + } + + @Test + fun sha256OfARangeAndOfNothing() { + val data = "xabcx".encodeToByteArray() + + assertContentEquals(GoldenVectors.sha256Abc, PlatformCrypto.sha256(data, 1, 4)) + assertContentEquals( + GoldenVectors.hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + PlatformCrypto.sha256(data, 2, 2), + ) + assertContentEquals( + GoldenVectors.hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + PlatformCrypto.sha256(ByteArray(0), 0, 0), + ) + } + + @Test + fun randomBytesAreTheRequestedSizeAndDiffer() { + assertEquals(0, PlatformCrypto.randomBytes(0).size) + val first = PlatformCrypto.randomBytes(32) + assertEquals(32, first.size) + assertFalse(first.contentEquals(PlatformCrypto.randomBytes(32))) + assertNotEquals(0L, Crypto.randomLong() or Crypto.randomLong()) + } + + @Test + fun constantTimeEqualsComparesLengthAndContent() { + assertTrue(PlatformCrypto.constantTimeEquals(byteArrayOf(1, 2), byteArrayOf(1, 2))) + assertTrue(PlatformCrypto.constantTimeEquals(ByteArray(0), ByteArray(0))) + assertFalse(PlatformCrypto.constantTimeEquals(byteArrayOf(1, 2), byteArrayOf(1, 3))) + assertFalse(PlatformCrypto.constantTimeEquals(byteArrayOf(1, 2), byteArrayOf(1))) + } + + @Test + fun theEncoderMatchesTheJdkOnEveryClassOfCharacter() { + // Same bytes the JDK's UTF-8 encoder produces: one to four bytes, a pair, and `?` for a lone surrogate. + assertContentEquals("aé汉😀".encodeToByteArray(), encodeUtf8("aé汉😀".toCharArray())) + assertContentEquals(byteArrayOf(0x61, 0x3F, 0x62), encodeUtf8(charArrayOf('a', '\uD800', 'b'))) + assertContentEquals(byteArrayOf(0x3F), encodeUtf8(charArrayOf('\uDC00'))) + assertContentEquals(byteArrayOf(0x3F, 0x61), encodeUtf8(charArrayOf('\uD800', 'a'))) + assertContentEquals(byteArrayOf(0x3F), encodeUtf8(charArrayOf('\uD800'))) + assertContentEquals(ByteArray(0), encodeUtf8(CharArray(0))) + } + + @Test + fun aPasswordOfCharactersAndOfTheirUtf8BytesDeriveTheSameKey() { + val salt = ByteArray(16) { it.toByte() } + + assertContentEquals( + PlatformCrypto.pbkdf2HmacSha256("pin é汉😀".encodeToByteArray(), salt, 3, 32), + PlatformCrypto.pbkdf2HmacSha256("pin é汉😀".toCharArray(), salt, 3, 32), + ) + // An empty password and a lone surrogate (`?`) are still passwords; the first value is what the JCA gives. + assertContentEquals( + GoldenVectors.hex("8cb314dea8659d2604b8cba0b44e0399deef372bc2ab48585f008fb7686be089"), + PlatformCrypto.pbkdf2HmacSha256(CharArray(0), byteArrayOf(1, 2, 3), 2, 32), + ) + assertContentEquals( + PlatformCrypto.pbkdf2HmacSha256("?".encodeToByteArray(), salt, 1, 32), + PlatformCrypto.pbkdf2HmacSha256(charArrayOf('\uD800'), salt, 1, 32), + ) + } +} diff --git a/core/crypto/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/crypto/JcaCrypto.kt b/core/crypto/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/crypto/JcaCrypto.kt new file mode 100644 index 00000000..fa1043e4 --- /dev/null +++ b/core/crypto/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/crypto/JcaCrypto.kt @@ -0,0 +1,106 @@ +package com.pasich.encly.core.crypto + +import com.pasich.encly.core.security.SensitiveDataCleaner +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.security.GeneralSecurityException +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +/** + * The platform (JCA) implementation behind [Crypto], and the only file here that imports + * `javax.crypto` / `java.security`. Every call builds a fresh `Cipher`, `Mac` or key factory and + * drops it: nothing is cached or pooled, so no key outlives the call inside this code. Each + * intermediate array that holds key material is zeroized before returning. + */ +internal object JcaCrypto { + private const val AES_GCM = "AES/GCM/NoPadding" + private const val HMAC = "HmacSHA256" + private const val PBKDF2 = "PBKDF2WithHmacSHA256" + + private val random = SecureRandom() + + fun randomBytes(size: Int): ByteArray = ByteArray(size).also(random::nextBytes) + + fun sha256(data: ByteArray, startIndex: Int, endIndex: Int): ByteArray = + MessageDigest.getInstance("SHA-256").apply { update(data, startIndex, endIndex - startIndex) }.digest() + + /** RFC 5869 for one output block: `T(1) = HMAC(PRK, info ‖ 0x01)`, PRK and T wiped. */ + fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + val mac = Mac.getInstance(HMAC) + mac.init(SecretKeySpec(salt, HMAC)) + val prk = mac.doFinal(ikm) + return try { + mac.init(SecretKeySpec(prk, HMAC)) + mac.update(info) + mac.update(0x01.toByte()) + val block = mac.doFinal() + try { + block.copyOf(length) + } finally { + SensitiveDataCleaner.clear(block) + } + } finally { + SensitiveDataCleaner.clear(prk) + } + } + + /** [password] goes to the platform KDF as characters; the spec's copy is cleared, no String is made. */ + fun pbkdf2HmacSha256(password: CharArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + val spec = PBEKeySpec(password, salt, iterations, length * Byte.SIZE_BITS) + return try { + SecretKeyFactory.getInstance(PBKDF2).generateSecret(spec).encoded + } finally { + spec.clearPassword() + } + } + + /** [password] as UTF-8 text; the characters made from it are wiped. */ + fun pbkdf2HmacSha256(password: ByteArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + val buffer = Charsets.UTF_8.decode(ByteBuffer.wrap(password)) + val chars = CharArray(buffer.remaining()) + buffer.get(chars) + clearBackingArray(buffer) + return try { + pbkdf2HmacSha256(chars, salt, iterations, length) + } finally { + SensitiveDataCleaner.clear(chars) + } + } + + /** Wipes the characters the decoder left behind, when [buffer] is backed by an array it can reach. */ + internal fun clearBackingArray(buffer: CharBuffer) { + if (buffer.hasArray()) SensitiveDataCleaner.clear(buffer.array()) + } + + fun aesGcmSeal(key: ByteArray, nonce: ByteArray, aad: ByteArray, plaintext: ByteArray): ByteArray = try { + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce)) + cipher.updateAAD(aad) + cipher.doFinal(plaintext) + } catch (e: GeneralSecurityException) { + throw CryptoException(e) + } + + /** Null when the data does not authenticate (an `AEADBadTagException`, or any other JCA refusal). */ + fun aesGcmOpen(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray? = try { + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce)) + cipher.updateAAD(aad) + cipher.doFinal(ciphertext) + } catch (_: GeneralSecurityException) { + null + } + + fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean = MessageDigest.isEqual(a, b) + + private const val TAG_BITS = Crypto.TAG_BYTES * Byte.SIZE_BITS +} + +internal actual typealias PlatformCrypto = JcaCrypto diff --git a/core/crypto/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/crypto/JcaCryptoTest.kt b/core/crypto/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/crypto/JcaCryptoTest.kt new file mode 100644 index 00000000..2c3a16d0 --- /dev/null +++ b/core/crypto/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/crypto/JcaCryptoTest.kt @@ -0,0 +1,41 @@ +package com.pasich.encly.core.crypto + +import java.nio.CharBuffer +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** The JCA seam: what [Crypto] cannot reach because it checks its arguments first. */ +class JcaCryptoTest { + private val nonce = ByteArray(12) + private val aad = ByteArray(4) + + @Test + fun aProviderRefusalToSealIsACryptoExceptionNotACrash() { + // Crypto only passes 32-byte keys; the provider itself refuses any other AES key length. + val failure = assertFailsWith { JcaCrypto.aesGcmSeal(ByteArray(17), nonce, aad, ByteArray(8)) } + + assertTrue(failure.cause is java.security.GeneralSecurityException) + } + + @Test + fun aProviderRefusalToOpenIsNullNotACrash() { + assertTrue(JcaCrypto.aesGcmOpen(ByteArray(17), nonce, aad, ByteArray(32)) == null) + } + + @Test + fun theDecodedCharactersAreWipedWhenTheBufferHasAnArray() { + val chars = charArrayOf('a', 'b', 'c') + + JcaCrypto.clearBackingArray(CharBuffer.wrap(chars)) + + assertContentEquals(CharArray(3), chars) + } + + @Test + fun aBufferWithoutAnArrayIsLeftAlone() { + // A read-only buffer (here over a String) has no accessible array; nothing may throw. + JcaCrypto.clearBackingArray(CharBuffer.wrap("abc")) + } +} diff --git a/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.def b/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.def new file mode 100644 index 00000000..362f4df9 --- /dev/null +++ b/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.def @@ -0,0 +1,5 @@ +package = com.pasich.encly.core.crypto.swift +headers = enclyaesgcm.h +staticLibraries = libenclyaesgcm.a +# The Swift runtime and CryptoKit come with the OS; ld resolves an absolute -L path inside the SDK. +linkerOpts = -L/usr/lib/swift diff --git a/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.h b/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.h new file mode 100644 index 00000000..8bd5d7a8 --- /dev/null +++ b/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.h @@ -0,0 +1,27 @@ +#ifndef ENCLY_AES_GCM_H +#define ENCLY_AES_GCM_H + +#include +#include + +/* Implemented in Swift over CryptoKit (EnclyAesGcm.swift). 0 on success, -1 on failure. */ + +/* out receives plaintext_length + 16 bytes: the ciphertext followed by the tag. */ +int32_t encly_aes_gcm_seal(const uint8_t *key, ptrdiff_t key_length, + const uint8_t *nonce, ptrdiff_t nonce_length, + const uint8_t *aad, ptrdiff_t aad_length, + const uint8_t *plaintext, ptrdiff_t plaintext_length, + uint8_t *out); + +/* out receives ciphertext_length - 16 bytes of plaintext. -1 also when the tag does not verify. */ +int32_t encly_aes_gcm_open(const uint8_t *key, ptrdiff_t key_length, + const uint8_t *nonce, ptrdiff_t nonce_length, + const uint8_t *aad, ptrdiff_t aad_length, + const uint8_t *ciphertext, ptrdiff_t ciphertext_length, + uint8_t *out); + +/* x: 32 bytes, big endian. out receives the 65-byte uncompressed point (04 || X || Y). -1 when x is not the + abscissa of a P-256 point. */ +int32_t encly_p256_point_from_x(const uint8_t *x, ptrdiff_t x_length, uint8_t *out); + +#endif diff --git a/core/database/.gitignore b/core/database/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/core/database/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/database/api/android/database.api b/core/database/api/android/database.api new file mode 100644 index 00000000..2d17441a --- /dev/null +++ b/core/database/api/android/database.api @@ -0,0 +1,535 @@ +public final class com/pasich/encly/core/security/cipher/SQLCipherUtils { + public static final field INSTANCE Lcom/pasich/encly/core/security/cipher/SQLCipherUtils; + public final fun getDatabaseState (Lkotlinx/io/files/Path;)Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; +} + +public final class com/pasich/encly/core/security/cipher/SQLCipherUtils$State : java/lang/Enum { + public static final field DOES_NOT_EXIST Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; + public static final field ENCRYPTED Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; + public static final field UNENCRYPTED Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; + public static fun values ()[Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; +} + +public final class com/pasich/encly/data/database/AndroidDatabasePlatform : com/pasich/encly/data/database/DatabasePlatform { + public fun (Landroid/content/Context;)V + public fun databaseBuilder (Lkotlinx/io/files/Path;)Landroidx/room/RoomDatabase$Builder; + public fun databasePath (Lcom/pasich/encly/data/database/VaultFile;)Lkotlinx/io/files/Path; +} + +public abstract class com/pasich/encly/data/database/AppDatabase : androidx/room/RoomDatabase { + public fun ()V + public abstract fun backupDao ()Lcom/pasich/encly/data/database/dao/BackupDao; + public abstract fun notesDao ()Lcom/pasich/encly/data/database/dao/NotesDao; + public abstract fun tagsDao ()Lcom/pasich/encly/data/database/dao/TagsDao; + public abstract fun tasksDao ()Lcom/pasich/encly/data/database/dao/TasksDao; +} + +public final class com/pasich/encly/data/database/AppDatabaseConstructor : androidx/room/RoomDatabaseConstructor { + public static final field INSTANCE Lcom/pasich/encly/data/database/AppDatabaseConstructor; + public synthetic fun initialize ()Landroidx/room/RoomDatabase; + public fun initialize ()Lcom/pasich/encly/data/database/AppDatabase; +} + +public final class com/pasich/encly/data/database/AppDatabaseKt { + public static final field DB_VERSION I +} + +public final class com/pasich/encly/data/database/AppDatabase_Impl : com/pasich/encly/data/database/AppDatabase { + public fun ()V + public fun backupDao ()Lcom/pasich/encly/data/database/dao/BackupDao; + public fun clearAllTables ()V + public fun createAutoMigrations (Ljava/util/Map;)Ljava/util/List; + public synthetic fun createOpenDelegate ()Landroidx/room/RoomOpenDelegateMarker; + public fun getRequiredAutoMigrationSpecClasses ()Ljava/util/Set; + public fun notesDao ()Lcom/pasich/encly/data/database/dao/NotesDao; + public fun tagsDao ()Lcom/pasich/encly/data/database/dao/TagsDao; + public fun tasksDao ()Lcom/pasich/encly/data/database/dao/TasksDao; +} + +public final class com/pasich/encly/data/database/DatabaseLease { + public fun ()V +} + +public abstract interface class com/pasich/encly/data/database/DatabasePlatform { + public abstract fun databaseBuilder (Lkotlinx/io/files/Path;)Landroidx/room/RoomDatabase$Builder; + public abstract fun databasePath (Lcom/pasich/encly/data/database/VaultFile;)Lkotlinx/io/files/Path; +} + +public abstract interface class com/pasich/encly/data/database/DatabaseProvider { + public abstract fun captureLease ()Lcom/pasich/encly/data/database/DatabaseLease; + public abstract fun getDatabase (Lcom/pasich/encly/data/database/DatabaseLease;)Lcom/pasich/encly/data/database/AppDatabase; +} + +public final class com/pasich/encly/data/database/SecureDatabaseManager : com/pasich/encly/data/database/DatabaseProvider { + public fun (Lcom/pasich/encly/data/database/DatabasePlatform;)V + public fun captureLease ()Lcom/pasich/encly/data/database/DatabaseLease; + public fun getDatabase (Lcom/pasich/encly/data/database/DatabaseLease;)Lcom/pasich/encly/data/database/AppDatabase; + public final fun hasEncryptedDatabase (Lcom/pasich/encly/data/database/VaultFile;)Z + public static synthetic fun hasEncryptedDatabase$default (Lcom/pasich/encly/data/database/SecureDatabaseManager;Lcom/pasich/encly/data/database/VaultFile;ILjava/lang/Object;)Z + public final fun isDatabaseUnlocked ()Z + public final fun reset ()V + public final fun unlockDatabase ([BZ)Z + public final fun unlockDatabase ([BZLcom/pasich/encly/data/database/VaultFile;)Z + public static synthetic fun unlockDatabase$default (Lcom/pasich/encly/data/database/SecureDatabaseManager;[BZILjava/lang/Object;)Z + public final fun wipe ()V + public final fun wipeIfClosed (Lcom/pasich/encly/data/database/VaultFile;)Z + public static synthetic fun wipeIfClosed$default (Lcom/pasich/encly/data/database/SecureDatabaseManager;Lcom/pasich/encly/data/database/VaultFile;ILjava/lang/Object;)Z +} + +public final class com/pasich/encly/data/database/SecureDatabaseManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/data/database/SecureDatabaseManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/data/database/SecureDatabaseManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/data/database/SecureDatabaseManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/data/database/DatabasePlatform;)Lcom/pasich/encly/data/database/SecureDatabaseManager; +} + +public final class com/pasich/encly/data/database/SecureDatabaseManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/data/database/SecureDatabaseManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/data/database/DatabasePlatform;)Lcom/pasich/encly/data/database/SecureDatabaseManager; +} + +public final class com/pasich/encly/data/database/VaultFile : java/lang/Enum { + public static final field DECOY Lcom/pasich/encly/data/database/VaultFile; + public static final field PRIMARY Lcom/pasich/encly/data/database/VaultFile; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/data/database/VaultFile; + public static fun values ()[Lcom/pasich/encly/data/database/VaultFile; +} + +public final class com/pasich/encly/data/database/VaultSchema { + public static final field INSTANCE Lcom/pasich/encly/data/database/VaultSchema; + public final fun createTriggers (Landroidx/sqlite/SQLiteConnection;)V + public final fun getCALLBACK ()Landroidx/room/RoomDatabase$Callback; + public final fun getMIGRATION_1_2 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_2_3 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_3_4 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_4_5 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_5_6 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_6_7 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_7_8 ()Landroidx/room/migration/Migration; + public final fun install (Landroidx/room/RoomDatabase$Builder;)Landroidx/room/RoomDatabase$Builder; +} + +public abstract interface class com/pasich/encly/data/database/dao/BackupDao { + public abstract fun allNoteTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun allNotes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun allSubtasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun allTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun allTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllNoteTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllNotes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllSubtasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertNoteTag (Lcom/pasich/encly/data/model/NoteTag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun noteTagsOf (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun tagsOfNote (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/BackupDao_Impl : com/pasich/encly/data/database/dao/BackupDao { + public static final field Companion Lcom/pasich/encly/data/database/dao/BackupDao_Impl$Companion; + public fun (Landroidx/room/RoomDatabase;)V + public fun allNoteTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun allNotes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun allSubtasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun allTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun allTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllNoteTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllNotes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllSubtasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNoteTag (Lcom/pasich/encly/data/model/NoteTag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun noteTagsOf (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun tagsOfNote (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/BackupDao_Impl$Companion { + public final fun getRequiredConverters ()Ljava/util/List; +} + +public abstract interface class com/pasich/encly/data/database/dao/NotesDao { + public fun changeNoteTags (JLjava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearNoteTags (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNoteTags (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNoteTagsExcept (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteTrashedBefore (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getAllNotes ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getAllNotesWithTags ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getArchivedNotesWithTags ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getNoteWithTags (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getNotesOfTagWithArchived (J)Lkotlinx/coroutines/flow/Flow; + public abstract fun getTrashNotes ()Lkotlinx/coroutines/flow/Flow; + public abstract fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertNoteTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNoteWithTags (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun observeNoteWithTags (J)Lkotlinx/coroutines/flow/Flow; + public abstract fun observeTagNoteCounts ()Lkotlinx/coroutines/flow/Flow; + public fun saveEdited (JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Ljava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun setNoteTags (JLjava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun stampTrashedAt (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateArchived (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateColor (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateDescription (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateEdited (JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateEditedWithAllTags (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updatePinned (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateTrashed (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/NotesDao$DefaultImpls { + public static fun changeNoteTags (Lcom/pasich/encly/data/database/dao/NotesDao;JLjava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun insertNoteWithTags (Lcom/pasich/encly/data/database/dao/NotesDao;Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun saveEdited (Lcom/pasich/encly/data/database/dao/NotesDao;JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Ljava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun setNoteTags (Lcom/pasich/encly/data/database/dao/NotesDao;JLjava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun updateEditedWithAllTags (Lcom/pasich/encly/data/database/dao/NotesDao;Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich/encly/data/database/dao/NotesDao { + public static final field Companion Lcom/pasich/encly/data/database/dao/NotesDao_Impl$Companion; + public fun (Landroidx/room/RoomDatabase;)V + public fun changeNoteTags (JLjava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun clearNoteTags (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNoteTags (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNoteTagsExcept (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTrashedBefore (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getAllNotes ()Lkotlinx/coroutines/flow/Flow; + public fun getAllNotesWithTags ()Lkotlinx/coroutines/flow/Flow; + public fun getArchivedNotesWithTags ()Lkotlinx/coroutines/flow/Flow; + public fun getNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getNoteWithTags (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getNotesOfTagWithArchived (J)Lkotlinx/coroutines/flow/Flow; + public fun getTrashNotes ()Lkotlinx/coroutines/flow/Flow; + public fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNoteTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNoteWithTags (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observeNoteWithTags (J)Lkotlinx/coroutines/flow/Flow; + public fun observeTagNoteCounts ()Lkotlinx/coroutines/flow/Flow; + public fun saveEdited (JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Ljava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun setNoteTags (JLjava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun stampTrashedAt (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateArchived (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateColor (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateDescription (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateEdited (JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateEditedWithAllTags (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updatePinned (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTrashed (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/NotesDao_Impl$Companion { + public final fun getRequiredConverters ()Ljava/util/List; +} + +public abstract interface class com/pasich/encly/data/database/dao/TagsDao { + public abstract fun addTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun addTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTagDetachingNotes (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun detachNotesFromTag (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getTags ()Lkotlinx/coroutines/flow/Flow; + public abstract fun updateTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TagsDao$DefaultImpls { + public static fun deleteTagDetachingNotes (Lcom/pasich/encly/data/database/dao/TagsDao;Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TagsDao_Impl : com/pasich/encly/data/database/dao/TagsDao { + public static final field Companion Lcom/pasich/encly/data/database/dao/TagsDao_Impl$Companion; + public fun (Landroidx/room/RoomDatabase;)V + public fun addTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun addTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTagDetachingNotes (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun detachNotesFromTag (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getTags ()Lkotlinx/coroutines/flow/Flow; + public fun updateTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TagsDao_Impl$Companion { + public final fun getRequiredConverters ()Ljava/util/List; +} + +public abstract interface class com/pasich/encly/data/database/dao/TasksDao { + public fun appendSubtask (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllCompletedTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteSubtaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteSubtasksExcept (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteTaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getActiveTasksCount ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getAllActiveTasks ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getAllCompletedTasks ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getAllTasks ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getCompletedTasksCount ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getSubtasks (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getTaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun nextSubtaskPosition (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun observeSubtasks ()Lkotlinx/coroutines/flow/Flow; + public abstract fun renameSubtask (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun replaceSubtasks (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun restoreSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun restoreTask (Lcom/pasich/encly/data/model/Task;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setSubtaskCompleted (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun shiftSubtasksFrom (JILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateTaskStatus (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TasksDao$DefaultImpls { + public static fun appendSubtask (Lcom/pasich/encly/data/database/dao/TasksDao;JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun replaceSubtasks (Lcom/pasich/encly/data/database/dao/TasksDao;JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun restoreSubtask (Lcom/pasich/encly/data/database/dao/TasksDao;Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun restoreTask (Lcom/pasich/encly/data/database/dao/TasksDao;Lcom/pasich/encly/data/model/Task;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TasksDao_Impl : com/pasich/encly/data/database/dao/TasksDao { + public static final field Companion Lcom/pasich/encly/data/database/dao/TasksDao_Impl$Companion; + public fun (Landroidx/room/RoomDatabase;)V + public fun appendSubtask (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllCompletedTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteSubtaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteSubtasksExcept (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getActiveTasksCount ()Lkotlinx/coroutines/flow/Flow; + public fun getAllActiveTasks ()Lkotlinx/coroutines/flow/Flow; + public fun getAllCompletedTasks ()Lkotlinx/coroutines/flow/Flow; + public fun getAllTasks ()Lkotlinx/coroutines/flow/Flow; + public fun getCompletedTasksCount ()Lkotlinx/coroutines/flow/Flow; + public fun getSubtasks (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getTaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun nextSubtaskPosition (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observeSubtasks ()Lkotlinx/coroutines/flow/Flow; + public fun renameSubtask (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun replaceSubtasks (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun restoreSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun restoreTask (Lcom/pasich/encly/data/model/Task;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun setSubtaskCompleted (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shiftSubtasksFrom (JILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTaskStatus (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TasksDao_Impl$Companion { + public final fun getRequiredConverters ()Ljava/util/List; +} + +public final class com/pasich/encly/data/model/Note { + public static final field Companion Lcom/pasich/encly/data/model/Note$Companion; + public static final field NO_COLOR Ljava/lang/String; + public fun ()V + public fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/Set;)V + public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component10 ()Z + public final fun component11 ()Z + public final fun component12 ()Ljava/lang/String; + public final fun component13 ()Ljava/lang/Integer; + public final fun component14 ()Ljava/lang/Integer; + public final fun component15 ()Ljava/lang/Long; + public final fun component16 ()Ljava/util/Set; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()J + public final fun component7 ()Z + public final fun component8 ()Z + public final fun component9 ()Ljava/lang/String; + public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/Set;)Lcom/pasich/encly/data/model/Note; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/Note;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/Set;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; + public fun equals (Ljava/lang/Object;)Z + public final fun getColor ()Ljava/lang/String; + public final fun getDate ()J + public final fun getDateCreate ()J + public final fun getDescription ()Ljava/lang/String; + public final fun getId ()J + public final fun getLineSpacing ()Ljava/lang/Integer; + public final fun getTagIds ()Ljava/util/Set; + public final fun getTextSize ()Ljava/lang/Integer; + public final fun getTitle ()Ljava/lang/String; + public final fun getTrashedAt ()Ljava/lang/Long; + public final fun getUid ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public final fun hasContentChanged (Ljava/lang/String;Ljava/lang/String;)Z + public fun hashCode ()I + public final fun isArchived ()Z + public final fun isChecked ()Z + public final fun isEmpty ()Z + public final fun isPinned ()Z + public final fun isTrash ()Z + public final fun setArchived (Z)V + public final fun setChecked (Z)V + public final fun setColor (Ljava/lang/String;)V + public final fun setDate (J)V + public final fun setDateCreate (J)V + public final fun setDescription (Ljava/lang/String;)V + public final fun setId (J)V + public final fun setLineSpacing (Ljava/lang/Integer;)V + public final fun setPinned (Z)V + public final fun setTagIds (Ljava/util/Set;)V + public final fun setTextSize (Ljava/lang/Integer;)V + public final fun setTitle (Ljava/lang/String;)V + public final fun setTrash (Z)V + public final fun setTrashedAt (Ljava/lang/Long;)V + public final fun setUid (Ljava/lang/String;)V + public final fun setValue (Ljava/lang/String;)V + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Note$Companion { + public final fun new (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)Lcom/pasich/encly/data/model/Note; + public static synthetic fun new$default (Lcom/pasich/encly/data/model/Note$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; +} + +public final class com/pasich/encly/data/model/NoteTag { + public fun (JJ)V + public final fun component1 ()J + public final fun component2 ()J + public final fun copy (JJ)Lcom/pasich/encly/data/model/NoteTag; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/NoteTag;JJILjava/lang/Object;)Lcom/pasich/encly/data/model/NoteTag; + public fun equals (Ljava/lang/Object;)Z + public final fun getNoteId ()J + public final fun getTagId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/NoteWithTags { + public fun (Lcom/pasich/encly/data/model/Note;Ljava/util/List;)V + public final fun component1 ()Lcom/pasich/encly/data/model/Note; + public final fun component2 ()Ljava/util/List; + public final fun copy (Lcom/pasich/encly/data/model/Note;Ljava/util/List;)Lcom/pasich/encly/data/model/NoteWithTags; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/NoteWithTags;Lcom/pasich/encly/data/model/Note;Ljava/util/List;ILjava/lang/Object;)Lcom/pasich/encly/data/model/NoteWithTags; + public fun equals (Ljava/lang/Object;)Z + public final fun getNote ()Lcom/pasich/encly/data/model/Note; + public final fun getTags ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Subtask { + public fun (JJLjava/lang/String;ZILjava/lang/String;)V + public synthetic fun (JJLjava/lang/String;ZILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()I + public final fun component6 ()Ljava/lang/String; + public final fun copy (JJLjava/lang/String;ZILjava/lang/String;)Lcom/pasich/encly/data/model/Subtask; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/Subtask;JJLjava/lang/String;ZILjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Subtask; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()J + public final fun getPosition ()I + public final fun getTaskId ()J + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Tag { + public fun ()V + public fun (JLjava/lang/String;ZILjava/lang/String;)V + public synthetic fun (JLjava/lang/String;ZILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun component4 ()I + public final fun component5 ()Ljava/lang/String; + public final fun copy (JLjava/lang/String;ZILjava/lang/String;)Lcom/pasich/encly/data/model/Tag; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/Tag;JLjava/lang/String;ZILjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Tag; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()J + public final fun getNameTag ()Ljava/lang/String; + public final fun getPosition ()I + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isVisible ()Z + public final fun setId (J)V + public final fun setNameTag (Ljava/lang/String;)V + public final fun setPosition (I)V + public final fun setUid (Ljava/lang/String;)V + public final fun setVisible (Z)V + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/TagNoteCount { + public fun (JI)V + public final fun component1 ()J + public final fun component2 ()I + public final fun copy (JI)Lcom/pasich/encly/data/model/TagNoteCount; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/TagNoteCount;JIILjava/lang/Object;)Lcom/pasich/encly/data/model/TagNoteCount; + public fun equals (Ljava/lang/Object;)Z + public final fun getCount ()I + public final fun getTagId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Task { + public static final field Companion Lcom/pasich/encly/data/model/Task$Companion; + public fun (JLjava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/Long;ILjava/lang/String;)V + public synthetic fun (JLjava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/Long;ILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component10 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()J + public final fun component6 ()Ljava/lang/Long; + public final fun component7 ()I + public final fun component8 ()Ljava/lang/Long; + public final fun component9 ()I + public final fun copy (JLjava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/Long;ILjava/lang/String;)Lcom/pasich/encly/data/model/Task; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/Task;JLjava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/Long;ILjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Task; + public fun equals (Ljava/lang/Object;)Z + public final fun getCategoryId ()Ljava/lang/Long; + public final fun getCompletedDate ()Ljava/lang/Long; + public final fun getCreatedDate ()J + public final fun getDescription ()Ljava/lang/String; + public final fun getId ()J + public final fun getPosition ()I + public final fun getPriority ()I + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public final fun setPosition (I)V + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Task$Companion { + public final fun new (Ljava/lang/String;Ljava/lang/String;ILjava/lang/Long;)Lcom/pasich/encly/data/model/Task; + public static synthetic fun new$default (Lcom/pasich/encly/data/model/Task$Companion;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Long;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Task; +} + diff --git a/core/database/api/database.klib.api b/core/database/api/database.klib.api new file mode 100644 index 00000000..e3002720 --- /dev/null +++ b/core/database/api/database.klib.api @@ -0,0 +1,571 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final enum class com.pasich.encly.data.database/VaultFile : kotlin/Enum { // com.pasich.encly.data.database/VaultFile|null[0] + enum entry DECOY // com.pasich.encly.data.database/VaultFile.DECOY|null[0] + enum entry PRIMARY // com.pasich.encly.data.database/VaultFile.PRIMARY|null[0] + + final val entries // com.pasich.encly.data.database/VaultFile.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.data.database/VaultFile.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.data.database/VaultFile // com.pasich.encly.data.database/VaultFile.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.data.database/VaultFile.values|values#static(){}[0] +} + +abstract interface com.pasich.encly.data.database.dao/BackupDao { // com.pasich.encly.data.database.dao/BackupDao|null[0] + abstract suspend fun allNoteTags(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao.allNoteTags|allNoteTags(){}[0] + abstract suspend fun allNotes(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao.allNotes|allNotes(){}[0] + abstract suspend fun allSubtasks(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao.allSubtasks|allSubtasks(){}[0] + abstract suspend fun allTags(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao.allTags|allTags(){}[0] + abstract suspend fun allTasks(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao.allTasks|allTasks(){}[0] + abstract suspend fun deleteAllNoteTags() // com.pasich.encly.data.database.dao/BackupDao.deleteAllNoteTags|deleteAllNoteTags(){}[0] + abstract suspend fun deleteAllNotes() // com.pasich.encly.data.database.dao/BackupDao.deleteAllNotes|deleteAllNotes(){}[0] + abstract suspend fun deleteAllSubtasks() // com.pasich.encly.data.database.dao/BackupDao.deleteAllSubtasks|deleteAllSubtasks(){}[0] + abstract suspend fun deleteAllTags() // com.pasich.encly.data.database.dao/BackupDao.deleteAllTags|deleteAllTags(){}[0] + abstract suspend fun deleteAllTasks() // com.pasich.encly.data.database.dao/BackupDao.deleteAllTasks|deleteAllTasks(){}[0] + abstract suspend fun insertNote(com.pasich.encly.data.model/Note): kotlin/Long // com.pasich.encly.data.database.dao/BackupDao.insertNote|insertNote(com.pasich.encly.data.model.Note){}[0] + abstract suspend fun insertNoteTag(com.pasich.encly.data.model/NoteTag) // com.pasich.encly.data.database.dao/BackupDao.insertNoteTag|insertNoteTag(com.pasich.encly.data.model.NoteTag){}[0] + abstract suspend fun insertSubtask(com.pasich.encly.data.model/Subtask): kotlin/Long // com.pasich.encly.data.database.dao/BackupDao.insertSubtask|insertSubtask(com.pasich.encly.data.model.Subtask){}[0] + abstract suspend fun insertTag(com.pasich.encly.data.model/Tag): kotlin/Long // com.pasich.encly.data.database.dao/BackupDao.insertTag|insertTag(com.pasich.encly.data.model.Tag){}[0] + abstract suspend fun insertTask(com.pasich.encly.data.model/Task): kotlin/Long // com.pasich.encly.data.database.dao/BackupDao.insertTask|insertTask(com.pasich.encly.data.model.Task){}[0] + abstract suspend fun noteTagsOf(kotlin/Long): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao.noteTagsOf|noteTagsOf(kotlin.Long){}[0] + abstract suspend fun tagsOfNote(kotlin/Long): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao.tagsOfNote|tagsOfNote(kotlin.Long){}[0] +} + +abstract interface com.pasich.encly.data.database.dao/NotesDao { // com.pasich.encly.data.database.dao/NotesDao|null[0] + abstract fun getAllNotes(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getAllNotes|getAllNotes(){}[0] + abstract fun getAllNotesWithTags(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getAllNotesWithTags|getAllNotesWithTags(){}[0] + abstract fun getArchivedNotesWithTags(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getArchivedNotesWithTags|getArchivedNotesWithTags(){}[0] + abstract fun getNotesOfTagWithArchived(kotlin/Long): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getNotesOfTagWithArchived|getNotesOfTagWithArchived(kotlin.Long){}[0] + abstract fun getTrashNotes(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getTrashNotes|getTrashNotes(){}[0] + abstract fun observeNoteWithTags(kotlin/Long): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/NotesDao.observeNoteWithTags|observeNoteWithTags(kotlin.Long){}[0] + abstract fun observeTagNoteCounts(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.observeTagNoteCounts|observeTagNoteCounts(){}[0] + abstract suspend fun clearNoteTags(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.clearNoteTags|clearNoteTags(kotlin.Long){}[0] + abstract suspend fun deleteNoteById(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.deleteNoteById|deleteNoteById(kotlin.Long){}[0] + abstract suspend fun deleteNoteTags(kotlin/Long, kotlin.collections/List): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.deleteNoteTags|deleteNoteTags(kotlin.Long;kotlin.collections.List){}[0] + abstract suspend fun deleteNoteTagsExcept(kotlin/Long, kotlin.collections/List): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.deleteNoteTagsExcept|deleteNoteTagsExcept(kotlin.Long;kotlin.collections.List){}[0] + abstract suspend fun deleteTrashedBefore(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.deleteTrashedBefore|deleteTrashedBefore(kotlin.Long){}[0] + abstract suspend fun getNoteById(kotlin/Long): com.pasich.encly.data.model/Note? // com.pasich.encly.data.database.dao/NotesDao.getNoteById|getNoteById(kotlin.Long){}[0] + abstract suspend fun getNoteWithTags(kotlin/Long): com.pasich.encly.data.model/NoteWithTags? // com.pasich.encly.data.database.dao/NotesDao.getNoteWithTags|getNoteWithTags(kotlin.Long){}[0] + abstract suspend fun insertNote(com.pasich.encly.data.model/Note): kotlin/Long // com.pasich.encly.data.database.dao/NotesDao.insertNote|insertNote(com.pasich.encly.data.model.Note){}[0] + abstract suspend fun insertNoteTags(kotlin.collections/List) // com.pasich.encly.data.database.dao/NotesDao.insertNoteTags|insertNoteTags(kotlin.collections.List){}[0] + abstract suspend fun stampTrashedAt(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.stampTrashedAt|stampTrashedAt(kotlin.Long){}[0] + abstract suspend fun updateArchived(kotlin/Long, kotlin/Boolean): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateArchived|updateArchived(kotlin.Long;kotlin.Boolean){}[0] + abstract suspend fun updateColor(kotlin/Long, kotlin/String): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateColor|updateColor(kotlin.Long;kotlin.String){}[0] + abstract suspend fun updateDescription(kotlin/Long, kotlin/String): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateDescription|updateDescription(kotlin.Long;kotlin.String){}[0] + abstract suspend fun updateEdited(kotlin/Long, kotlin/String, kotlin/String, kotlin/Boolean, kotlin/String, kotlin/Long, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/String, kotlin/Boolean, kotlin/Int?, kotlin/Boolean, kotlin/Int?): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateEdited|updateEdited(kotlin.Long;kotlin.String;kotlin.String;kotlin.Boolean;kotlin.String;kotlin.Long;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.Boolean;kotlin.Int?){}[0] + abstract suspend fun updateNote(com.pasich.encly.data.model/Note): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateNote|updateNote(com.pasich.encly.data.model.Note){}[0] + abstract suspend fun updatePinned(kotlin/Long, kotlin/Boolean): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updatePinned|updatePinned(kotlin.Long;kotlin.Boolean){}[0] + abstract suspend fun updateTrashed(kotlin/Long, kotlin/Boolean, kotlin/Long?): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateTrashed|updateTrashed(kotlin.Long;kotlin.Boolean;kotlin.Long?){}[0] + open suspend fun changeNoteTags(kotlin/Long, kotlin.collections/Collection, kotlin.collections/Collection) // com.pasich.encly.data.database.dao/NotesDao.changeNoteTags|changeNoteTags(kotlin.Long;kotlin.collections.Collection;kotlin.collections.Collection){}[0] + open suspend fun insertNoteWithTags(com.pasich.encly.data.model/Note): kotlin/Long // com.pasich.encly.data.database.dao/NotesDao.insertNoteWithTags|insertNoteWithTags(com.pasich.encly.data.model.Note){}[0] + open suspend fun saveEdited(kotlin/Long, kotlin/String, kotlin/String, kotlin/Boolean, kotlin/String, kotlin/Long, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/String, kotlin/Boolean, kotlin/Int?, kotlin/Boolean, kotlin/Int?, kotlin.collections/Collection, kotlin.collections/Collection): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.saveEdited|saveEdited(kotlin.Long;kotlin.String;kotlin.String;kotlin.Boolean;kotlin.String;kotlin.Long;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.Boolean;kotlin.Int?;kotlin.collections.Collection;kotlin.collections.Collection){}[0] + open suspend fun setNoteTags(kotlin/Long, kotlin.collections/Collection) // com.pasich.encly.data.database.dao/NotesDao.setNoteTags|setNoteTags(kotlin.Long;kotlin.collections.Collection){}[0] + open suspend fun updateEditedWithAllTags(com.pasich.encly.data.model/Note): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateEditedWithAllTags|updateEditedWithAllTags(com.pasich.encly.data.model.Note){}[0] +} + +abstract interface com.pasich.encly.data.database.dao/TagsDao { // com.pasich.encly.data.database.dao/TagsDao|null[0] + abstract fun getTags(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TagsDao.getTags|getTags(){}[0] + abstract suspend fun addTag(com.pasich.encly.data.model/Tag): kotlin/Long // com.pasich.encly.data.database.dao/TagsDao.addTag|addTag(com.pasich.encly.data.model.Tag){}[0] + abstract suspend fun addTags(kotlin.collections/List): kotlin.collections/List // com.pasich.encly.data.database.dao/TagsDao.addTags|addTags(kotlin.collections.List){}[0] + abstract suspend fun deleteTag(com.pasich.encly.data.model/Tag): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao.deleteTag|deleteTag(com.pasich.encly.data.model.Tag){}[0] + abstract suspend fun detachNotesFromTag(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao.detachNotesFromTag|detachNotesFromTag(kotlin.Long){}[0] + abstract suspend fun updateTag(com.pasich.encly.data.model/Tag): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao.updateTag|updateTag(com.pasich.encly.data.model.Tag){}[0] + abstract suspend fun updateTags(kotlin.collections/List): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao.updateTags|updateTags(kotlin.collections.List){}[0] + open suspend fun deleteTagDetachingNotes(com.pasich.encly.data.model/Tag): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao.deleteTagDetachingNotes|deleteTagDetachingNotes(com.pasich.encly.data.model.Tag){}[0] +} + +abstract interface com.pasich.encly.data.database.dao/TasksDao { // com.pasich.encly.data.database.dao/TasksDao|null[0] + abstract fun getActiveTasksCount(): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/TasksDao.getActiveTasksCount|getActiveTasksCount(){}[0] + abstract fun getAllActiveTasks(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TasksDao.getAllActiveTasks|getAllActiveTasks(){}[0] + abstract fun getAllCompletedTasks(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TasksDao.getAllCompletedTasks|getAllCompletedTasks(){}[0] + abstract fun getAllTasks(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TasksDao.getAllTasks|getAllTasks(){}[0] + abstract fun getCompletedTasksCount(): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/TasksDao.getCompletedTasksCount|getCompletedTasksCount(){}[0] + abstract fun observeSubtasks(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TasksDao.observeSubtasks|observeSubtasks(){}[0] + abstract suspend fun deleteAllCompletedTasks(): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.deleteAllCompletedTasks|deleteAllCompletedTasks(){}[0] + abstract suspend fun deleteSubtaskById(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.deleteSubtaskById|deleteSubtaskById(kotlin.Long){}[0] + abstract suspend fun deleteSubtasksExcept(kotlin/Long, kotlin.collections/List): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.deleteSubtasksExcept|deleteSubtasksExcept(kotlin.Long;kotlin.collections.List){}[0] + abstract suspend fun deleteTask(com.pasich.encly.data.model/Task): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.deleteTask|deleteTask(com.pasich.encly.data.model.Task){}[0] + abstract suspend fun deleteTaskById(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.deleteTaskById|deleteTaskById(kotlin.Long){}[0] + abstract suspend fun getSubtasks(kotlin/Long): kotlin.collections/List // com.pasich.encly.data.database.dao/TasksDao.getSubtasks|getSubtasks(kotlin.Long){}[0] + abstract suspend fun getTaskById(kotlin/Long): com.pasich.encly.data.model/Task? // com.pasich.encly.data.database.dao/TasksDao.getTaskById|getTaskById(kotlin.Long){}[0] + abstract suspend fun insertSubtask(com.pasich.encly.data.model/Subtask): kotlin/Long // com.pasich.encly.data.database.dao/TasksDao.insertSubtask|insertSubtask(com.pasich.encly.data.model.Subtask){}[0] + abstract suspend fun insertTask(com.pasich.encly.data.model/Task): kotlin/Long // com.pasich.encly.data.database.dao/TasksDao.insertTask|insertTask(com.pasich.encly.data.model.Task){}[0] + abstract suspend fun nextSubtaskPosition(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.nextSubtaskPosition|nextSubtaskPosition(kotlin.Long){}[0] + abstract suspend fun renameSubtask(kotlin/Long, kotlin/String): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.renameSubtask|renameSubtask(kotlin.Long;kotlin.String){}[0] + abstract suspend fun setSubtaskCompleted(kotlin/Long, kotlin/Boolean): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.setSubtaskCompleted|setSubtaskCompleted(kotlin.Long;kotlin.Boolean){}[0] + abstract suspend fun shiftSubtasksFrom(kotlin/Long, kotlin/Int): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.shiftSubtasksFrom|shiftSubtasksFrom(kotlin.Long;kotlin.Int){}[0] + abstract suspend fun updateSubtask(com.pasich.encly.data.model/Subtask): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.updateSubtask|updateSubtask(com.pasich.encly.data.model.Subtask){}[0] + abstract suspend fun updateTask(com.pasich.encly.data.model/Task): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.updateTask|updateTask(com.pasich.encly.data.model.Task){}[0] + abstract suspend fun updateTaskStatus(kotlin/Long, kotlin/Boolean, kotlin/Long?): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao.updateTaskStatus|updateTaskStatus(kotlin.Long;kotlin.Boolean;kotlin.Long?){}[0] + open suspend fun appendSubtask(kotlin/Long, kotlin/String): kotlin/Long // com.pasich.encly.data.database.dao/TasksDao.appendSubtask|appendSubtask(kotlin.Long;kotlin.String){}[0] + open suspend fun replaceSubtasks(kotlin/Long, kotlin.collections/List) // com.pasich.encly.data.database.dao/TasksDao.replaceSubtasks|replaceSubtasks(kotlin.Long;kotlin.collections.List){}[0] + open suspend fun restoreSubtask(com.pasich.encly.data.model/Subtask) // com.pasich.encly.data.database.dao/TasksDao.restoreSubtask|restoreSubtask(com.pasich.encly.data.model.Subtask){}[0] + open suspend fun restoreTask(com.pasich.encly.data.model/Task, kotlin.collections/List) // com.pasich.encly.data.database.dao/TasksDao.restoreTask|restoreTask(com.pasich.encly.data.model.Task;kotlin.collections.List){}[0] +} + +abstract interface com.pasich.encly.data.database/DatabasePlatform { // com.pasich.encly.data.database/DatabasePlatform|null[0] + abstract fun databaseBuilder(kotlinx.io.files/Path): androidx.room/RoomDatabase.Builder // com.pasich.encly.data.database/DatabasePlatform.databaseBuilder|databaseBuilder(kotlinx.io.files.Path){}[0] + abstract fun databasePath(com.pasich.encly.data.database/VaultFile): kotlinx.io.files/Path // com.pasich.encly.data.database/DatabasePlatform.databasePath|databasePath(com.pasich.encly.data.database.VaultFile){}[0] +} + +abstract interface com.pasich.encly.data.database/DatabaseProvider { // com.pasich.encly.data.database/DatabaseProvider|null[0] + abstract fun captureLease(): com.pasich.encly.data.database/DatabaseLease? // com.pasich.encly.data.database/DatabaseProvider.captureLease|captureLease(){}[0] + abstract fun getDatabase(com.pasich.encly.data.database/DatabaseLease): com.pasich.encly.data.database/AppDatabase // com.pasich.encly.data.database/DatabaseProvider.getDatabase|getDatabase(com.pasich.encly.data.database.DatabaseLease){}[0] +} + +abstract class com.pasich.encly.data.database/AppDatabase : androidx.room/RoomDatabase { // com.pasich.encly.data.database/AppDatabase|null[0] + constructor () // com.pasich.encly.data.database/AppDatabase.|(){}[0] + + abstract fun backupDao(): com.pasich.encly.data.database.dao/BackupDao // com.pasich.encly.data.database/AppDatabase.backupDao|backupDao(){}[0] + abstract fun notesDao(): com.pasich.encly.data.database.dao/NotesDao // com.pasich.encly.data.database/AppDatabase.notesDao|notesDao(){}[0] + abstract fun tagsDao(): com.pasich.encly.data.database.dao/TagsDao // com.pasich.encly.data.database/AppDatabase.tagsDao|tagsDao(){}[0] + abstract fun tasksDao(): com.pasich.encly.data.database.dao/TasksDao // com.pasich.encly.data.database/AppDatabase.tasksDao|tasksDao(){}[0] +} + +final class com.pasich.encly.data.database.dao/BackupDao_Impl : com.pasich.encly.data.database.dao/BackupDao { // com.pasich.encly.data.database.dao/BackupDao_Impl|null[0] + constructor (androidx.room/RoomDatabase) // com.pasich.encly.data.database.dao/BackupDao_Impl.|(androidx.room.RoomDatabase){}[0] + + final suspend fun allNoteTags(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao_Impl.allNoteTags|allNoteTags(){}[0] + final suspend fun allNotes(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao_Impl.allNotes|allNotes(){}[0] + final suspend fun allSubtasks(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao_Impl.allSubtasks|allSubtasks(){}[0] + final suspend fun allTags(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao_Impl.allTags|allTags(){}[0] + final suspend fun allTasks(): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao_Impl.allTasks|allTasks(){}[0] + final suspend fun deleteAllNoteTags() // com.pasich.encly.data.database.dao/BackupDao_Impl.deleteAllNoteTags|deleteAllNoteTags(){}[0] + final suspend fun deleteAllNotes() // com.pasich.encly.data.database.dao/BackupDao_Impl.deleteAllNotes|deleteAllNotes(){}[0] + final suspend fun deleteAllSubtasks() // com.pasich.encly.data.database.dao/BackupDao_Impl.deleteAllSubtasks|deleteAllSubtasks(){}[0] + final suspend fun deleteAllTags() // com.pasich.encly.data.database.dao/BackupDao_Impl.deleteAllTags|deleteAllTags(){}[0] + final suspend fun deleteAllTasks() // com.pasich.encly.data.database.dao/BackupDao_Impl.deleteAllTasks|deleteAllTasks(){}[0] + final suspend fun insertNote(com.pasich.encly.data.model/Note): kotlin/Long // com.pasich.encly.data.database.dao/BackupDao_Impl.insertNote|insertNote(com.pasich.encly.data.model.Note){}[0] + final suspend fun insertNoteTag(com.pasich.encly.data.model/NoteTag) // com.pasich.encly.data.database.dao/BackupDao_Impl.insertNoteTag|insertNoteTag(com.pasich.encly.data.model.NoteTag){}[0] + final suspend fun insertSubtask(com.pasich.encly.data.model/Subtask): kotlin/Long // com.pasich.encly.data.database.dao/BackupDao_Impl.insertSubtask|insertSubtask(com.pasich.encly.data.model.Subtask){}[0] + final suspend fun insertTag(com.pasich.encly.data.model/Tag): kotlin/Long // com.pasich.encly.data.database.dao/BackupDao_Impl.insertTag|insertTag(com.pasich.encly.data.model.Tag){}[0] + final suspend fun insertTask(com.pasich.encly.data.model/Task): kotlin/Long // com.pasich.encly.data.database.dao/BackupDao_Impl.insertTask|insertTask(com.pasich.encly.data.model.Task){}[0] + final suspend fun noteTagsOf(kotlin/Long): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao_Impl.noteTagsOf|noteTagsOf(kotlin.Long){}[0] + final suspend fun tagsOfNote(kotlin/Long): kotlin.collections/List // com.pasich.encly.data.database.dao/BackupDao_Impl.tagsOfNote|tagsOfNote(kotlin.Long){}[0] + + final object Companion { // com.pasich.encly.data.database.dao/BackupDao_Impl.Companion|null[0] + final fun getRequiredConverters(): kotlin.collections/List> // com.pasich.encly.data.database.dao/BackupDao_Impl.Companion.getRequiredConverters|getRequiredConverters(){}[0] + } +} + +final class com.pasich.encly.data.database.dao/NotesDao_Impl : com.pasich.encly.data.database.dao/NotesDao { // com.pasich.encly.data.database.dao/NotesDao_Impl|null[0] + constructor (androidx.room/RoomDatabase) // com.pasich.encly.data.database.dao/NotesDao_Impl.|(androidx.room.RoomDatabase){}[0] + + final fun getAllNotes(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getAllNotes|getAllNotes(){}[0] + final fun getAllNotesWithTags(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getAllNotesWithTags|getAllNotesWithTags(){}[0] + final fun getArchivedNotesWithTags(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getArchivedNotesWithTags|getArchivedNotesWithTags(){}[0] + final fun getNotesOfTagWithArchived(kotlin/Long): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getNotesOfTagWithArchived|getNotesOfTagWithArchived(kotlin.Long){}[0] + final fun getTrashNotes(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getTrashNotes|getTrashNotes(){}[0] + final fun observeNoteWithTags(kotlin/Long): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/NotesDao_Impl.observeNoteWithTags|observeNoteWithTags(kotlin.Long){}[0] + final fun observeTagNoteCounts(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.observeTagNoteCounts|observeTagNoteCounts(){}[0] + final suspend fun changeNoteTags(kotlin/Long, kotlin.collections/Collection, kotlin.collections/Collection) // com.pasich.encly.data.database.dao/NotesDao_Impl.changeNoteTags|changeNoteTags(kotlin.Long;kotlin.collections.Collection;kotlin.collections.Collection){}[0] + final suspend fun clearNoteTags(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.clearNoteTags|clearNoteTags(kotlin.Long){}[0] + final suspend fun deleteNoteById(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.deleteNoteById|deleteNoteById(kotlin.Long){}[0] + final suspend fun deleteNoteTags(kotlin/Long, kotlin.collections/List): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.deleteNoteTags|deleteNoteTags(kotlin.Long;kotlin.collections.List){}[0] + final suspend fun deleteNoteTagsExcept(kotlin/Long, kotlin.collections/List): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.deleteNoteTagsExcept|deleteNoteTagsExcept(kotlin.Long;kotlin.collections.List){}[0] + final suspend fun deleteTrashedBefore(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.deleteTrashedBefore|deleteTrashedBefore(kotlin.Long){}[0] + final suspend fun getNoteById(kotlin/Long): com.pasich.encly.data.model/Note? // com.pasich.encly.data.database.dao/NotesDao_Impl.getNoteById|getNoteById(kotlin.Long){}[0] + final suspend fun getNoteWithTags(kotlin/Long): com.pasich.encly.data.model/NoteWithTags? // com.pasich.encly.data.database.dao/NotesDao_Impl.getNoteWithTags|getNoteWithTags(kotlin.Long){}[0] + final suspend fun insertNote(com.pasich.encly.data.model/Note): kotlin/Long // com.pasich.encly.data.database.dao/NotesDao_Impl.insertNote|insertNote(com.pasich.encly.data.model.Note){}[0] + final suspend fun insertNoteTags(kotlin.collections/List) // com.pasich.encly.data.database.dao/NotesDao_Impl.insertNoteTags|insertNoteTags(kotlin.collections.List){}[0] + final suspend fun insertNoteWithTags(com.pasich.encly.data.model/Note): kotlin/Long // com.pasich.encly.data.database.dao/NotesDao_Impl.insertNoteWithTags|insertNoteWithTags(com.pasich.encly.data.model.Note){}[0] + final suspend fun saveEdited(kotlin/Long, kotlin/String, kotlin/String, kotlin/Boolean, kotlin/String, kotlin/Long, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/String, kotlin/Boolean, kotlin/Int?, kotlin/Boolean, kotlin/Int?, kotlin.collections/Collection, kotlin.collections/Collection): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.saveEdited|saveEdited(kotlin.Long;kotlin.String;kotlin.String;kotlin.Boolean;kotlin.String;kotlin.Long;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.Boolean;kotlin.Int?;kotlin.collections.Collection;kotlin.collections.Collection){}[0] + final suspend fun setNoteTags(kotlin/Long, kotlin.collections/Collection) // com.pasich.encly.data.database.dao/NotesDao_Impl.setNoteTags|setNoteTags(kotlin.Long;kotlin.collections.Collection){}[0] + final suspend fun stampTrashedAt(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.stampTrashedAt|stampTrashedAt(kotlin.Long){}[0] + final suspend fun updateArchived(kotlin/Long, kotlin/Boolean): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateArchived|updateArchived(kotlin.Long;kotlin.Boolean){}[0] + final suspend fun updateColor(kotlin/Long, kotlin/String): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateColor|updateColor(kotlin.Long;kotlin.String){}[0] + final suspend fun updateDescription(kotlin/Long, kotlin/String): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateDescription|updateDescription(kotlin.Long;kotlin.String){}[0] + final suspend fun updateEdited(kotlin/Long, kotlin/String, kotlin/String, kotlin/Boolean, kotlin/String, kotlin/Long, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/String, kotlin/Boolean, kotlin/Int?, kotlin/Boolean, kotlin/Int?): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateEdited|updateEdited(kotlin.Long;kotlin.String;kotlin.String;kotlin.Boolean;kotlin.String;kotlin.Long;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.Boolean;kotlin.Int?){}[0] + final suspend fun updateEditedWithAllTags(com.pasich.encly.data.model/Note): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateEditedWithAllTags|updateEditedWithAllTags(com.pasich.encly.data.model.Note){}[0] + final suspend fun updateNote(com.pasich.encly.data.model/Note): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateNote|updateNote(com.pasich.encly.data.model.Note){}[0] + final suspend fun updatePinned(kotlin/Long, kotlin/Boolean): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updatePinned|updatePinned(kotlin.Long;kotlin.Boolean){}[0] + final suspend fun updateTrashed(kotlin/Long, kotlin/Boolean, kotlin/Long?): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateTrashed|updateTrashed(kotlin.Long;kotlin.Boolean;kotlin.Long?){}[0] + + final object Companion { // com.pasich.encly.data.database.dao/NotesDao_Impl.Companion|null[0] + final fun getRequiredConverters(): kotlin.collections/List> // com.pasich.encly.data.database.dao/NotesDao_Impl.Companion.getRequiredConverters|getRequiredConverters(){}[0] + } +} + +final class com.pasich.encly.data.database.dao/TagsDao_Impl : com.pasich.encly.data.database.dao/TagsDao { // com.pasich.encly.data.database.dao/TagsDao_Impl|null[0] + constructor (androidx.room/RoomDatabase) // com.pasich.encly.data.database.dao/TagsDao_Impl.|(androidx.room.RoomDatabase){}[0] + + final fun getTags(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TagsDao_Impl.getTags|getTags(){}[0] + final suspend fun addTag(com.pasich.encly.data.model/Tag): kotlin/Long // com.pasich.encly.data.database.dao/TagsDao_Impl.addTag|addTag(com.pasich.encly.data.model.Tag){}[0] + final suspend fun addTags(kotlin.collections/List): kotlin.collections/List // com.pasich.encly.data.database.dao/TagsDao_Impl.addTags|addTags(kotlin.collections.List){}[0] + final suspend fun deleteTag(com.pasich.encly.data.model/Tag): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao_Impl.deleteTag|deleteTag(com.pasich.encly.data.model.Tag){}[0] + final suspend fun deleteTagDetachingNotes(com.pasich.encly.data.model/Tag): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao_Impl.deleteTagDetachingNotes|deleteTagDetachingNotes(com.pasich.encly.data.model.Tag){}[0] + final suspend fun detachNotesFromTag(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao_Impl.detachNotesFromTag|detachNotesFromTag(kotlin.Long){}[0] + final suspend fun updateTag(com.pasich.encly.data.model/Tag): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao_Impl.updateTag|updateTag(com.pasich.encly.data.model.Tag){}[0] + final suspend fun updateTags(kotlin.collections/List): kotlin/Int // com.pasich.encly.data.database.dao/TagsDao_Impl.updateTags|updateTags(kotlin.collections.List){}[0] + + final object Companion { // com.pasich.encly.data.database.dao/TagsDao_Impl.Companion|null[0] + final fun getRequiredConverters(): kotlin.collections/List> // com.pasich.encly.data.database.dao/TagsDao_Impl.Companion.getRequiredConverters|getRequiredConverters(){}[0] + } +} + +final class com.pasich.encly.data.database.dao/TasksDao_Impl : com.pasich.encly.data.database.dao/TasksDao { // com.pasich.encly.data.database.dao/TasksDao_Impl|null[0] + constructor (androidx.room/RoomDatabase) // com.pasich.encly.data.database.dao/TasksDao_Impl.|(androidx.room.RoomDatabase){}[0] + + final fun getActiveTasksCount(): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/TasksDao_Impl.getActiveTasksCount|getActiveTasksCount(){}[0] + final fun getAllActiveTasks(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TasksDao_Impl.getAllActiveTasks|getAllActiveTasks(){}[0] + final fun getAllCompletedTasks(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TasksDao_Impl.getAllCompletedTasks|getAllCompletedTasks(){}[0] + final fun getAllTasks(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TasksDao_Impl.getAllTasks|getAllTasks(){}[0] + final fun getCompletedTasksCount(): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/TasksDao_Impl.getCompletedTasksCount|getCompletedTasksCount(){}[0] + final fun observeSubtasks(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/TasksDao_Impl.observeSubtasks|observeSubtasks(){}[0] + final suspend fun appendSubtask(kotlin/Long, kotlin/String): kotlin/Long // com.pasich.encly.data.database.dao/TasksDao_Impl.appendSubtask|appendSubtask(kotlin.Long;kotlin.String){}[0] + final suspend fun deleteAllCompletedTasks(): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.deleteAllCompletedTasks|deleteAllCompletedTasks(){}[0] + final suspend fun deleteSubtaskById(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.deleteSubtaskById|deleteSubtaskById(kotlin.Long){}[0] + final suspend fun deleteSubtasksExcept(kotlin/Long, kotlin.collections/List): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.deleteSubtasksExcept|deleteSubtasksExcept(kotlin.Long;kotlin.collections.List){}[0] + final suspend fun deleteTask(com.pasich.encly.data.model/Task): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.deleteTask|deleteTask(com.pasich.encly.data.model.Task){}[0] + final suspend fun deleteTaskById(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.deleteTaskById|deleteTaskById(kotlin.Long){}[0] + final suspend fun getSubtasks(kotlin/Long): kotlin.collections/List // com.pasich.encly.data.database.dao/TasksDao_Impl.getSubtasks|getSubtasks(kotlin.Long){}[0] + final suspend fun getTaskById(kotlin/Long): com.pasich.encly.data.model/Task? // com.pasich.encly.data.database.dao/TasksDao_Impl.getTaskById|getTaskById(kotlin.Long){}[0] + final suspend fun insertSubtask(com.pasich.encly.data.model/Subtask): kotlin/Long // com.pasich.encly.data.database.dao/TasksDao_Impl.insertSubtask|insertSubtask(com.pasich.encly.data.model.Subtask){}[0] + final suspend fun insertTask(com.pasich.encly.data.model/Task): kotlin/Long // com.pasich.encly.data.database.dao/TasksDao_Impl.insertTask|insertTask(com.pasich.encly.data.model.Task){}[0] + final suspend fun nextSubtaskPosition(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.nextSubtaskPosition|nextSubtaskPosition(kotlin.Long){}[0] + final suspend fun renameSubtask(kotlin/Long, kotlin/String): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.renameSubtask|renameSubtask(kotlin.Long;kotlin.String){}[0] + final suspend fun replaceSubtasks(kotlin/Long, kotlin.collections/List) // com.pasich.encly.data.database.dao/TasksDao_Impl.replaceSubtasks|replaceSubtasks(kotlin.Long;kotlin.collections.List){}[0] + final suspend fun restoreSubtask(com.pasich.encly.data.model/Subtask) // com.pasich.encly.data.database.dao/TasksDao_Impl.restoreSubtask|restoreSubtask(com.pasich.encly.data.model.Subtask){}[0] + final suspend fun restoreTask(com.pasich.encly.data.model/Task, kotlin.collections/List) // com.pasich.encly.data.database.dao/TasksDao_Impl.restoreTask|restoreTask(com.pasich.encly.data.model.Task;kotlin.collections.List){}[0] + final suspend fun setSubtaskCompleted(kotlin/Long, kotlin/Boolean): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.setSubtaskCompleted|setSubtaskCompleted(kotlin.Long;kotlin.Boolean){}[0] + final suspend fun shiftSubtasksFrom(kotlin/Long, kotlin/Int): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.shiftSubtasksFrom|shiftSubtasksFrom(kotlin.Long;kotlin.Int){}[0] + final suspend fun updateSubtask(com.pasich.encly.data.model/Subtask): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.updateSubtask|updateSubtask(com.pasich.encly.data.model.Subtask){}[0] + final suspend fun updateTask(com.pasich.encly.data.model/Task): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.updateTask|updateTask(com.pasich.encly.data.model.Task){}[0] + final suspend fun updateTaskStatus(kotlin/Long, kotlin/Boolean, kotlin/Long?): kotlin/Int // com.pasich.encly.data.database.dao/TasksDao_Impl.updateTaskStatus|updateTaskStatus(kotlin.Long;kotlin.Boolean;kotlin.Long?){}[0] + + final object Companion { // com.pasich.encly.data.database.dao/TasksDao_Impl.Companion|null[0] + final fun getRequiredConverters(): kotlin.collections/List> // com.pasich.encly.data.database.dao/TasksDao_Impl.Companion.getRequiredConverters|getRequiredConverters(){}[0] + } +} + +final class com.pasich.encly.data.database/AppDatabase_Impl : com.pasich.encly.data.database/AppDatabase { // com.pasich.encly.data.database/AppDatabase_Impl|null[0] + constructor () // com.pasich.encly.data.database/AppDatabase_Impl.|(){}[0] + + final fun backupDao(): com.pasich.encly.data.database.dao/BackupDao // com.pasich.encly.data.database/AppDatabase_Impl.backupDao|backupDao(){}[0] + final fun createAutoMigrations(kotlin.collections/Map, androidx.room.migration/AutoMigrationSpec>): kotlin.collections/List // com.pasich.encly.data.database/AppDatabase_Impl.createAutoMigrations|createAutoMigrations(kotlin.collections.Map,androidx.room.migration.AutoMigrationSpec>){}[0] + final fun getRequiredAutoMigrationSpecClasses(): kotlin.collections/Set> // com.pasich.encly.data.database/AppDatabase_Impl.getRequiredAutoMigrationSpecClasses|getRequiredAutoMigrationSpecClasses(){}[0] + final fun notesDao(): com.pasich.encly.data.database.dao/NotesDao // com.pasich.encly.data.database/AppDatabase_Impl.notesDao|notesDao(){}[0] + final fun tagsDao(): com.pasich.encly.data.database.dao/TagsDao // com.pasich.encly.data.database/AppDatabase_Impl.tagsDao|tagsDao(){}[0] + final fun tasksDao(): com.pasich.encly.data.database.dao/TasksDao // com.pasich.encly.data.database/AppDatabase_Impl.tasksDao|tasksDao(){}[0] +} + +final class com.pasich.encly.data.database/DatabaseLease { // com.pasich.encly.data.database/DatabaseLease|null[0] + constructor () // com.pasich.encly.data.database/DatabaseLease.|(){}[0] +} + +final class com.pasich.encly.data.database/IosDatabasePlatform : com.pasich.encly.data.database/DatabasePlatform { // com.pasich.encly.data.database/IosDatabasePlatform|null[0] + constructor (kotlinx.io.files/Path) // com.pasich.encly.data.database/IosDatabasePlatform.|(kotlinx.io.files.Path){}[0] + + final fun databaseBuilder(kotlinx.io.files/Path): androidx.room/RoomDatabase.Builder // com.pasich.encly.data.database/IosDatabasePlatform.databaseBuilder|databaseBuilder(kotlinx.io.files.Path){}[0] + final fun databasePath(com.pasich.encly.data.database/VaultFile): kotlinx.io.files/Path // com.pasich.encly.data.database/IosDatabasePlatform.databasePath|databasePath(com.pasich.encly.data.database.VaultFile){}[0] +} + +final class com.pasich.encly.data.database/SecureDatabaseManager : com.pasich.encly.data.database/DatabaseProvider { // com.pasich.encly.data.database/SecureDatabaseManager|null[0] + constructor (com.pasich.encly.data.database/DatabasePlatform) // com.pasich.encly.data.database/SecureDatabaseManager.|(com.pasich.encly.data.database.DatabasePlatform){}[0] + + final fun captureLease(): com.pasich.encly.data.database/DatabaseLease? // com.pasich.encly.data.database/SecureDatabaseManager.captureLease|captureLease(){}[0] + final fun getDatabase(com.pasich.encly.data.database/DatabaseLease): com.pasich.encly.data.database/AppDatabase // com.pasich.encly.data.database/SecureDatabaseManager.getDatabase|getDatabase(com.pasich.encly.data.database.DatabaseLease){}[0] + final fun hasEncryptedDatabase(com.pasich.encly.data.database/VaultFile = ...): kotlin/Boolean // com.pasich.encly.data.database/SecureDatabaseManager.hasEncryptedDatabase|hasEncryptedDatabase(com.pasich.encly.data.database.VaultFile){}[0] + final fun isDatabaseUnlocked(): kotlin/Boolean // com.pasich.encly.data.database/SecureDatabaseManager.isDatabaseUnlocked|isDatabaseUnlocked(){}[0] + final fun reset() // com.pasich.encly.data.database/SecureDatabaseManager.reset|reset(){}[0] + final fun unlockDatabase(kotlin/ByteArray, kotlin/Boolean = ...): kotlin/Boolean // com.pasich.encly.data.database/SecureDatabaseManager.unlockDatabase|unlockDatabase(kotlin.ByteArray;kotlin.Boolean){}[0] + final fun unlockDatabase(kotlin/ByteArray, kotlin/Boolean, com.pasich.encly.data.database/VaultFile): kotlin/Boolean // com.pasich.encly.data.database/SecureDatabaseManager.unlockDatabase|unlockDatabase(kotlin.ByteArray;kotlin.Boolean;com.pasich.encly.data.database.VaultFile){}[0] + final fun wipe() // com.pasich.encly.data.database/SecureDatabaseManager.wipe|wipe(){}[0] + final fun wipeIfClosed(com.pasich.encly.data.database/VaultFile? = ...): kotlin/Boolean // com.pasich.encly.data.database/SecureDatabaseManager.wipeIfClosed|wipeIfClosed(com.pasich.encly.data.database.VaultFile?){}[0] + + final class MetroFactory : dev.zacsweers.metro.internal/Factory { // com.pasich.encly.data.database/SecureDatabaseManager.MetroFactory|null[0] + final fun declarationMirror(com.pasich.encly.data.database/DatabasePlatform): com.pasich.encly.data.database/SecureDatabaseManager // com.pasich.encly.data.database/SecureDatabaseManager.MetroFactory.declarationMirror|declarationMirror(com.pasich.encly.data.database.DatabasePlatform){}[0] + final fun invoke(): com.pasich.encly.data.database/SecureDatabaseManager // com.pasich.encly.data.database/SecureDatabaseManager.MetroFactory.invoke|invoke(){}[0] + + final object Companion { // com.pasich.encly.data.database/SecureDatabaseManager.MetroFactory.Companion|null[0] + final fun create(dev.zacsweers.metro/Provider): com.pasich.encly.data.database/SecureDatabaseManager.MetroFactory // com.pasich.encly.data.database/SecureDatabaseManager.MetroFactory.Companion.create|create(dev.zacsweers.metro.Provider){}[0] + final fun newInstance(com.pasich.encly.data.database/DatabasePlatform): com.pasich.encly.data.database/SecureDatabaseManager // com.pasich.encly.data.database/SecureDatabaseManager.MetroFactory.Companion.newInstance|newInstance(com.pasich.encly.data.database.DatabasePlatform){}[0] + } + } +} + +final class com.pasich.encly.data.model/Note { // com.pasich.encly.data.model/Note|null[0] + constructor (kotlin/Long = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Long = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ..., kotlin/Long? = ..., kotlin.collections/Set = ...) // com.pasich.encly.data.model/Note.|(kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.Long;kotlin.Long;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?;kotlin.Long?;kotlin.collections.Set){}[0] + + final var color // com.pasich.encly.data.model/Note.color|{}color[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Note.color.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.data.model/Note.color.|(kotlin.String){}[0] + final var date // com.pasich.encly.data.model/Note.date|{}date[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/Note.date.|(){}[0] + final fun (kotlin/Long) // com.pasich.encly.data.model/Note.date.|(kotlin.Long){}[0] + final var dateCreate // com.pasich.encly.data.model/Note.dateCreate|{}dateCreate[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/Note.dateCreate.|(){}[0] + final fun (kotlin/Long) // com.pasich.encly.data.model/Note.dateCreate.|(kotlin.Long){}[0] + final var description // com.pasich.encly.data.model/Note.description|{}description[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Note.description.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.data.model/Note.description.|(kotlin.String){}[0] + final var id // com.pasich.encly.data.model/Note.id|{}id[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/Note.id.|(){}[0] + final fun (kotlin/Long) // com.pasich.encly.data.model/Note.id.|(kotlin.Long){}[0] + final var isArchived // com.pasich.encly.data.model/Note.isArchived|{}isArchived[0] + final fun (): kotlin/Boolean // com.pasich.encly.data.model/Note.isArchived.|(){}[0] + final fun (kotlin/Boolean) // com.pasich.encly.data.model/Note.isArchived.|(kotlin.Boolean){}[0] + final var isChecked // com.pasich.encly.data.model/Note.isChecked|{}isChecked[0] + final fun (): kotlin/Boolean // com.pasich.encly.data.model/Note.isChecked.|(){}[0] + final fun (kotlin/Boolean) // com.pasich.encly.data.model/Note.isChecked.|(kotlin.Boolean){}[0] + final var isPinned // com.pasich.encly.data.model/Note.isPinned|{}isPinned[0] + final fun (): kotlin/Boolean // com.pasich.encly.data.model/Note.isPinned.|(){}[0] + final fun (kotlin/Boolean) // com.pasich.encly.data.model/Note.isPinned.|(kotlin.Boolean){}[0] + final var isTrash // com.pasich.encly.data.model/Note.isTrash|{}isTrash[0] + final fun (): kotlin/Boolean // com.pasich.encly.data.model/Note.isTrash.|(){}[0] + final fun (kotlin/Boolean) // com.pasich.encly.data.model/Note.isTrash.|(kotlin.Boolean){}[0] + final var lineSpacing // com.pasich.encly.data.model/Note.lineSpacing|{}lineSpacing[0] + final fun (): kotlin/Int? // com.pasich.encly.data.model/Note.lineSpacing.|(){}[0] + final fun (kotlin/Int?) // com.pasich.encly.data.model/Note.lineSpacing.|(kotlin.Int?){}[0] + final var tagIds // com.pasich.encly.data.model/Note.tagIds|{}tagIds[0] + final fun (): kotlin.collections/Set // com.pasich.encly.data.model/Note.tagIds.|(){}[0] + final fun (kotlin.collections/Set) // com.pasich.encly.data.model/Note.tagIds.|(kotlin.collections.Set){}[0] + final var textSize // com.pasich.encly.data.model/Note.textSize|{}textSize[0] + final fun (): kotlin/Int? // com.pasich.encly.data.model/Note.textSize.|(){}[0] + final fun (kotlin/Int?) // com.pasich.encly.data.model/Note.textSize.|(kotlin.Int?){}[0] + final var title // com.pasich.encly.data.model/Note.title|{}title[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Note.title.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.data.model/Note.title.|(kotlin.String){}[0] + final var trashedAt // com.pasich.encly.data.model/Note.trashedAt|{}trashedAt[0] + final fun (): kotlin/Long? // com.pasich.encly.data.model/Note.trashedAt.|(){}[0] + final fun (kotlin/Long?) // com.pasich.encly.data.model/Note.trashedAt.|(kotlin.Long?){}[0] + final var uid // com.pasich.encly.data.model/Note.uid|{}uid[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Note.uid.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.data.model/Note.uid.|(kotlin.String){}[0] + final var value // com.pasich.encly.data.model/Note.value|{}value[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Note.value.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.data.model/Note.value.|(kotlin.String){}[0] + + final fun component1(): kotlin/Long // com.pasich.encly.data.model/Note.component1|component1(){}[0] + final fun component10(): kotlin/Boolean // com.pasich.encly.data.model/Note.component10|component10(){}[0] + final fun component11(): kotlin/Boolean // com.pasich.encly.data.model/Note.component11|component11(){}[0] + final fun component12(): kotlin/String // com.pasich.encly.data.model/Note.component12|component12(){}[0] + final fun component13(): kotlin/Int? // com.pasich.encly.data.model/Note.component13|component13(){}[0] + final fun component14(): kotlin/Int? // com.pasich.encly.data.model/Note.component14|component14(){}[0] + final fun component15(): kotlin/Long? // com.pasich.encly.data.model/Note.component15|component15(){}[0] + final fun component16(): kotlin.collections/Set // com.pasich.encly.data.model/Note.component16|component16(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.data.model/Note.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.data.model/Note.component3|component3(){}[0] + final fun component4(): kotlin/String // com.pasich.encly.data.model/Note.component4|component4(){}[0] + final fun component5(): kotlin/Long // com.pasich.encly.data.model/Note.component5|component5(){}[0] + final fun component6(): kotlin/Long // com.pasich.encly.data.model/Note.component6|component6(){}[0] + final fun component7(): kotlin/Boolean // com.pasich.encly.data.model/Note.component7|component7(){}[0] + final fun component8(): kotlin/Boolean // com.pasich.encly.data.model/Note.component8|component8(){}[0] + final fun component9(): kotlin/String // com.pasich.encly.data.model/Note.component9|component9(){}[0] + final fun copy(kotlin/Long = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Long = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ..., kotlin/Long? = ..., kotlin.collections/Set = ...): com.pasich.encly.data.model/Note // com.pasich.encly.data.model/Note.copy|copy(kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.Long;kotlin.Long;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?;kotlin.Long?;kotlin.collections.Set){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.data.model/Note.equals|equals(kotlin.Any?){}[0] + final fun hasContentChanged(kotlin/String, kotlin/String): kotlin/Boolean // com.pasich.encly.data.model/Note.hasContentChanged|hasContentChanged(kotlin.String;kotlin.String){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.data.model/Note.hashCode|hashCode(){}[0] + final fun isEmpty(): kotlin/Boolean // com.pasich.encly.data.model/Note.isEmpty|isEmpty(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.data.model/Note.toString|toString(){}[0] + + final object Companion { // com.pasich.encly.data.model/Note.Companion|null[0] + final const val NO_COLOR // com.pasich.encly.data.model/Note.Companion.NO_COLOR|{}NO_COLOR[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Note.Companion.NO_COLOR.|(){}[0] + + final fun new(kotlin/String, kotlin/String, kotlin.collections/Set = ...): com.pasich.encly.data.model/Note // com.pasich.encly.data.model/Note.Companion.new|new(kotlin.String;kotlin.String;kotlin.collections.Set){}[0] + } +} + +final class com.pasich.encly.data.model/NoteTag { // com.pasich.encly.data.model/NoteTag|null[0] + constructor (kotlin/Long, kotlin/Long) // com.pasich.encly.data.model/NoteTag.|(kotlin.Long;kotlin.Long){}[0] + + final val noteId // com.pasich.encly.data.model/NoteTag.noteId|{}noteId[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/NoteTag.noteId.|(){}[0] + final val tagId // com.pasich.encly.data.model/NoteTag.tagId|{}tagId[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/NoteTag.tagId.|(){}[0] + + final fun component1(): kotlin/Long // com.pasich.encly.data.model/NoteTag.component1|component1(){}[0] + final fun component2(): kotlin/Long // com.pasich.encly.data.model/NoteTag.component2|component2(){}[0] + final fun copy(kotlin/Long = ..., kotlin/Long = ...): com.pasich.encly.data.model/NoteTag // com.pasich.encly.data.model/NoteTag.copy|copy(kotlin.Long;kotlin.Long){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.data.model/NoteTag.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.data.model/NoteTag.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.data.model/NoteTag.toString|toString(){}[0] +} + +final class com.pasich.encly.data.model/NoteWithTags { // com.pasich.encly.data.model/NoteWithTags|null[0] + constructor (com.pasich.encly.data.model/Note, kotlin.collections/List) // com.pasich.encly.data.model/NoteWithTags.|(com.pasich.encly.data.model.Note;kotlin.collections.List){}[0] + + final val note // com.pasich.encly.data.model/NoteWithTags.note|{}note[0] + final fun (): com.pasich.encly.data.model/Note // com.pasich.encly.data.model/NoteWithTags.note.|(){}[0] + final val tags // com.pasich.encly.data.model/NoteWithTags.tags|{}tags[0] + final fun (): kotlin.collections/List // com.pasich.encly.data.model/NoteWithTags.tags.|(){}[0] + + final fun component1(): com.pasich.encly.data.model/Note // com.pasich.encly.data.model/NoteWithTags.component1|component1(){}[0] + final fun component2(): kotlin.collections/List // com.pasich.encly.data.model/NoteWithTags.component2|component2(){}[0] + final fun copy(com.pasich.encly.data.model/Note = ..., kotlin.collections/List = ...): com.pasich.encly.data.model/NoteWithTags // com.pasich.encly.data.model/NoteWithTags.copy|copy(com.pasich.encly.data.model.Note;kotlin.collections.List){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.data.model/NoteWithTags.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.data.model/NoteWithTags.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.data.model/NoteWithTags.toString|toString(){}[0] +} + +final class com.pasich.encly.data.model/Subtask { // com.pasich.encly.data.model/Subtask|null[0] + constructor (kotlin/Long = ..., kotlin/Long, kotlin/String, kotlin/Boolean = ..., kotlin/Int = ..., kotlin/String = ...) // com.pasich.encly.data.model/Subtask.|(kotlin.Long;kotlin.Long;kotlin.String;kotlin.Boolean;kotlin.Int;kotlin.String){}[0] + + final val id // com.pasich.encly.data.model/Subtask.id|{}id[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/Subtask.id.|(){}[0] + final val isCompleted // com.pasich.encly.data.model/Subtask.isCompleted|{}isCompleted[0] + final fun (): kotlin/Boolean // com.pasich.encly.data.model/Subtask.isCompleted.|(){}[0] + final val position // com.pasich.encly.data.model/Subtask.position|{}position[0] + final fun (): kotlin/Int // com.pasich.encly.data.model/Subtask.position.|(){}[0] + final val taskId // com.pasich.encly.data.model/Subtask.taskId|{}taskId[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/Subtask.taskId.|(){}[0] + final val title // com.pasich.encly.data.model/Subtask.title|{}title[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Subtask.title.|(){}[0] + final val uid // com.pasich.encly.data.model/Subtask.uid|{}uid[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Subtask.uid.|(){}[0] + + final fun component1(): kotlin/Long // com.pasich.encly.data.model/Subtask.component1|component1(){}[0] + final fun component2(): kotlin/Long // com.pasich.encly.data.model/Subtask.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.data.model/Subtask.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // com.pasich.encly.data.model/Subtask.component4|component4(){}[0] + final fun component5(): kotlin/Int // com.pasich.encly.data.model/Subtask.component5|component5(){}[0] + final fun component6(): kotlin/String // com.pasich.encly.data.model/Subtask.component6|component6(){}[0] + final fun copy(kotlin/Long = ..., kotlin/Long = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Int = ..., kotlin/String = ...): com.pasich.encly.data.model/Subtask // com.pasich.encly.data.model/Subtask.copy|copy(kotlin.Long;kotlin.Long;kotlin.String;kotlin.Boolean;kotlin.Int;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.data.model/Subtask.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.data.model/Subtask.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.data.model/Subtask.toString|toString(){}[0] +} + +final class com.pasich.encly.data.model/Tag { // com.pasich.encly.data.model/Tag|null[0] + constructor (kotlin/Long = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Int = ..., kotlin/String = ...) // com.pasich.encly.data.model/Tag.|(kotlin.Long;kotlin.String;kotlin.Boolean;kotlin.Int;kotlin.String){}[0] + + final var id // com.pasich.encly.data.model/Tag.id|{}id[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/Tag.id.|(){}[0] + final fun (kotlin/Long) // com.pasich.encly.data.model/Tag.id.|(kotlin.Long){}[0] + final var isVisible // com.pasich.encly.data.model/Tag.isVisible|{}isVisible[0] + final fun (): kotlin/Boolean // com.pasich.encly.data.model/Tag.isVisible.|(){}[0] + final fun (kotlin/Boolean) // com.pasich.encly.data.model/Tag.isVisible.|(kotlin.Boolean){}[0] + final var nameTag // com.pasich.encly.data.model/Tag.nameTag|{}nameTag[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Tag.nameTag.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.data.model/Tag.nameTag.|(kotlin.String){}[0] + final var position // com.pasich.encly.data.model/Tag.position|{}position[0] + final fun (): kotlin/Int // com.pasich.encly.data.model/Tag.position.|(){}[0] + final fun (kotlin/Int) // com.pasich.encly.data.model/Tag.position.|(kotlin.Int){}[0] + final var uid // com.pasich.encly.data.model/Tag.uid|{}uid[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Tag.uid.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.data.model/Tag.uid.|(kotlin.String){}[0] + + final fun component1(): kotlin/Long // com.pasich.encly.data.model/Tag.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.data.model/Tag.component2|component2(){}[0] + final fun component3(): kotlin/Boolean // com.pasich.encly.data.model/Tag.component3|component3(){}[0] + final fun component4(): kotlin/Int // com.pasich.encly.data.model/Tag.component4|component4(){}[0] + final fun component5(): kotlin/String // com.pasich.encly.data.model/Tag.component5|component5(){}[0] + final fun copy(kotlin/Long = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Int = ..., kotlin/String = ...): com.pasich.encly.data.model/Tag // com.pasich.encly.data.model/Tag.copy|copy(kotlin.Long;kotlin.String;kotlin.Boolean;kotlin.Int;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.data.model/Tag.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.data.model/Tag.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.data.model/Tag.toString|toString(){}[0] +} + +final class com.pasich.encly.data.model/TagNoteCount { // com.pasich.encly.data.model/TagNoteCount|null[0] + constructor (kotlin/Long, kotlin/Int) // com.pasich.encly.data.model/TagNoteCount.|(kotlin.Long;kotlin.Int){}[0] + + final val count // com.pasich.encly.data.model/TagNoteCount.count|{}count[0] + final fun (): kotlin/Int // com.pasich.encly.data.model/TagNoteCount.count.|(){}[0] + final val tagId // com.pasich.encly.data.model/TagNoteCount.tagId|{}tagId[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/TagNoteCount.tagId.|(){}[0] + + final fun component1(): kotlin/Long // com.pasich.encly.data.model/TagNoteCount.component1|component1(){}[0] + final fun component2(): kotlin/Int // com.pasich.encly.data.model/TagNoteCount.component2|component2(){}[0] + final fun copy(kotlin/Long = ..., kotlin/Int = ...): com.pasich.encly.data.model/TagNoteCount // com.pasich.encly.data.model/TagNoteCount.copy|copy(kotlin.Long;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.data.model/TagNoteCount.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.data.model/TagNoteCount.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.data.model/TagNoteCount.toString|toString(){}[0] +} + +final class com.pasich.encly.data.model/Task { // com.pasich.encly.data.model/Task|null[0] + constructor (kotlin/Long = ..., kotlin/String, kotlin/String? = ..., kotlin/Boolean = ..., kotlin/Long = ..., kotlin/Long? = ..., kotlin/Int = ..., kotlin/Long? = ..., kotlin/Int = ..., kotlin/String = ...) // com.pasich.encly.data.model/Task.|(kotlin.Long;kotlin.String;kotlin.String?;kotlin.Boolean;kotlin.Long;kotlin.Long?;kotlin.Int;kotlin.Long?;kotlin.Int;kotlin.String){}[0] + + final val categoryId // com.pasich.encly.data.model/Task.categoryId|{}categoryId[0] + final fun (): kotlin/Long? // com.pasich.encly.data.model/Task.categoryId.|(){}[0] + final val completedDate // com.pasich.encly.data.model/Task.completedDate|{}completedDate[0] + final fun (): kotlin/Long? // com.pasich.encly.data.model/Task.completedDate.|(){}[0] + final val createdDate // com.pasich.encly.data.model/Task.createdDate|{}createdDate[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/Task.createdDate.|(){}[0] + final val description // com.pasich.encly.data.model/Task.description|{}description[0] + final fun (): kotlin/String? // com.pasich.encly.data.model/Task.description.|(){}[0] + final val id // com.pasich.encly.data.model/Task.id|{}id[0] + final fun (): kotlin/Long // com.pasich.encly.data.model/Task.id.|(){}[0] + final val isCompleted // com.pasich.encly.data.model/Task.isCompleted|{}isCompleted[0] + final fun (): kotlin/Boolean // com.pasich.encly.data.model/Task.isCompleted.|(){}[0] + final val priority // com.pasich.encly.data.model/Task.priority|{}priority[0] + final fun (): kotlin/Int // com.pasich.encly.data.model/Task.priority.|(){}[0] + final val title // com.pasich.encly.data.model/Task.title|{}title[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Task.title.|(){}[0] + final val uid // com.pasich.encly.data.model/Task.uid|{}uid[0] + final fun (): kotlin/String // com.pasich.encly.data.model/Task.uid.|(){}[0] + + final var position // com.pasich.encly.data.model/Task.position|{}position[0] + final fun (): kotlin/Int // com.pasich.encly.data.model/Task.position.|(){}[0] + final fun (kotlin/Int) // com.pasich.encly.data.model/Task.position.|(kotlin.Int){}[0] + + final fun component1(): kotlin/Long // com.pasich.encly.data.model/Task.component1|component1(){}[0] + final fun component10(): kotlin/String // com.pasich.encly.data.model/Task.component10|component10(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.data.model/Task.component2|component2(){}[0] + final fun component3(): kotlin/String? // com.pasich.encly.data.model/Task.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // com.pasich.encly.data.model/Task.component4|component4(){}[0] + final fun component5(): kotlin/Long // com.pasich.encly.data.model/Task.component5|component5(){}[0] + final fun component6(): kotlin/Long? // com.pasich.encly.data.model/Task.component6|component6(){}[0] + final fun component7(): kotlin/Int // com.pasich.encly.data.model/Task.component7|component7(){}[0] + final fun component8(): kotlin/Long? // com.pasich.encly.data.model/Task.component8|component8(){}[0] + final fun component9(): kotlin/Int // com.pasich.encly.data.model/Task.component9|component9(){}[0] + final fun copy(kotlin/Long = ..., kotlin/String = ..., kotlin/String? = ..., kotlin/Boolean = ..., kotlin/Long = ..., kotlin/Long? = ..., kotlin/Int = ..., kotlin/Long? = ..., kotlin/Int = ..., kotlin/String = ...): com.pasich.encly.data.model/Task // com.pasich.encly.data.model/Task.copy|copy(kotlin.Long;kotlin.String;kotlin.String?;kotlin.Boolean;kotlin.Long;kotlin.Long?;kotlin.Int;kotlin.Long?;kotlin.Int;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.data.model/Task.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.data.model/Task.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.data.model/Task.toString|toString(){}[0] + + final object Companion { // com.pasich.encly.data.model/Task.Companion|null[0] + final fun new(kotlin/String, kotlin/String? = ..., kotlin/Int = ..., kotlin/Long? = ...): com.pasich.encly.data.model/Task // com.pasich.encly.data.model/Task.Companion.new|new(kotlin.String;kotlin.String?;kotlin.Int;kotlin.Long?){}[0] + } +} + +final object com.pasich.encly.core.security.cipher/SQLCipherUtils { // com.pasich.encly.core.security.cipher/SQLCipherUtils|null[0] + final fun getDatabaseState(kotlinx.io.files/Path): com.pasich.encly.core.security.cipher/SQLCipherUtils.State // com.pasich.encly.core.security.cipher/SQLCipherUtils.getDatabaseState|getDatabaseState(kotlinx.io.files.Path){}[0] + + final enum class State : kotlin/Enum { // com.pasich.encly.core.security.cipher/SQLCipherUtils.State|null[0] + enum entry DOES_NOT_EXIST // com.pasich.encly.core.security.cipher/SQLCipherUtils.State.DOES_NOT_EXIST|null[0] + enum entry ENCRYPTED // com.pasich.encly.core.security.cipher/SQLCipherUtils.State.ENCRYPTED|null[0] + enum entry UNENCRYPTED // com.pasich.encly.core.security.cipher/SQLCipherUtils.State.UNENCRYPTED|null[0] + + final val entries // com.pasich.encly.core.security.cipher/SQLCipherUtils.State.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security.cipher/SQLCipherUtils.State.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security.cipher/SQLCipherUtils.State // com.pasich.encly.core.security.cipher/SQLCipherUtils.State.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security.cipher/SQLCipherUtils.State.values|values#static(){}[0] + } +} + +final object com.pasich.encly.data.database/AppDatabaseConstructor : androidx.room/RoomDatabaseConstructor { // com.pasich.encly.data.database/AppDatabaseConstructor|null[0] + final fun initialize(): com.pasich.encly.data.database/AppDatabase // com.pasich.encly.data.database/AppDatabaseConstructor.initialize|initialize(){}[0] +} + +final object com.pasich.encly.data.database/VaultSchema { // com.pasich.encly.data.database/VaultSchema|null[0] + final val CALLBACK // com.pasich.encly.data.database/VaultSchema.CALLBACK|{}CALLBACK[0] + final fun (): androidx.room/RoomDatabase.Callback // com.pasich.encly.data.database/VaultSchema.CALLBACK.|(){}[0] + final val MIGRATION_1_2 // com.pasich.encly.data.database/VaultSchema.MIGRATION_1_2|{}MIGRATION_1_2[0] + final fun (): androidx.room.migration/Migration // com.pasich.encly.data.database/VaultSchema.MIGRATION_1_2.|(){}[0] + final val MIGRATION_2_3 // com.pasich.encly.data.database/VaultSchema.MIGRATION_2_3|{}MIGRATION_2_3[0] + final fun (): androidx.room.migration/Migration // com.pasich.encly.data.database/VaultSchema.MIGRATION_2_3.|(){}[0] + final val MIGRATION_3_4 // com.pasich.encly.data.database/VaultSchema.MIGRATION_3_4|{}MIGRATION_3_4[0] + final fun (): androidx.room.migration/Migration // com.pasich.encly.data.database/VaultSchema.MIGRATION_3_4.|(){}[0] + final val MIGRATION_4_5 // com.pasich.encly.data.database/VaultSchema.MIGRATION_4_5|{}MIGRATION_4_5[0] + final fun (): androidx.room.migration/Migration // com.pasich.encly.data.database/VaultSchema.MIGRATION_4_5.|(){}[0] + final val MIGRATION_5_6 // com.pasich.encly.data.database/VaultSchema.MIGRATION_5_6|{}MIGRATION_5_6[0] + final fun (): androidx.room.migration/Migration // com.pasich.encly.data.database/VaultSchema.MIGRATION_5_6.|(){}[0] + final val MIGRATION_6_7 // com.pasich.encly.data.database/VaultSchema.MIGRATION_6_7|{}MIGRATION_6_7[0] + final fun (): androidx.room.migration/Migration // com.pasich.encly.data.database/VaultSchema.MIGRATION_6_7.|(){}[0] + final val MIGRATION_7_8 // com.pasich.encly.data.database/VaultSchema.MIGRATION_7_8|{}MIGRATION_7_8[0] + final fun (): androidx.room.migration/Migration // com.pasich.encly.data.database/VaultSchema.MIGRATION_7_8.|(){}[0] + + final fun <#A1: androidx.room/RoomDatabase> install(androidx.room/RoomDatabase.Builder<#A1>): androidx.room/RoomDatabase.Builder<#A1> // com.pasich.encly.data.database/VaultSchema.install|install(androidx.room.RoomDatabase.Builder<0:0>){0§}[0] + final fun createTriggers(androidx.sqlite/SQLiteConnection) // com.pasich.encly.data.database/VaultSchema.createTriggers|createTriggers(androidx.sqlite.SQLiteConnection){}[0] +} + +final const val com.pasich.encly.data.database/DB_VERSION // com.pasich.encly.data.database/DB_VERSION|{}DB_VERSION[0] + final fun (): kotlin/Int // com.pasich.encly.data.database/DB_VERSION.|(){}[0] diff --git a/core/database/api/desktop/database.api b/core/database/api/desktop/database.api new file mode 100644 index 00000000..ff00ce77 --- /dev/null +++ b/core/database/api/desktop/database.api @@ -0,0 +1,534 @@ +public final class com/pasich/encly/core/security/cipher/SQLCipherUtils { + public static final field INSTANCE Lcom/pasich/encly/core/security/cipher/SQLCipherUtils; + public final fun getDatabaseState (Lkotlinx/io/files/Path;)Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; +} + +public final class com/pasich/encly/core/security/cipher/SQLCipherUtils$State : java/lang/Enum { + public static final field DOES_NOT_EXIST Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; + public static final field ENCRYPTED Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; + public static final field UNENCRYPTED Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; + public static fun values ()[Lcom/pasich/encly/core/security/cipher/SQLCipherUtils$State; +} + +public abstract class com/pasich/encly/data/database/AppDatabase : androidx/room/RoomDatabase { + public fun ()V + public abstract fun backupDao ()Lcom/pasich/encly/data/database/dao/BackupDao; + public abstract fun notesDao ()Lcom/pasich/encly/data/database/dao/NotesDao; + public abstract fun tagsDao ()Lcom/pasich/encly/data/database/dao/TagsDao; + public abstract fun tasksDao ()Lcom/pasich/encly/data/database/dao/TasksDao; +} + +public final class com/pasich/encly/data/database/AppDatabaseConstructor : androidx/room/RoomDatabaseConstructor { + public static final field INSTANCE Lcom/pasich/encly/data/database/AppDatabaseConstructor; + public synthetic fun initialize ()Landroidx/room/RoomDatabase; + public fun initialize ()Lcom/pasich/encly/data/database/AppDatabase; +} + +public final class com/pasich/encly/data/database/AppDatabaseKt { + public static final field DB_VERSION I +} + +public final class com/pasich/encly/data/database/AppDatabase_Impl : com/pasich/encly/data/database/AppDatabase { + public fun ()V + public fun backupDao ()Lcom/pasich/encly/data/database/dao/BackupDao; + public fun createAutoMigrations (Ljava/util/Map;)Ljava/util/List; + public synthetic fun createOpenDelegate ()Landroidx/room/RoomOpenDelegateMarker; + public fun getRequiredAutoMigrationSpecClasses ()Ljava/util/Set; + public fun notesDao ()Lcom/pasich/encly/data/database/dao/NotesDao; + public fun tagsDao ()Lcom/pasich/encly/data/database/dao/TagsDao; + public fun tasksDao ()Lcom/pasich/encly/data/database/dao/TasksDao; +} + +public final class com/pasich/encly/data/database/DatabaseLease { + public fun ()V +} + +public abstract interface class com/pasich/encly/data/database/DatabasePlatform { + public abstract fun databaseBuilder (Lkotlinx/io/files/Path;)Landroidx/room/RoomDatabase$Builder; + public abstract fun databasePath (Lcom/pasich/encly/data/database/VaultFile;)Lkotlinx/io/files/Path; +} + +public abstract interface class com/pasich/encly/data/database/DatabaseProvider { + public abstract fun captureLease ()Lcom/pasich/encly/data/database/DatabaseLease; + public abstract fun getDatabase (Lcom/pasich/encly/data/database/DatabaseLease;)Lcom/pasich/encly/data/database/AppDatabase; +} + +public final class com/pasich/encly/data/database/DesktopDatabasePlatform : com/pasich/encly/data/database/DatabasePlatform { + public fun (Lkotlinx/io/files/Path;)V + public fun databaseBuilder (Lkotlinx/io/files/Path;)Landroidx/room/RoomDatabase$Builder; + public fun databasePath (Lcom/pasich/encly/data/database/VaultFile;)Lkotlinx/io/files/Path; +} + +public final class com/pasich/encly/data/database/SecureDatabaseManager : com/pasich/encly/data/database/DatabaseProvider { + public fun (Lcom/pasich/encly/data/database/DatabasePlatform;)V + public fun captureLease ()Lcom/pasich/encly/data/database/DatabaseLease; + public fun getDatabase (Lcom/pasich/encly/data/database/DatabaseLease;)Lcom/pasich/encly/data/database/AppDatabase; + public final fun hasEncryptedDatabase (Lcom/pasich/encly/data/database/VaultFile;)Z + public static synthetic fun hasEncryptedDatabase$default (Lcom/pasich/encly/data/database/SecureDatabaseManager;Lcom/pasich/encly/data/database/VaultFile;ILjava/lang/Object;)Z + public final fun isDatabaseUnlocked ()Z + public final fun reset ()V + public final fun unlockDatabase ([BZ)Z + public final fun unlockDatabase ([BZLcom/pasich/encly/data/database/VaultFile;)Z + public static synthetic fun unlockDatabase$default (Lcom/pasich/encly/data/database/SecureDatabaseManager;[BZILjava/lang/Object;)Z + public final fun wipe ()V + public final fun wipeIfClosed (Lcom/pasich/encly/data/database/VaultFile;)Z + public static synthetic fun wipeIfClosed$default (Lcom/pasich/encly/data/database/SecureDatabaseManager;Lcom/pasich/encly/data/database/VaultFile;ILjava/lang/Object;)Z +} + +public final class com/pasich/encly/data/database/SecureDatabaseManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/data/database/SecureDatabaseManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/data/database/SecureDatabaseManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/data/database/SecureDatabaseManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/data/database/DatabasePlatform;)Lcom/pasich/encly/data/database/SecureDatabaseManager; +} + +public final class com/pasich/encly/data/database/SecureDatabaseManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/data/database/SecureDatabaseManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/data/database/DatabasePlatform;)Lcom/pasich/encly/data/database/SecureDatabaseManager; +} + +public final class com/pasich/encly/data/database/VaultFile : java/lang/Enum { + public static final field DECOY Lcom/pasich/encly/data/database/VaultFile; + public static final field PRIMARY Lcom/pasich/encly/data/database/VaultFile; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/data/database/VaultFile; + public static fun values ()[Lcom/pasich/encly/data/database/VaultFile; +} + +public final class com/pasich/encly/data/database/VaultSchema { + public static final field INSTANCE Lcom/pasich/encly/data/database/VaultSchema; + public final fun createTriggers (Landroidx/sqlite/SQLiteConnection;)V + public final fun getCALLBACK ()Landroidx/room/RoomDatabase$Callback; + public final fun getMIGRATION_1_2 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_2_3 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_3_4 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_4_5 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_5_6 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_6_7 ()Landroidx/room/migration/Migration; + public final fun getMIGRATION_7_8 ()Landroidx/room/migration/Migration; + public final fun install (Landroidx/room/RoomDatabase$Builder;)Landroidx/room/RoomDatabase$Builder; +} + +public abstract interface class com/pasich/encly/data/database/dao/BackupDao { + public abstract fun allNoteTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun allNotes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun allSubtasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun allTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun allTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllNoteTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllNotes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllSubtasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertNoteTag (Lcom/pasich/encly/data/model/NoteTag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun noteTagsOf (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun tagsOfNote (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/BackupDao_Impl : com/pasich/encly/data/database/dao/BackupDao { + public static final field Companion Lcom/pasich/encly/data/database/dao/BackupDao_Impl$Companion; + public fun (Landroidx/room/RoomDatabase;)V + public fun allNoteTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun allNotes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun allSubtasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun allTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun allTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllNoteTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllNotes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllSubtasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllTags (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNoteTag (Lcom/pasich/encly/data/model/NoteTag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun noteTagsOf (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun tagsOfNote (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/BackupDao_Impl$Companion { + public final fun getRequiredConverters ()Ljava/util/List; +} + +public abstract interface class com/pasich/encly/data/database/dao/NotesDao { + public fun changeNoteTags (JLjava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearNoteTags (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNoteTags (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNoteTagsExcept (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteTrashedBefore (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getAllNotes ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getAllNotesWithTags ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getArchivedNotesWithTags ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getNoteWithTags (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getNotesOfTagWithArchived (J)Lkotlinx/coroutines/flow/Flow; + public abstract fun getTrashNotes ()Lkotlinx/coroutines/flow/Flow; + public abstract fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertNoteTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNoteWithTags (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun observeNoteWithTags (J)Lkotlinx/coroutines/flow/Flow; + public abstract fun observeTagNoteCounts ()Lkotlinx/coroutines/flow/Flow; + public fun saveEdited (JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Ljava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun setNoteTags (JLjava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun stampTrashedAt (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateArchived (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateColor (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateDescription (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateEdited (JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateEditedWithAllTags (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updatePinned (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateTrashed (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/NotesDao$DefaultImpls { + public static fun changeNoteTags (Lcom/pasich/encly/data/database/dao/NotesDao;JLjava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun insertNoteWithTags (Lcom/pasich/encly/data/database/dao/NotesDao;Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun saveEdited (Lcom/pasich/encly/data/database/dao/NotesDao;JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Ljava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun setNoteTags (Lcom/pasich/encly/data/database/dao/NotesDao;JLjava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun updateEditedWithAllTags (Lcom/pasich/encly/data/database/dao/NotesDao;Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich/encly/data/database/dao/NotesDao { + public static final field Companion Lcom/pasich/encly/data/database/dao/NotesDao_Impl$Companion; + public fun (Landroidx/room/RoomDatabase;)V + public fun changeNoteTags (JLjava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun clearNoteTags (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNoteTags (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteNoteTagsExcept (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTrashedBefore (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getAllNotes ()Lkotlinx/coroutines/flow/Flow; + public fun getAllNotesWithTags ()Lkotlinx/coroutines/flow/Flow; + public fun getArchivedNotesWithTags ()Lkotlinx/coroutines/flow/Flow; + public fun getNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getNoteWithTags (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getNotesOfTagWithArchived (J)Lkotlinx/coroutines/flow/Flow; + public fun getTrashNotes ()Lkotlinx/coroutines/flow/Flow; + public fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNoteTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertNoteWithTags (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observeNoteWithTags (J)Lkotlinx/coroutines/flow/Flow; + public fun observeTagNoteCounts ()Lkotlinx/coroutines/flow/Flow; + public fun saveEdited (JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Ljava/util/Collection;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun setNoteTags (JLjava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun stampTrashedAt (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateArchived (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateColor (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateDescription (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateEdited (JLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;JZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateEditedWithAllTags (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updatePinned (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTrashed (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/NotesDao_Impl$Companion { + public final fun getRequiredConverters ()Ljava/util/List; +} + +public abstract interface class com/pasich/encly/data/database/dao/TagsDao { + public abstract fun addTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun addTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTagDetachingNotes (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun detachNotesFromTag (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getTags ()Lkotlinx/coroutines/flow/Flow; + public abstract fun updateTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TagsDao$DefaultImpls { + public static fun deleteTagDetachingNotes (Lcom/pasich/encly/data/database/dao/TagsDao;Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TagsDao_Impl : com/pasich/encly/data/database/dao/TagsDao { + public static final field Companion Lcom/pasich/encly/data/database/dao/TagsDao_Impl$Companion; + public fun (Landroidx/room/RoomDatabase;)V + public fun addTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun addTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTagDetachingNotes (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun detachNotesFromTag (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getTags ()Lkotlinx/coroutines/flow/Flow; + public fun updateTag (Lcom/pasich/encly/data/model/Tag;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTags (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TagsDao_Impl$Companion { + public final fun getRequiredConverters ()Ljava/util/List; +} + +public abstract interface class com/pasich/encly/data/database/dao/TasksDao { + public fun appendSubtask (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAllCompletedTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteSubtaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteSubtasksExcept (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteTaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getActiveTasksCount ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getAllActiveTasks ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getAllCompletedTasks ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getAllTasks ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getCompletedTasksCount ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getSubtasks (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getTaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun insertTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun nextSubtaskPosition (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun observeSubtasks ()Lkotlinx/coroutines/flow/Flow; + public abstract fun renameSubtask (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun replaceSubtasks (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun restoreSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun restoreTask (Lcom/pasich/encly/data/model/Task;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setSubtaskCompleted (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun shiftSubtasksFrom (JILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateTaskStatus (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TasksDao$DefaultImpls { + public static fun appendSubtask (Lcom/pasich/encly/data/database/dao/TasksDao;JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun replaceSubtasks (Lcom/pasich/encly/data/database/dao/TasksDao;JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun restoreSubtask (Lcom/pasich/encly/data/database/dao/TasksDao;Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun restoreTask (Lcom/pasich/encly/data/database/dao/TasksDao;Lcom/pasich/encly/data/model/Task;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TasksDao_Impl : com/pasich/encly/data/database/dao/TasksDao { + public static final field Companion Lcom/pasich/encly/data/database/dao/TasksDao_Impl$Companion; + public fun (Landroidx/room/RoomDatabase;)V + public fun appendSubtask (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAllCompletedTasks (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteSubtaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteSubtasksExcept (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getActiveTasksCount ()Lkotlinx/coroutines/flow/Flow; + public fun getAllActiveTasks ()Lkotlinx/coroutines/flow/Flow; + public fun getAllCompletedTasks ()Lkotlinx/coroutines/flow/Flow; + public fun getAllTasks ()Lkotlinx/coroutines/flow/Flow; + public fun getCompletedTasksCount ()Lkotlinx/coroutines/flow/Flow; + public fun getSubtasks (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getTaskById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun insertTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun nextSubtaskPosition (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun observeSubtasks ()Lkotlinx/coroutines/flow/Flow; + public fun renameSubtask (JLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun replaceSubtasks (JLjava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun restoreSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun restoreTask (Lcom/pasich/encly/data/model/Task;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun setSubtaskCompleted (JZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun shiftSubtasksFrom (JILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateSubtask (Lcom/pasich/encly/data/model/Subtask;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTask (Lcom/pasich/encly/data/model/Task;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTaskStatus (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/data/database/dao/TasksDao_Impl$Companion { + public final fun getRequiredConverters ()Ljava/util/List; +} + +public final class com/pasich/encly/data/model/Note { + public static final field Companion Lcom/pasich/encly/data/model/Note$Companion; + public static final field NO_COLOR Ljava/lang/String; + public fun ()V + public fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/Set;)V + public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component10 ()Z + public final fun component11 ()Z + public final fun component12 ()Ljava/lang/String; + public final fun component13 ()Ljava/lang/Integer; + public final fun component14 ()Ljava/lang/Integer; + public final fun component15 ()Ljava/lang/Long; + public final fun component16 ()Ljava/util/Set; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()J + public final fun component7 ()Z + public final fun component8 ()Z + public final fun component9 ()Ljava/lang/String; + public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/Set;)Lcom/pasich/encly/data/model/Note; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/Note;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/Set;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; + public fun equals (Ljava/lang/Object;)Z + public final fun getColor ()Ljava/lang/String; + public final fun getDate ()J + public final fun getDateCreate ()J + public final fun getDescription ()Ljava/lang/String; + public final fun getId ()J + public final fun getLineSpacing ()Ljava/lang/Integer; + public final fun getTagIds ()Ljava/util/Set; + public final fun getTextSize ()Ljava/lang/Integer; + public final fun getTitle ()Ljava/lang/String; + public final fun getTrashedAt ()Ljava/lang/Long; + public final fun getUid ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public final fun hasContentChanged (Ljava/lang/String;Ljava/lang/String;)Z + public fun hashCode ()I + public final fun isArchived ()Z + public final fun isChecked ()Z + public final fun isEmpty ()Z + public final fun isPinned ()Z + public final fun isTrash ()Z + public final fun setArchived (Z)V + public final fun setChecked (Z)V + public final fun setColor (Ljava/lang/String;)V + public final fun setDate (J)V + public final fun setDateCreate (J)V + public final fun setDescription (Ljava/lang/String;)V + public final fun setId (J)V + public final fun setLineSpacing (Ljava/lang/Integer;)V + public final fun setPinned (Z)V + public final fun setTagIds (Ljava/util/Set;)V + public final fun setTextSize (Ljava/lang/Integer;)V + public final fun setTitle (Ljava/lang/String;)V + public final fun setTrash (Z)V + public final fun setTrashedAt (Ljava/lang/Long;)V + public final fun setUid (Ljava/lang/String;)V + public final fun setValue (Ljava/lang/String;)V + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Note$Companion { + public final fun new (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;)Lcom/pasich/encly/data/model/Note; + public static synthetic fun new$default (Lcom/pasich/encly/data/model/Note$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; +} + +public final class com/pasich/encly/data/model/NoteTag { + public fun (JJ)V + public final fun component1 ()J + public final fun component2 ()J + public final fun copy (JJ)Lcom/pasich/encly/data/model/NoteTag; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/NoteTag;JJILjava/lang/Object;)Lcom/pasich/encly/data/model/NoteTag; + public fun equals (Ljava/lang/Object;)Z + public final fun getNoteId ()J + public final fun getTagId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/NoteWithTags { + public fun (Lcom/pasich/encly/data/model/Note;Ljava/util/List;)V + public final fun component1 ()Lcom/pasich/encly/data/model/Note; + public final fun component2 ()Ljava/util/List; + public final fun copy (Lcom/pasich/encly/data/model/Note;Ljava/util/List;)Lcom/pasich/encly/data/model/NoteWithTags; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/NoteWithTags;Lcom/pasich/encly/data/model/Note;Ljava/util/List;ILjava/lang/Object;)Lcom/pasich/encly/data/model/NoteWithTags; + public fun equals (Ljava/lang/Object;)Z + public final fun getNote ()Lcom/pasich/encly/data/model/Note; + public final fun getTags ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Subtask { + public fun (JJLjava/lang/String;ZILjava/lang/String;)V + public synthetic fun (JJLjava/lang/String;ZILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()I + public final fun component6 ()Ljava/lang/String; + public final fun copy (JJLjava/lang/String;ZILjava/lang/String;)Lcom/pasich/encly/data/model/Subtask; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/Subtask;JJLjava/lang/String;ZILjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Subtask; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()J + public final fun getPosition ()I + public final fun getTaskId ()J + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Tag { + public fun ()V + public fun (JLjava/lang/String;ZILjava/lang/String;)V + public synthetic fun (JLjava/lang/String;ZILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun component4 ()I + public final fun component5 ()Ljava/lang/String; + public final fun copy (JLjava/lang/String;ZILjava/lang/String;)Lcom/pasich/encly/data/model/Tag; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/Tag;JLjava/lang/String;ZILjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Tag; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()J + public final fun getNameTag ()Ljava/lang/String; + public final fun getPosition ()I + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isVisible ()Z + public final fun setId (J)V + public final fun setNameTag (Ljava/lang/String;)V + public final fun setPosition (I)V + public final fun setUid (Ljava/lang/String;)V + public final fun setVisible (Z)V + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/TagNoteCount { + public fun (JI)V + public final fun component1 ()J + public final fun component2 ()I + public final fun copy (JI)Lcom/pasich/encly/data/model/TagNoteCount; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/TagNoteCount;JIILjava/lang/Object;)Lcom/pasich/encly/data/model/TagNoteCount; + public fun equals (Ljava/lang/Object;)Z + public final fun getCount ()I + public final fun getTagId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Task { + public static final field Companion Lcom/pasich/encly/data/model/Task$Companion; + public fun (JLjava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/Long;ILjava/lang/String;)V + public synthetic fun (JLjava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/Long;ILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component10 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()J + public final fun component6 ()Ljava/lang/Long; + public final fun component7 ()I + public final fun component8 ()Ljava/lang/Long; + public final fun component9 ()I + public final fun copy (JLjava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/Long;ILjava/lang/String;)Lcom/pasich/encly/data/model/Task; + public static synthetic fun copy$default (Lcom/pasich/encly/data/model/Task;JLjava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/Long;ILjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Task; + public fun equals (Ljava/lang/Object;)Z + public final fun getCategoryId ()Ljava/lang/Long; + public final fun getCompletedDate ()Ljava/lang/Long; + public final fun getCreatedDate ()J + public final fun getDescription ()Ljava/lang/String; + public final fun getId ()J + public final fun getPosition ()I + public final fun getPriority ()I + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public final fun setPosition (I)V + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/data/model/Task$Companion { + public final fun new (Ljava/lang/String;Ljava/lang/String;ILjava/lang/Long;)Lcom/pasich/encly/data/model/Task; + public static synthetic fun new$default (Lcom/pasich/encly/data/model/Task$Companion;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Long;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Task; +} + diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts new file mode 100644 index 00000000..1999f529 --- /dev/null +++ b/core/database/build.gradle.kts @@ -0,0 +1,135 @@ +// The encrypted vault database as a Kotlin Multiplatform library (Android, desktop JVM, iOS): the Room +// schema, the entities and DAOs, the migrations and SecureDatabaseManager, which opens the database +// through Sealite (SQLCipher). The app supplies where the files are through DatabasePlatform. +import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension + +plugins { + id("encly.kmp-library") + id("encly.kmp-ios") + id("encly.quality") + alias(libs.plugins.ksp) + alias(libs.plugins.androidx.room) + alias(libs.plugins.metro) +} + +// The shared detekt config plus the module's own (the tests are left out of the style and complexity rules). +// Findings that predate the move out of the app are captured in detekt-baseline.xml, which should only +// ever shrink. +detekt { + config.setFrom(rootProject.files("config/detekt/detekt.yml"), files("config/detekt.yml")) + baseline = file("detekt-baseline.xml") +} + +// Minimum line coverage (%) of the whole module enforced by koverVerify. +val databaseMinCoverage = 85 + +kotlin { + android { + namespace = "com.pasich.encly.core.database" + // Android resources (and so assets) are off in a Kotlin Multiplatform library unless asked for; the + // device tests need the exported schemas as assets of their app. + androidResources { + enable = true + } + // Instrumented tests (native SQLCipher on a device, every schema migration): task + // `connectedAndroidDeviceTest`, in the source set `androidDeviceTest`. + withDeviceTestBuilder { sourceSetTreeName = "test" }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain.dependencies { + // RoomDatabase and the DAOs' Flow are part of the public API, and so is kotlinx-io's Path. + api(libs.androidx.room.runtime) + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.io.core) + // The cipher: SQLCipher through the androidx.sqlite driver API, on every target. + implementation(libs.sealite) + // SynchronizedObject: the lock of SecureDatabaseManager that @Synchronized would not give iOS. + implementation(libs.kotlinx.atomicfu) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + getByName("desktopTest").dependencies { + implementation(libs.kotlinx.serialization.json) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.androidx.test.core) + implementation(libs.mockito.core) + implementation(libs.robolectric) + // Room's migrations run against the platform SQLite under Robolectric. + implementation(libs.androidx.sqlite.framework) + implementation(libs.kotlinx.serialization.json) + } + getByName("androidDeviceTest").dependencies { + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.androidx.test.core) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.room.testing) + implementation(libs.androidx.sqlite.framework) + // Writes a vault the way earlier releases did, to check that the current driver opens it. + implementation(libs.sqlcipher.android) + } + } +} + +// Room's compiler runs once per target (KSP), and writes the database implementation and the actual of +// AppDatabaseConstructor for it. The configurations are added as KSP creates them. +val roomCompilerTargets = setOf("kspAndroid", "kspDesktop", "kspIosArm64", "kspIosSimulatorArm64") +configurations.configureEach { + if (name in roomCompilerTargets) { + project.dependencies.add(name, libs.androidx.room.compiler) + } +} + +// The exported schemas (core/database/schemas) are committed, so every schema change is reviewed and the +// migration tests can validate upgrades against them. +room { + schemaDirectory("$projectDir/schemas") +} + +// The device tests' MigrationTestHelper reads the exported schemas as assets of the test app, at the +// assets root (//.json), as the app's androidTest did. +extensions.getByType().onVariants { variant -> + variant.deviceTests.values.forEach { deviceTest -> + deviceTest.sources.assets?.addStaticSourceDirectory(layout.projectDirectory.dir("schemas").asFile.path) + } +} + +tasks.withType().configureEach { + // Sealite loads its native library into the test JVM; be explicit about native access (JDK 24+ warns). + jvmArgs("--enable-native-access=ALL-UNNAMED") +} + +// Coverage: `./gradlew :core:database:koverVerify`, over the desktop JVM tests and the Android host tests. +kover { + reports { + total { + filters { + // Code the compilers and annotation processors write, not this project. + excludes { + classes( + "*_Impl", + "*_Impl$*", + "*AppDatabaseConstructor*", + "*MetroFactory*", + "*MetroContributionTo*", + ) + } + } + xml { onCheck.set(false) } + html { onCheck.set(false) } + verify { + rule("Database line coverage") { + minBound(databaseMinCoverage) + } + } + } + } +} diff --git a/core/database/config/detekt.yml b/core/database/config/detekt.yml new file mode 100644 index 00000000..d07d8e44 --- /dev/null +++ b/core/database/config/detekt.yml @@ -0,0 +1,13 @@ +# Database-module detekt rules, layered on config/detekt/detekt.yml. The module's source sets +# `commonTest`, `desktopTest`, `androidHostTest` and `androidDeviceTest` are not among the ones detekt +# leaves out of the style and complexity rules by itself (test, commonTest, jvmTest, ...): the tests +# build keys, pages and counts from literals and have one function per case, so they are named here. +complexity: + excludes: &tests + - '**/commonTest/**' + - '**/desktopTest/**' + - '**/androidHostTest/**' + - '**/androidDeviceTest/**' + +style: + excludes: *tests diff --git a/core/database/detekt-baseline.xml b/core/database/detekt-baseline.xml new file mode 100644 index 00000000..12a0b387 --- /dev/null +++ b/core/database/detekt-baseline.xml @@ -0,0 +1,8 @@ + + + + + TooManyFunctions:SecureDatabaseManager.kt$SecureDatabaseManager : DatabaseProvider + TooManyFunctions:TasksDao.kt$TasksDao + + diff --git a/app/schemas/com.pasich.encly.data.database.AppDatabase/1.json b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/1.json similarity index 100% rename from app/schemas/com.pasich.encly.data.database.AppDatabase/1.json rename to core/database/schemas/com.pasich.encly.data.database.AppDatabase/1.json diff --git a/app/schemas/com.pasich.encly.data.database.AppDatabase/2.json b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/2.json similarity index 100% rename from app/schemas/com.pasich.encly.data.database.AppDatabase/2.json rename to core/database/schemas/com.pasich.encly.data.database.AppDatabase/2.json diff --git a/app/schemas/com.pasich.encly.data.database.AppDatabase/3.json b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/3.json similarity index 100% rename from app/schemas/com.pasich.encly.data.database.AppDatabase/3.json rename to core/database/schemas/com.pasich.encly.data.database.AppDatabase/3.json diff --git a/app/schemas/com.pasich.encly.data.database.AppDatabase/4.json b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/4.json similarity index 100% rename from app/schemas/com.pasich.encly.data.database.AppDatabase/4.json rename to core/database/schemas/com.pasich.encly.data.database.AppDatabase/4.json diff --git a/app/schemas/com.pasich.encly.data.database.AppDatabase/5.json b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/5.json similarity index 100% rename from app/schemas/com.pasich.encly.data.database.AppDatabase/5.json rename to core/database/schemas/com.pasich.encly.data.database.AppDatabase/5.json diff --git a/app/schemas/com.pasich.encly.data.database.AppDatabase/6.json b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/6.json similarity index 100% rename from app/schemas/com.pasich.encly.data.database.AppDatabase/6.json rename to core/database/schemas/com.pasich.encly.data.database.AppDatabase/6.json diff --git a/core/database/schemas/com.pasich.encly.data.database.AppDatabase/7.json b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/7.json new file mode 100644 index 00000000..1114c878 --- /dev/null +++ b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/7.json @@ -0,0 +1,343 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "8be83ca1b6d7324f0a19099950010fe6", + "entities": [ + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `value` TEXT NOT NULL, `description` TEXT NOT NULL, `date` INTEGER NOT NULL, `dateCreate` INTEGER NOT NULL, `tagId` INTEGER, `isTrash` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '', `isPinned` INTEGER NOT NULL DEFAULT 0, `isArchived` INTEGER NOT NULL DEFAULT 0, `color` TEXT NOT NULL DEFAULT 'none', `textSize` INTEGER, `lineSpacing` INTEGER, `trashedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateCreate", + "columnName": "dateCreate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'none'" + }, + { + "fieldPath": "textSize", + "columnName": "textSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "lineSpacing", + "columnName": "lineSpacing", + "affinity": "INTEGER" + }, + { + "fieldPath": "trashedAt", + "columnName": "trashedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_notes_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_notes_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameTag", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isVisible", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tags_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `isCompleted` INTEGER NOT NULL, `createdDate` INTEGER NOT NULL, `completedDate` INTEGER, `priority` INTEGER NOT NULL, `categoryId` INTEGER, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "isCompleted", + "columnName": "isCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdDate", + "columnName": "createdDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedDate", + "columnName": "completedDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "subtasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `taskId` INTEGER NOT NULL, `title` TEXT NOT NULL, `isCompleted` INTEGER NOT NULL, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '', FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCompleted", + "columnName": "isCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subtasks_taskId", + "unique": false, + "columnNames": [ + "taskId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subtasks_taskId` ON `${TABLE_NAME}` (`taskId`)" + }, + { + "name": "index_subtasks_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subtasks_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "taskId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8be83ca1b6d7324f0a19099950010fe6')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/com.pasich.encly.data.database.AppDatabase/8.json b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/8.json new file mode 100644 index 00000000..d567d8a1 --- /dev/null +++ b/core/database/schemas/com.pasich.encly.data.database.AppDatabase/8.json @@ -0,0 +1,398 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "4631bf46afa7cef70162e386d08a3f58", + "entities": [ + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `value` TEXT NOT NULL, `description` TEXT NOT NULL, `date` INTEGER NOT NULL, `dateCreate` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '', `isPinned` INTEGER NOT NULL DEFAULT 0, `isArchived` INTEGER NOT NULL DEFAULT 0, `color` TEXT NOT NULL DEFAULT 'none', `textSize` INTEGER, `lineSpacing` INTEGER, `trashedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateCreate", + "columnName": "dateCreate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'none'" + }, + { + "fieldPath": "textSize", + "columnName": "textSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "lineSpacing", + "columnName": "lineSpacing", + "affinity": "INTEGER" + }, + { + "fieldPath": "trashedAt", + "columnName": "trashedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_notes_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_notes_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameTag", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isVisible", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tags_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `isCompleted` INTEGER NOT NULL, `createdDate` INTEGER NOT NULL, `completedDate` INTEGER, `priority` INTEGER NOT NULL, `categoryId` INTEGER, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "isCompleted", + "columnName": "isCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdDate", + "columnName": "createdDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedDate", + "columnName": "completedDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "subtasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `taskId` INTEGER NOT NULL, `title` TEXT NOT NULL, `isCompleted` INTEGER NOT NULL, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '', FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCompleted", + "columnName": "isCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subtasks_taskId", + "unique": false, + "columnNames": [ + "taskId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subtasks_taskId` ON `${TABLE_NAME}` (`taskId`)" + }, + { + "name": "index_subtasks_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subtasks_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "taskId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`noteId` INTEGER NOT NULL, `tagId` INTEGER NOT NULL, PRIMARY KEY(`noteId`, `tagId`), FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "noteId", + "tagId" + ] + }, + "indices": [ + { + "name": "index_note_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tags", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tagId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4631bf46afa7cef70162e386d08a3f58')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt new file mode 100644 index 00000000..befe6f0b --- /dev/null +++ b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt @@ -0,0 +1,272 @@ +package com.pasich.encly.data.database + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.driver.AndroidSQLiteDriver +import androidx.sqlite.execSQL +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Validates the committed Room schema export (app/schemas) against the compiled entities. + * + * Every future DB_VERSION bump must add its migration and a `runMigrationsAndValidate` case + * from the previous exported version here. The vault builder (VaultSchema.databaseBuilder) has + * no destructive fallback, so a missing migration crashes instead of silently dropping the + * user's encrypted notes. + * The helper runs the migrations through the androidx.sqlite driver API (SQLiteConnection), the + * same API the vault uses, but on a plain (non-SQLCipher) test database: it checks the schema, + * not encryption. + */ +@RunWith(AndroidJUnit4::class) +class AppDatabaseSchemaTest { + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @get:Rule + val helper = MigrationTestHelper( + instrumentation = instrumentation, + file = instrumentation.targetContext.getDatabasePath(TEST_DB), + driver = AndroidSQLiteDriver(), + databaseClass = AppDatabase::class, + ) + + // The driver-based helper creates the file itself and refuses one that already exists. + @Before + fun deleteTestDatabase() { + val path = instrumentation.targetContext.getDatabasePath(TEST_DB).path + listOf("", "-wal", "-shm", "-journal").forEach { File(path + it).delete() } + } + + @Test + fun currentSchemaMatchesExport() { + helper.createDatabase(DB_VERSION).close() + helper.runMigrationsAndValidate(DB_VERSION, emptyList()).close() + } + + @Test + fun migrate2To3DropsReminderAndKeepsTasks() { + helper.createDatabase(2).use { db -> + db.execSQL( + "INSERT INTO tasks (id, title, description, isCompleted, createdDate, completedDate, " + + "reminderDate, priority, categoryId, position, uid) " + + "VALUES (7, 'Buy milk', '2 l', 0, 100, NULL, 200, 2, NULL, 3, 'task-uid')", + ) + } + + helper.runMigrationsAndValidate(3, listOf(VaultSchema.MIGRATION_2_3)).use { db -> + db.prepare("SELECT id, title, description, priority, position, uid FROM tasks").use { s -> + assertTrue(s.step()) + assertEquals(7L, s.getLong(0)) + assertEquals("Buy milk", s.getText(1)) + assertEquals("2 l", s.getText(2)) + assertEquals(2, s.getLong(3).toInt()) + assertEquals(3, s.getLong(4).toInt()) + assertEquals("task-uid", s.getText(5)) + } + db.prepare("PRAGMA table_info(tasks)").use { s -> + // table_info columns: cid, name, type, notnull, dflt_value, pk + while (s.step()) assertNotEquals("reminderDate", s.getText(1)) + } + + // The uid triggers are rebuilt with the table: a blank uid is still filled in. + db.execSQL( + "INSERT INTO tasks (title, isCompleted, createdDate, priority, position) VALUES ('x', 0, 1, 0, 0)", + ) + db.prepare("SELECT uid FROM tasks WHERE title = 'x'").use { s -> + assertTrue(s.step()) + assertEquals(32, s.getText(0).length) + } + } + } + + @Test + fun migrate3To4AddsSubtasksWithTriggersAndCascade() { + helper.createDatabase(3).use { db -> + db.execSQL( + "INSERT INTO tasks (id, title, isCompleted, createdDate, priority, position, uid) " + + "VALUES (7, 'Buy milk', 0, 100, 2, 3, 'task-uid')", + ) + } + + helper.runMigrationsAndValidate(4, listOf(VaultSchema.MIGRATION_3_4)).use { db -> + db.prepare("SELECT id, title, uid FROM tasks").use { s -> + assertTrue(s.step()) + assertEquals(7L, s.getLong(0)) + assertEquals("Buy milk", s.getText(1)) + assertEquals("task-uid", s.getText(2)) + } + db.prepare("SELECT COUNT(*) FROM subtasks").use { s -> + assertTrue(s.step()) + assertEquals(0, s.getLong(0).toInt()) + } + + // A blank uid is filled in, and a later blank update keeps it. + db.execSQL("INSERT INTO subtasks (taskId, title, isCompleted, position) VALUES (7, 'Oat', 0, 0)") + val uid = db.prepare("SELECT uid FROM subtasks").use { s -> + assertTrue(s.step()) + s.getText(0) + } + assertEquals(32, uid.length) + db.execSQL("UPDATE subtasks SET uid = '' WHERE taskId = 7") + db.prepare("SELECT uid FROM subtasks").use { s -> + assertTrue(s.step()) + assertEquals(uid, s.getText(0)) + } + + // Deleting the task deletes its sub-tasks (Room turns foreign keys on when it opens the vault). + db.execSQL("PRAGMA foreign_keys = ON") + db.execSQL("DELETE FROM tasks WHERE id = 7") + db.prepare("SELECT COUNT(*) FROM subtasks").use { s -> + assertTrue(s.step()) + assertEquals(0, s.getLong(0).toInt()) + } + } + } + + @Test + fun migrate4To5KeepsNotesUnpinnedUnarchivedUncoloured() { + helper.createDatabase(4).use { db -> + // A real version-4 vault has the uid triggers (the creation callback or 3 -> 4 adds + // them); the helper builds the tables from the export only. + VaultSchema.createTriggers(db) + db.execSQL( + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (3, 'Groceries', '[]', 'milk', 200, 100, NULL, 1, 'note-uid')", + ) + } + + helper.runMigrationsAndValidate(5, listOf(VaultSchema.MIGRATION_4_5)).use { db -> + db.prepare( + "SELECT title, isTrash, uid, isPinned, isArchived, color FROM notes WHERE id = 3", + ).use { s -> + assertTrue(s.step()) + assertEquals("Groceries", s.getText(0)) + assertEquals(1, s.getLong(1).toInt()) + assertEquals("note-uid", s.getText(2)) + assertEquals(0, s.getLong(3).toInt()) + assertEquals(0, s.getLong(4).toInt()) + assertEquals("none", s.getText(5)) + } + + // A note inserted by older-style code (no new columns) still gets the defaults. + db.execSQL( + "INSERT INTO notes (title, value, description, date, dateCreate, isTrash) " + + "VALUES ('n', '[]', '', 1, 1, 0)", + ) + db.prepare("SELECT isPinned, isArchived, color, uid FROM notes WHERE title = 'n'").use { s -> + assertTrue(s.step()) + assertEquals(0, s.getLong(0).toInt()) + assertEquals(0, s.getLong(1).toInt()) + assertEquals("none", s.getText(2)) + assertEquals(32, s.getText(3).length) + } + } + } + + @Test + fun migrate5To6AddsNullableTextSettings() { + helper.createDatabase(5).use { db -> + db.execSQL( + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid, " + + "isPinned, isArchived, color) VALUES (3, 'Groceries', '[]', 'milk', 200, 100, NULL, 0, " + + "'note-uid', 1, 0, 'red')", + ) + } + + helper.runMigrationsAndValidate(6, listOf(VaultSchema.MIGRATION_5_6)).use { db -> + db.prepare("SELECT title, isPinned, color, textSize, lineSpacing FROM notes WHERE id = 3").use { s -> + assertTrue(s.step()) + assertEquals("Groceries", s.getText(0)) + assertEquals(1, s.getLong(1).toInt()) + assertEquals("red", s.getText(2)) + assertTrue(s.isNull(3)) + assertTrue(s.isNull(4)) + } + } + } + + @Test + fun migrate6To7StampsTheNotesAlreadyInTheTrash() { + helper.createDatabase(6).use { db -> + db.execSQL( + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (1, 'Trashed', '[]', 'd', 20, 10, NULL, 1, 'trashed-uid')", + ) + db.execSQL( + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (2, 'Kept', '[]', 'd', 20, 10, NULL, 0, 'kept-uid')", + ) + } + + helper.runMigrationsAndValidate(7, listOf(VaultSchema.MIGRATION_6_7)).use { db -> + db.prepare("SELECT trashedAt FROM notes WHERE id = 1").use { s -> + assertTrue(s.step()) + assertTrue(s.getLong(0) > 0) + } + db.prepare("SELECT trashedAt FROM notes WHERE id = 2").use { s -> + assertTrue(s.step()) + assertTrue(s.isNull(0)) + } + } + } + + @Test + fun migrate1ToCurrentRunsTheWholeChain() { + helper.createDatabase(1).close() + + helper.runMigrationsAndValidate( + DB_VERSION, + listOf( + VaultSchema.MIGRATION_1_2, + VaultSchema.MIGRATION_2_3, + VaultSchema.MIGRATION_3_4, + VaultSchema.MIGRATION_4_5, + VaultSchema.MIGRATION_5_6, + VaultSchema.MIGRATION_6_7, + VaultSchema.MIGRATION_7_8, + ), + ).close() + } + + @Test + fun migrate7To8MovesEachNotesTagIntoNoteTags() { + helper.createDatabase(7).use { db -> + VaultSchema.createTriggers(db) + db.execSQL("INSERT INTO tags (id, name, visibility, position, uid) VALUES (1, 'Work', 1, 0, 'tag-uid')") + db.execSQL( + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (1, 'Tagged', '[]', 'd', 20, 10, 1, 0, 'tagged-uid')", + ) + db.execSQL( + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (2, 'Dangling', '[]', 'd', 20, 10, 9, 0, 'dangling-uid')", + ) + } + + helper.runMigrationsAndValidate(8, listOf(VaultSchema.MIGRATION_7_8)).use { db -> + db.prepare("SELECT noteId, tagId FROM note_tags ORDER BY noteId").use { s -> + assertTrue(s.step()) + assertEquals(1, s.getLong(0).toInt()) + assertEquals(1, s.getLong(1).toInt()) + assertTrue(!s.step()) + } + db.prepare("SELECT title, uid FROM notes WHERE id = 2").use { s -> + assertTrue(s.step()) + assertEquals("Dangling", s.getText(0)) + assertEquals("dangling-uid", s.getText(1)) + } + } + } + + private companion object { + const val TEST_DB = "schema-test.db" + } +} diff --git a/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/EarlierReleaseVaultTest.kt b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/EarlierReleaseVaultTest.kt new file mode 100644 index 00000000..ec634136 --- /dev/null +++ b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/EarlierReleaseVaultTest.kt @@ -0,0 +1,80 @@ +package com.pasich.encly.data.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.pasich.encly.data.model.Note +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.security.SecureRandom + +/** + * A vault written the way releases up to 2.3 wrote it (sqlcipher-android 4.19, the DEK as a raw + * key `x'<64 hex>'`) opens with the current driver: same notes, still writable, and still refused + * under another key. Existing installs depend on it. + * + * Runs inside the app under test and deletes its database.db: run it on an emulator. + */ +@RunWith(AndroidJUnit4::class) +class EarlierReleaseVaultTest { + private val context = ApplicationProvider.getApplicationContext() + private lateinit var manager: SecureDatabaseManager + private val dek = ByteArray(32).also(SecureRandom()::nextBytes) + + @Before + fun setUp() { + manager = SecureDatabaseManager(AndroidDatabasePlatform(context)) + manager.wipe() + } + + @After + fun tearDown() { + manager.wipe() + } + + @Test + fun aVaultOfAnEarlierReleaseOpensWithItsNotes() = runBlocking { + writeVaultTheEarlierWay(listOf("first", "second")) + + assertTrue(manager.unlockDatabase(dek)) + val db = manager.getDatabase(manager.captureLease()!!) + assertEquals(setOf("first", "second"), db.notesDao().getAllNotes().first().map { it.title }.toSet()) + + db.notesDao().insertNote(Note(title = "third")) + manager.reset() + + assertFalse(manager.unlockDatabase(ByteArray(32) { 7 })) + assertTrue(manager.unlockDatabase(dek)) + val reopened = manager.getDatabase(manager.captureLease()!!) + assertEquals(3, reopened.notesDao().getAllNotes().first().size) + } + + private suspend fun writeVaultTheEarlierWay(titles: List) { + System.loadLibrary("sqlcipher") + val passphrase = rawKey(dek) + val db = VaultSchema.install( + Room.databaseBuilder(context, AppDatabase::class.java, VaultFile.PRIMARY.fileName), + ) + .openHelperFactory(SupportOpenHelperFactory(passphrase, null, false)) + .build() + try { + titles.forEach { db.notesDao().insertNote(Note(title = it)) } + } finally { + db.close() + } + assertTrue(manager.hasEncryptedDatabase()) + } + + /** The key form releases up to 2.3 handed to sqlcipher-android: `x''` as ASCII. */ + private fun rawKey(key: ByteArray): ByteArray = + ("x'" + key.joinToString("") { "%02x".format(it) } + "'").toByteArray(Charsets.US_ASCII) +} diff --git a/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt similarity index 96% rename from app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt rename to core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt index 44fab51f..c1627103 100644 --- a/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt +++ b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt @@ -31,7 +31,7 @@ class SecureDatabaseManagerInstrumentedTest { @Before fun setUp() { - manager = SecureDatabaseManager(context) + manager = SecureDatabaseManager(AndroidDatabasePlatform(context)) manager.wipe() } @@ -67,7 +67,7 @@ class SecureDatabaseManagerInstrumentedTest { assertFalse(manager.isDatabaseUnlocked()) assertTrue(manager.unlockDatabase(KEY_A)) assertTrue(runCatching { manager.getDatabase(oldLease) }.isFailure) - assertTrue(manager.getDatabase(requireNotNull(manager.captureLease())).isOpen) + assertTrue(manager.isDatabaseUnlocked()) assertEquals(NOTE_COUNT, manager.getDatabase(manager.captureLease()!!).notesDao().getAllNotes().first().size) } diff --git a/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/TagsDaoTest.kt b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/TagsDaoTest.kt new file mode 100644 index 00000000..4a367a41 --- /dev/null +++ b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/TagsDaoTest.kt @@ -0,0 +1,48 @@ +package com.pasich.encly.data.database + +import androidx.room.Room +import androidx.sqlite.driver.AndroidSQLiteDriver +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Tag +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** Deleting a tag must not leave notes pointing at it. Plain (non-SQLCipher) in-memory DB. */ +@RunWith(AndroidJUnit4::class) +class TagsDaoTest { + private lateinit var db: AppDatabase + + @Before + fun setUp() { + db = VaultSchema.install( + Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ), + ).setDriver(AndroidSQLiteDriver()).build() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun deletingATagUntagsItsNotesAndLeavesOthersAlone() = runBlocking { + val work = db.tagsDao().addTag(Tag(nameTag = "Work")) + val home = db.tagsDao().addTag(Tag(nameTag = "Home")) + val workNote = db.notesDao().insertNoteWithTags(Note(title = "w", tagIds = setOf(work))) + val homeNote = db.notesDao().insertNoteWithTags(Note(title = "h", tagIds = setOf(work, home))) + + assertEquals(1, db.tagsDao().deleteTagDetachingNotes(Tag(id = work, nameTag = "Work"))) + + assertEquals(emptyList(), db.notesDao().getNoteWithTags(workNote)?.tags?.map { it.id }) + assertEquals(listOf(home), db.notesDao().getNoteWithTags(homeNote)?.tags?.map { it.id }) + } +} diff --git a/core/database/src/androidHostTest/kotlin/com/pasich/encly/core/security/cipher/SQLCipherUtilsTest.kt b/core/database/src/androidHostTest/kotlin/com/pasich/encly/core/security/cipher/SQLCipherUtilsTest.kt new file mode 100644 index 00000000..334df1d0 --- /dev/null +++ b/core/database/src/androidHostTest/kotlin/com/pasich/encly/core/security/cipher/SQLCipherUtilsTest.kt @@ -0,0 +1,57 @@ +package com.pasich.encly.core.security.cipher + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.core.security.cipher.SQLCipherUtils.State +import kotlinx.io.files.Path +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** The database file is classified by its header alone: missing, plaintext SQLite, or anything else. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SQLCipherUtilsTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + + private fun dbFile(name: String): File = context.getDatabasePath(name).also { it.parentFile?.mkdirs() } + + private fun stateOf(name: String): State = SQLCipherUtils.getDatabaseState(Path(dbFile(name).path)) + + @Test + fun aMissingFileDoesNotExist() { + assertEquals(State.DOES_NOT_EXIST, stateOf("no-such.db")) + } + + @Test + fun aFileWithTheRealSqliteHeaderIsPlaintext() { + dbFile("plain.db").writeBytes("SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) + ByteArray(100)) + + assertEquals(State.UNENCRYPTED, stateOf("plain.db")) + } + + @Test + fun aFileWithAnyOtherHeaderIsTreatedAsEncrypted() { + dbFile("cipher.db").writeBytes(ByteArray(4096) { (it * 31).toByte() }) + + assertEquals(State.ENCRYPTED, stateOf("cipher.db")) + } + + @Test + fun aFileShorterThanTheMagicIsNotPlaintext() { + dbFile("short.db").writeBytes("SQLite".toByteArray()) + + assertEquals(State.ENCRYPTED, stateOf("short.db")) + } + + @Test + fun anUnreadablePathIsTreatedAsEncryptedNotAsPlaintext() { + // A directory exists but cannot be read as a file: failing closed means "encrypted". + dbFile("dir.db").also { it.mkdirs() } + + assertEquals(State.ENCRYPTED, stateOf("dir.db")) + } +} diff --git a/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/AndroidDatabasePlatformTest.kt b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/AndroidDatabasePlatformTest.kt new file mode 100644 index 00000000..84527a3d --- /dev/null +++ b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/AndroidDatabasePlatformTest.kt @@ -0,0 +1,91 @@ +package com.pasich.encly.data.database + +import android.app.Application +import androidx.room.Room +import androidx.sqlite.driver.AndroidSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.data.model.Note +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +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.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * The files of the vault are exactly where they were before the database moved into its own module: + * `Context.getDatabasePath("database.db")` and `("vault_b.db")`. A vault written by an earlier + * release is found only there. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class AndroidDatabasePlatformTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + private val platform get() = AndroidDatabasePlatform(context) + + @Before + fun setUp() { + deleteFiles() + } + + @After + fun tearDown() { + deleteFiles() + } + + private fun deleteFiles() = VaultFile.entries.forEach { + listOf("", "-wal", "-shm", "-journal").forEach { suffix -> + File(context.getDatabasePath(it.fileName).path + suffix).delete() + } + } + + @Test + fun theFilesKeepTheirNamesAndTheAppDatabaseDirectory() { + assertEquals("database.db", File(platform.databasePath(VaultFile.PRIMARY).toString()).name) + assertEquals("vault_b.db", File(platform.databasePath(VaultFile.DECOY).toString()).name) + VaultFile.entries.forEach { + assertEquals( + context.getDatabasePath(it.fileName).absolutePath, + platform.databasePath(it).toString(), + ) + } + } + + @Test + fun theDatabaseDirectoryExistsForTheFirstRun() { + val directory = File(platform.databasePath(VaultFile.PRIMARY).toString()).parentFile + + assertTrue(checkNotNull(directory).isDirectory) + } + + @Test + fun roomWritesTheFileWhereTheBareNameBuilderOfEarlierReleasesLookedForIt() = runBlocking { + val path = platform.databasePath(VaultFile.PRIMARY) + + val db = VaultSchema.install(platform.databaseBuilder(path)).setDriver(AndroidSQLiteDriver()).build() + try { + db.notesDao().insertNote(Note(title = "written through the platform")) + } finally { + db.close() + } + assertTrue(context.getDatabasePath("database.db").exists()) + + // The way it was opened before: Room resolves the bare file name itself. + val earlier = VaultSchema.install(Room.databaseBuilder(context, AppDatabase::class.java, "database.db")) + .setDriver(AndroidSQLiteDriver()) + .build() + try { + assertEquals( + listOf("written through the platform"), + earlier.notesDao().getAllNotes().first().map { it.title }, + ) + } finally { + earlier.close() + } + } +} diff --git a/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/NotesDaoTest.kt b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/NotesDaoTest.kt new file mode 100644 index 00000000..3c9e4ab6 --- /dev/null +++ b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/NotesDaoTest.kt @@ -0,0 +1,391 @@ +package com.pasich.encly.data.database + +import android.app.Application +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Where a note shows up, in the real Room schema (plain SQLite under Robolectric, not SQLCipher): + * the main list, the archive and the trash never overlap, and the new columns default to + * "not pinned, not archived, no colour". + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class NotesDaoTest { + private lateinit var db: AppDatabase + private val dao get() = db.notesDao() + + @Before + fun setUp() { + db = VaultSchema.install( + Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) + .allowMainThreadQueries(), + ).build() + runBlocking { db.tagsDao().addTags(listOf(Tag(id = TAG, nameTag = "X"), Tag(id = OTHER_TAG, nameTag = "Y"))) } + } + + @After + fun tearDown() { + db.close() + } + + private suspend fun insert(title: String, vararg tagIds: Long, block: Note.() -> Unit = {}): Long = + dao.insertNoteWithTags(Note(title = title, tagIds = tagIds.toSet()).apply(block)) + + private suspend fun titles(flow: Flow>) = flow.first().map { it.note.title }.sorted() + + private suspend fun tagsOf(id: Long) = checkNotNull(dao.getNoteWithTags(id)).tags.map { it.id }.toSet() + + @Test + fun aNewNoteIsNotPinnedNotArchivedAndHasNoColour() = runBlocking { + val id = insert("plain") + + val stored = checkNotNull(dao.getNoteById(id)) + + assertEquals(false, stored.isPinned) + assertEquals(false, stored.isArchived) + assertEquals("none", stored.color) + } + + @Test + fun theMainListLeavesOutArchivedAndTrashedNotes() = runBlocking { + insert("list") + insert("pinned") { isPinned = true } + insert("archived") { isArchived = true } + insert("trashed") { isTrash = true } + + assertEquals(listOf("list", "pinned"), titles(dao.getAllNotesWithTags())) + assertEquals(listOf("list", "pinned"), dao.getAllNotes().first().map { it.title }.sorted()) + } + + @Test + fun aNoteWithSeveralTagsIsListedOnceInEachOfItsTagsAndOnceInTheMainList() = runBlocking { + insert("both", TAG, OTHER_TAG) + insert("one", TAG) + + assertEquals(listOf("both", "one"), titles(dao.getNotesOfTagWithArchived(TAG))) + assertEquals(listOf("both"), titles(dao.getNotesOfTagWithArchived(OTHER_TAG))) + assertEquals(listOf("both", "one"), titles(dao.getAllNotesWithTags())) + val both = dao.getAllNotesWithTags().first().single { it.note.title == "both" } + assertEquals(setOf("X", "Y"), both.tags.map { it.nameTag }.toSet()) + } + + @Test + fun tagChangesTouchOnlyTheTagsNamed() = runBlocking { + val id = insert("note", TAG) + + dao.changeNoteTags(id, added = listOf(OTHER_TAG, TAG), removed = emptyList()) + assertEquals(setOf(TAG, OTHER_TAG), tagsOf(id)) + dao.changeNoteTags(id, added = emptyList(), removed = listOf(TAG)) + assertEquals(setOf(OTHER_TAG), tagsOf(id)) + dao.setNoteTags(id, listOf(TAG)) + assertEquals(setOf(TAG), tagsOf(id)) + dao.clearNoteTags(id) + assertEquals(emptySet(), tagsOf(id)) + } + + @Test + fun deletingATagOrANoteDeletesItsLinksAndTheNoteKeepsItsOtherTags() = runBlocking { + val kept = insert("kept", TAG, OTHER_TAG) + val gone = insert("gone", TAG) + + db.tagsDao().deleteTagDetachingNotes(Tag(id = TAG, nameTag = "X")) + assertEquals(setOf(OTHER_TAG), tagsOf(kept)) + assertEquals(emptySet(), tagsOf(gone)) + + dao.deleteNoteById(kept) + assertEquals(emptyList(), db.backupDao().allNoteTags()) + } + + @Test + fun tagCountsCountArchivedNotesButNotTrashedOnes() = runBlocking { + insert("live", TAG, OTHER_TAG) + insert("archived", TAG) { isArchived = true } + insert("trashed", TAG) { isTrash = true } + + val counts = dao.observeTagNoteCounts().first().associate { it.tagId to it.count } + assertEquals(mapOf(TAG to 2, OTHER_TAG to 1), counts) + } + + @Test + fun anEditedSaveChangesTheTagsWithTheRowAndNotWithoutIt() = runBlocking { + val id = insert("note", TAG) + + val rows = dao.saveEdited( + id, "new", "[]", false, "", 0L, false, false, false, "none", false, null, false, null, + addTags = listOf(OTHER_TAG), + removeTags = listOf(TAG), + ) + assertEquals(1, rows) + assertEquals(setOf(OTHER_TAG), tagsOf(id)) + assertEquals("new", dao.getNoteById(id)?.title) + + val missing = dao.saveEdited( + 999, "x", "[]", false, "", 0L, false, false, false, "none", false, null, false, null, + addTags = listOf(TAG), + removeTags = emptyList(), + ) + assertEquals(0, missing) + assertEquals(emptyList(), db.backupDao().noteTagsOf(999)) + } + + @Test + fun theArchiveHoldsArchivedNotesThatAreNotInTheTrash() = runBlocking { + insert("list") + insert("archived") { isArchived = true } + insert("archived and trashed") { + isArchived = true + isTrash = true + } + + assertEquals(listOf("archived"), titles(dao.getArchivedNotesWithTags())) + } + + @Test + fun aTrashedArchivedNoteIsInTheTrashAndComesBackToTheArchive() = runBlocking { + val id = insert("both") { isArchived = true } + + dao.updateTrashed(id, true, TRASHED_AT) + assertEquals(listOf("both"), dao.getTrashNotes().first().map { it.title }) + assertTrue(titles(dao.getArchivedNotesWithTags()).isEmpty()) + + dao.updateTrashed(id, false, null) + assertTrue(dao.getTrashNotes().first().isEmpty()) + assertEquals(listOf("both"), titles(dao.getArchivedNotesWithTags())) + assertTrue(titles(dao.getAllNotesWithTags()).isEmpty()) + } + + @Test + fun anEditorSaveKeepsWhatAnotherWindowDidToTheRow() = runBlocking { + val id = insert("note") { value = "old" } + // Another window moves the note to the trash and then archives and pins it... + dao.updateTrashed(id, true, TRASHED_AT) + + // ...and the editor, which opened the note before, saves its text and the colour it changed. + dao.updateEdited( + id, "edited", "new", false, "", 0L, false, false, true, "teal", false, null, false, null, + ) + + val stored = checkNotNull(dao.getNoteById(id)) + assertEquals("edited", stored.title) + assertEquals("new", stored.value) + assertEquals("teal", stored.color) + assertEquals(true, stored.isTrash) + assertEquals(TRASHED_AT, stored.trashedAt) + } + + @Test + fun anEditorSaveDoesNotUnarchiveOrUnpinWhatItDidNotChange() = runBlocking { + val id = insert("note") { + isArchived = true + isPinned = false + } + + dao.updateEdited( + id, "note", "text", false, "", 0L, false, true, false, "none", false, null, false, null, + ) + + // The editor never wrote the pin (its flag is off), and the archive is not its column at all. + val stored = checkNotNull(dao.getNoteById(id)) + assertEquals(true, stored.isArchived) + assertEquals(false, stored.isPinned) + } + + @Test + fun anEditorPinNeverLandsOnAnArchivedNote() = runBlocking { + val id = insert("note") { isArchived = true } + + dao.updateEdited( + id, "note", "", false, "", 0L, true, true, false, "none", false, null, false, null, + ) + + assertEquals(false, checkNotNull(dao.getNoteById(id)).isPinned) + } + + @Test + fun theDateAndSummaryChangeOnlyWithTheContent() = runBlocking { + val id = insert("note") { + date = 5L + description = "summary" + } + + dao.updateEdited( + id, "note", "same", false, "", 99L, false, false, false, "none", false, null, false, null, + ) + assertEquals(5L, checkNotNull(dao.getNoteById(id)).date) + assertEquals("summary", checkNotNull(dao.getNoteById(id)).description) + + dao.updateEdited( + id, "note", "changed", true, "", 99L, false, false, false, "none", false, null, false, null, + ) + assertEquals(99L, checkNotNull(dao.getNoteById(id)).date) + assertEquals("", checkNotNull(dao.getNoteById(id)).description) + } + + @Test + fun aPartialUpdateKeepsTheUid() = runBlocking { + val id = insert("note") + val uid = checkNotNull(dao.getNoteById(id)).uid + assertTrue(uid.isNotEmpty()) + + dao.updateEdited( + id, "again", "x", false, "", 0L, false, false, false, "none", false, null, false, null, + ) + + assertEquals(uid, checkNotNull(dao.getNoteById(id)).uid) + } + + @Test + fun theBackupSnapshotKeepsPinArchiveAndColour() = runBlocking { + insert("styled") { + isPinned = true + color = "teal" + } + + val stored = db.backupDao().allNotes().single() + + assertEquals(true, stored.isPinned) + assertEquals("teal", stored.color) + } + + @Test + fun aColourWrittenAfterATagChangeKeepsTheNewTag() = runBlocking { + val id = insert("note", TAG) + // What a stale sheet holds: the note as read in tag X's view. + val stale = checkNotNull(dao.getNoteById(id)) + + dao.changeNoteTags(id, added = listOf(OTHER_TAG), removed = listOf(TAG)) + dao.updateColor(stale.id, "teal") + + assertEquals(setOf(OTHER_TAG), tagsOf(id)) + assertEquals("teal", checkNotNull(dao.getNoteById(id)).color) + } + + @Test + fun everySingleColumnWriteLeavesTheOtherColumnsAlone() = runBlocking { + val id = insert("note", TAG) { + description = "d" + color = "red" + isPinned = true + } + val before = checkNotNull(dao.getNoteById(id)) + + dao.updateDescription(id, "new") + dao.updateColor(id, "blue") + assertEquals(before.copy(description = "new", color = "blue"), dao.getNoteById(id)) + dao.clearNoteTags(id) + assertEquals(before.copy(description = "new", color = "blue"), dao.getNoteById(id)) + } + + @Test + fun archivingUnpinsInTheSameWriteAndUnarchivingKeepsThePinState() = runBlocking { + val id = insert("note") { isPinned = true } + + dao.updateArchived(id, true) + val archived = checkNotNull(dao.getNoteById(id)) + assertEquals(true, archived.isArchived) + assertEquals(false, archived.isPinned) + + dao.updateArchived(id, false) + assertEquals(false, checkNotNull(dao.getNoteById(id)).isPinned) + } + + @Test + fun anArchivedNoteCannotBePinnedButCanBeUnpinned() = runBlocking { + val id = insert("note") { isArchived = true } + + dao.updatePinned(id, true) + assertEquals(false, checkNotNull(dao.getNoteById(id)).isPinned) + + dao.updateArchived(id, false) + dao.updatePinned(id, true) + assertEquals(true, checkNotNull(dao.getNoteById(id)).isPinned) + dao.updatePinned(id, false) + assertEquals(false, checkNotNull(dao.getNoteById(id)).isPinned) + } + + @Test + fun aTagsNotesAreReadWithItsArchivedOnesInOneQuery() = runBlocking { + insert("live", TAG) + insert("old", TAG) { isArchived = true } + insert("trashed", TAG) { + isArchived = true + isTrash = true + } + insert("other", OTHER_TAG) { isArchived = true } + + assertEquals(listOf("live", "old"), titles(dao.getNotesOfTagWithArchived(TAG))) + } + + @Test + fun aNoteCanBeObservedByIdWithItsTags() = runBlocking { + val id = insert("note", TAG, OTHER_TAG) { isArchived = true } + + val live = checkNotNull(dao.observeNoteWithTags(id).first()) + + assertEquals(setOf("X", "Y"), live.tags.map { it.nameTag }.toSet()) + assertEquals(null, dao.observeNoteWithTags(999).first()) + } + + @Test + fun stampingGivesATrashTimeOnlyToTrashedNotesWithoutOne() = runBlocking { + val timed = insert("timed") { + isTrash = true + trashedAt = EARLIER + } + val untimed = insert("untimed") { isTrash = true } + val live = insert("live") + + assertEquals(1, dao.stampTrashedAt(STAMP)) + + assertEquals(EARLIER, dao.getNoteById(timed)?.trashedAt) + assertEquals(STAMP, dao.getNoteById(untimed)?.trashedAt) + assertEquals(null, dao.getNoteById(live)?.trashedAt) + } + + @Test + fun deletingExpiredTrashTouchesOnlyTrashedNotesOlderThanTheCutoff() = runBlocking { + val old = insert("old") { + isTrash = true + trashedAt = EARLIER + } + val restored = insert("restored") { trashedAt = EARLIER } + val recent = insert("recent") { + isTrash = true + trashedAt = STAMP + 1 + } + val untimed = insert("untimed") { isTrash = true } + val future = insert("future") { + isTrash = true + trashedAt = Long.MAX_VALUE + } + + assertEquals(1, dao.deleteTrashedBefore(STAMP)) + + assertEquals(null, dao.getNoteById(old)) + listOf(restored, recent, untimed, future).forEach { assertNotNull(dao.getNoteById(it)) } + } + + private companion object { + const val EARLIER = 1_000L + const val TRASHED_AT = 3_000L + const val STAMP = 2_000L + const val TAG = 1L + const val OTHER_TAG = 2L + } +} diff --git a/app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt similarity index 82% rename from app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt rename to core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt index d2ba21f3..93c42ccd 100644 --- a/app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt +++ b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt @@ -2,7 +2,6 @@ package com.pasich.encly.data.database import android.app.Application import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -16,8 +15,8 @@ import org.robolectric.annotation.Config import java.io.File /** - * The parts of the database owner that do not need the native SQLCipher library: the raw-key - * syntax, the refusals before any file is touched, and which files a wipe removes. + * The parts of the database owner that do not need the native SQLCipher library: the refusals + * before any file is touched, and which files a wipe removes. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -27,7 +26,7 @@ class SecureDatabaseManagerTest { @Before fun setUp() { - manager = SecureDatabaseManager(context) + manager = SecureDatabaseManager(AndroidDatabasePlatform(context)) listOf(VaultFile.PRIMARY, VaultFile.DECOY).forEach { vault -> suffixes.forEach { File(context.getDatabasePath(vault.fileName).path + it).delete() } } @@ -42,24 +41,6 @@ class SecureDatabaseManagerTest { it.writeBytes(ByteArray(64) { 7 }) } - @Test - fun theRawKeyIsHexInsideSqlcipherRawKeySyntax() { - val dek = ByteArray(32) { (it * 8 + 1).toByte() } - - val passphrase = SecureDatabaseManager.rawKeyPassphrase(dek) - - val expected = "x'" + dek.joinToString("") { "%02x".format(it) } + "'" - assertEquals(67, passphrase.size) - assertArrayEquals(expected.toByteArray(Charsets.US_ASCII), passphrase) - } - - @Test - fun theRawKeyUsesLowercaseHexForHighBytes() { - val passphrase = SecureDatabaseManager.rawKeyPassphrase(ByteArray(32) { 0xAB.toByte() }) - - assertEquals("x'" + "ab".repeat(32) + "'", String(passphrase, Charsets.US_ASCII)) - } - @Test fun aKeyOfTheWrongSizeIsRefusedWithoutTouchingAnyFile() { assertFalse(manager.unlockDatabase(ByteArray(31), allowCreate = true)) diff --git a/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/SubtasksDaoTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt rename to core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/SubtasksDaoTest.kt diff --git a/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/VaultMigrationTest.kt b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/VaultMigrationTest.kt new file mode 100644 index 00000000..70470168 --- /dev/null +++ b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/VaultMigrationTest.kt @@ -0,0 +1,250 @@ +package com.pasich.encly.data.database + +import android.app.Application +import android.content.Context +import androidx.room.Room +import androidx.room.useReaderConnection +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.AndroidSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * Upgrades a real version-4 vault file (the exported schema 4.json, with its triggers) through + * [VaultSchema], the way an existing install does, and lets Room validate the result against the + * current entities. Plain SQLite under Robolectric, not SQLCipher: it checks the schema, not + * encryption. The instrumented `AppDatabaseSchemaTest` covers the same on a device. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class VaultMigrationTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val name = "migration-test.db" + + @Before + fun setUp() { + context.deleteDatabase(name) + } + + @After + fun tearDown() { + context.deleteDatabase(name) + } + + /** + * The database of [version] as Room created it from its exported [schemaPath], with one note + * written the way that version wrote it ([noteSql] inserts it). + */ + private fun createVersion(version: Int, schemaPath: String, vararg rowsSql: String) { + val schema = Json.parseToJsonElement(File(schemaPath).readText()).jsonObject.getValue("database").jsonObject + val statements = buildList { + schema.getValue("entities").jsonArray.forEach { entity -> + val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content + add(entity.jsonObject.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table)) + entity.jsonObject.getValue("indices").jsonArray.forEach { index -> + add(index.jsonObject.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table)) + } + } + schema.getValue("setupQueries").jsonArray.forEach { add(it.jsonPrimitive.content) } + } + val helper = FrameworkSQLiteOpenHelperFactory().create( + SupportSQLiteOpenHelper.Configuration.builder(context) + .name(name) + .callback( + object : SupportSQLiteOpenHelper.Callback(version) { + override fun onCreate(db: SupportSQLiteDatabase) { + statements.forEach(db::execSQL) + VaultSchema.triggerStatements().forEach(db::execSQL) + rowsSql.forEach(db::execSQL) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + }, + ) + .build(), + ) + helper.writableDatabase.close() + helper.close() + } + + @Test + fun aVersion5VaultAsItWasPushedMigratesToTheCurrentVersion() = runBlocking { + createVersion( + 5, + SCHEMA_5, + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid, " + + "isPinned, isArchived, color) " + + "VALUES (5, 'Old note', '[]', 'd', 20, 10, NULL, 1, 'old-uid', 1, 0, 'red')", + ) + + val db = VaultSchema.install(Room.databaseBuilder(context, AppDatabase::class.java, name)) + .setDriver(AndroidSQLiteDriver()) + .build() + try { + val old = checkNotNull(db.notesDao().getNoteById(5)) + assertEquals("Old note", old.title) + assertEquals(true, old.isPinned) + assertEquals("red", old.color) + assertNull(old.textSize) + assertNull(old.lineSpacing) + assertEquals(DB_VERSION, db.userVersion()) + } finally { + db.close() + } + } + + @Test + fun aVersion6VaultStampsItsTrashedNotesWithTheTimeOfTheMigration() = runBlocking { + createVersion( + 6, + SCHEMA_6, + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (1, 'Trashed', '[]', 'd', 20, 10, NULL, 1, 'trashed-uid')", + ) + val before = System.currentTimeMillis() + + val db = VaultSchema.install(Room.databaseBuilder(context, AppDatabase::class.java, name)) + .setDriver(AndroidSQLiteDriver()) + .build() + try { + db.notesDao().insertNote(com.pasich.encly.data.model.Note(title = "Kept", isTrash = false)) + val trashed = checkNotNull(db.notesDao().getNoteById(1)) + val stamp = checkNotNull(trashed.trashedAt) + // Seconds precision: the stamp is not older than the start of the test by more than a second. + assertTrue("stamp $stamp is about now ($before)", stamp >= before - 1_000 && stamp <= before + 60_000) + assertEquals(DB_VERSION, db.userVersion()) + assertNull(db.notesDao().getAllNotes().first().single().trashedAt) + } finally { + db.close() + } + } + + @Test + fun aVersion4VaultOpensAtTheCurrentVersionAndKeepsItsNotes() = runBlocking { + createVersion( + 4, + SCHEMA_4, + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (5, 'Old note', '[]', 'd', 20, 10, NULL, 1, 'old-uid')", + ) + + val db = VaultSchema.install(Room.databaseBuilder(context, AppDatabase::class.java, name)) + .setDriver(AndroidSQLiteDriver()) + .build() + try { + // Room validates the migrated tables against the entities when the first query opens it. + val old = checkNotNull(db.notesDao().getNoteById(5)) + assertEquals("Old note", old.title) + assertEquals(true, old.isTrash) + assertEquals("old-uid", old.uid) + assertEquals(false, old.isPinned) + assertEquals(false, old.isArchived) + assertEquals("none", old.color) + assertNull(old.textSize) + assertNull(old.lineSpacing) + assertEquals(DB_VERSION, db.userVersion()) + + // The uid trigger survived the upgrade, and the new columns default for a new note. + val id = db.notesDao().insertNote(com.pasich.encly.data.model.Note(title = "New")) + val fresh = checkNotNull(db.notesDao().getNoteById(id)) + assertEquals(32, fresh.uid.length) + assertEquals("none", fresh.color) + // The migrated old note is in the trash, so only the new one is in the list. + assertEquals(listOf("New"), db.notesDao().getAllNotes().first().map { it.title }) + assertEquals(listOf("Old note"), db.notesDao().getTrashNotes().first().map { it.title }) + } finally { + db.close() + } + } + + private fun open() = VaultSchema.install(Room.databaseBuilder(context, AppDatabase::class.java, name)) + .setDriver(AndroidSQLiteDriver()) + .build() + + /** A tagged, an untagged and a dangling note (its tag was deleted by an old version without untagging it). */ + private fun taggedRows(trashedAt: Boolean): Array { + val columns = "id, title, value, description, date, dateCreate, tagId, isTrash, uid" + + if (trashedAt) ", trashedAt" else "" + val end = if (trashedAt) ", NULL)" else ")" + return arrayOf( + "INSERT INTO tags (id, name, visibility, position, uid) VALUES (1, 'Work', 1, 0, 'tag-1')", + "INSERT INTO tags (id, name, visibility, position, uid) VALUES (2, 'Secret', 0, 1, 'tag-2')", + "INSERT INTO notes ($columns) VALUES (1, 'Work note', '[]', 'd', 20, 10, 1, 0, 'n-1'$end", + "INSERT INTO notes ($columns) VALUES (2, 'Secret note', '[]', 'd', 20, 10, 2, 0, 'n-2'$end", + "INSERT INTO notes ($columns) VALUES (3, 'Untagged', '[]', 'd', 20, 10, NULL, 0, 'n-3'$end", + "INSERT INTO notes ($columns) VALUES (4, 'Dangling', '[]', 'd', 20, 10, 99, 0, 'n-4'$end", + ) + } + + private suspend fun assertTagsKept(db: AppDatabase) { + suspend fun names(id: Long) = checkNotNull(db.notesDao().getNoteWithTags(id)).tags.map { it.nameTag } + assertEquals(listOf("Work"), names(1)) + assertEquals(listOf("Secret"), names(2)) + assertEquals(emptyList(), names(3)) + assertEquals(emptyList(), names(4)) + assertEquals("n-1", db.notesDao().getNoteById(1)?.uid) + assertEquals(DB_VERSION, db.userVersion()) + val counts = db.notesDao().observeTagNoteCounts().first().associate { it.tagId to it.count } + assertEquals(mapOf(1L to 1, 2L to 1), counts) + } + + @Suppress("SpreadOperator") // A handful of rows, once. + @Test + fun aVersion7VaultKeepsEveryNotesTag() = runBlocking { + createVersion(7, SCHEMA_7, *taggedRows(trashedAt = true)) + + val db = open() + try { + assertTagsKept(db) + } finally { + db.close() + } + } + + @Suppress("SpreadOperator") // A handful of rows, once. + @Test + fun aVersion6VaultKeepsEveryNotesTagThroughBothMigrations() = runBlocking { + createVersion(6, SCHEMA_6, *taggedRows(trashedAt = false)) + + val db = open() + try { + assertTagsKept(db) + // The uid triggers are back on the rebuilt notes table. + val id = db.notesDao().insertNote(com.pasich.encly.data.model.Note(title = "New")) + assertEquals(32, checkNotNull(db.notesDao().getNoteById(id)).uid.length) + } finally { + db.close() + } + } + + private suspend fun AppDatabase.userVersion(): Int = useReaderConnection { connection -> + connection.usePrepared("PRAGMA user_version") { + it.step() + it.getLong(0).toInt() + } + } + + private companion object { + const val SCHEMA_7 = "schemas/com.pasich.encly.data.database.AppDatabase/7.json" + const val SCHEMA_6 = "schemas/com.pasich.encly.data.database.AppDatabase/6.json" + const val SCHEMA_5 = "schemas/com.pasich.encly.data.database.AppDatabase/5.json" + const val SCHEMA_4 = "schemas/com.pasich.encly.data.database.AppDatabase/4.json" + } +} diff --git a/app/src/test/java/com/pasich/encly/data/database/VaultSchemaTest.kt b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/VaultSchemaTest.kt similarity index 84% rename from app/src/test/java/com/pasich/encly/data/database/VaultSchemaTest.kt rename to core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/VaultSchemaTest.kt index 929e9695..519503c8 100644 --- a/app/src/test/java/com/pasich/encly/data/database/VaultSchemaTest.kt +++ b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/VaultSchemaTest.kt @@ -2,6 +2,7 @@ package com.pasich.encly.data.database import android.content.Context import androidx.room.RoomDatabase +import kotlinx.io.files.Path import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -14,7 +15,9 @@ import org.mockito.Mockito.mock */ class VaultSchemaTest { - private val builder = VaultSchema.databaseBuilder(mock(Context::class.java), "vault-test.db") + private val builder = VaultSchema.install( + AndroidDatabasePlatform(mock(Context::class.java)).databaseBuilder(Path("vault-test.db")), + ) @Test fun missingMigrationsAreRequiredNotSilentlyDestructive() { diff --git a/core/database/src/androidMain/kotlin/com/pasich/encly/data/database/AndroidDatabasePlatform.kt b/core/database/src/androidMain/kotlin/com/pasich/encly/data/database/AndroidDatabasePlatform.kt new file mode 100644 index 00000000..1eb4089d --- /dev/null +++ b/core/database/src/androidMain/kotlin/com/pasich/encly/data/database/AndroidDatabasePlatform.kt @@ -0,0 +1,26 @@ +package com.pasich.encly.data.database + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import kotlinx.io.files.Path + +/** + * The Android [DatabasePlatform]: the files are where `Context.getDatabasePath` puts them, in the + * app's `databases` directory, under the same names as ever (`database.db`, `vault_b.db`), so a vault + * written by an earlier release is found where it was left. + * + * `getDatabasePath` is asked with the bare file name, not a path joined by hand: that is also what + * makes the `databases` directory exist on a fresh install. + */ +public class AndroidDatabasePlatform(context: Context) : DatabasePlatform { + private val context: Context = context.applicationContext ?: context + + override fun databasePath(file: VaultFile): Path = Path(context.getDatabasePath(file.fileName).path) + + override fun databaseBuilder(path: Path): RoomDatabase.Builder = Room.databaseBuilder( + context = context, + name = path.toString(), + factory = { AppDatabaseConstructor.initialize() }, + ) +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt new file mode 100644 index 00000000..0198af12 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt @@ -0,0 +1,35 @@ +package com.pasich.encly.core.security.cipher + +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem + +/** + * Detects whether the app database file is missing, plaintext, or SQLCipher-encrypted. + * + * A plaintext SQLite database starts with the 16-byte magic header + * "SQLite format 3\0" (ending in NUL); SQLCipher encrypts the header too, so an encrypted database + * will not match. Inspecting the header keeps this check independent of any SQLCipher + * runtime API. + */ +public object SQLCipherUtils { + + public enum class State { DOES_NOT_EXIST, UNENCRYPTED, ENCRYPTED } + + public fun getDatabaseState(dbPath: Path): State { + if (!SystemFileSystem.exists(dbPath)) return State.DOES_NOT_EXIST + return try { + val header = ByteArray(SQLITE_MAGIC.size) + val read = SystemFileSystem.source(dbPath).buffered().use { it.readAtMostTo(header) } + if (read == SQLITE_MAGIC.size && header.contentEquals(SQLITE_MAGIC)) { + State.UNENCRYPTED + } else { + State.ENCRYPTED + } + } catch (_: Exception) { + State.ENCRYPTED + } + } + + private val SQLITE_MAGIC = "SQLite format 3\u0000".encodeToByteArray() +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/AppDatabase.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/AppDatabase.kt new file mode 100644 index 00000000..deaf9e1e --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/AppDatabase.kt @@ -0,0 +1,37 @@ +package com.pasich.encly.data.database + +import androidx.room.ConstructedBy +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.RoomDatabaseConstructor +import com.pasich.encly.data.database.dao.BackupDao +import com.pasich.encly.data.database.dao.NotesDao +import com.pasich.encly.data.database.dao.TagsDao +import com.pasich.encly.data.database.dao.TasksDao +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Tag +import com.pasich.encly.data.model.Task + +/** Room schema version. Every bump needs a migration and an exported schema in core/database/schemas. */ +public const val DB_VERSION: Int = 8 + +@Database( + entities = [Note::class, Tag::class, Task::class, Subtask::class, NoteTag::class], + version = DB_VERSION, + exportSchema = true, +) +@ConstructedBy(AppDatabaseConstructor::class) +public abstract class AppDatabase : RoomDatabase() { + public abstract fun notesDao(): NotesDao + public abstract fun tagsDao(): TagsDao + public abstract fun tasksDao(): TasksDao + public abstract fun backupDao(): BackupDao +} + +/** Room's KSP processor writes the `actual` of every target; Room calls it to construct [AppDatabase]. */ +@Suppress("KotlinNoActualForExpect", "NO_ACTUAL_FOR_EXPECT") +public expect object AppDatabaseConstructor : RoomDatabaseConstructor { + override fun initialize(): AppDatabase +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/DatabasePlatform.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/DatabasePlatform.kt new file mode 100644 index 00000000..238b7f4b --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/DatabasePlatform.kt @@ -0,0 +1,25 @@ +package com.pasich.encly.data.database + +import androidx.room.RoomDatabase +import kotlinx.io.files.Path + +/** + * What differs per platform about the vault's database: where its files are and how Room is + * pointed at one. Everything else (the key, the driver, the lifecycle) is common code. + * + * Android binds [AndroidDatabasePlatform], whose files are exactly where the app's database + * directory has always been; the desktop and iOS targets get the directory from the app. + */ +public interface DatabasePlatform { + /** + * The path of [file]'s database. The directory it is in exists when this returns, so a first + * run can create the database there. + */ + public fun databasePath(file: VaultFile): Path + + /** + * A Room builder for the database at [path] (a path from [databasePath]). It carries no driver, + * migrations or callbacks: [SecureDatabaseManager] adds those, the same on every platform. + */ + public fun databaseBuilder(path: Path): RoomDatabase.Builder +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/DatabaseProvider.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/DatabaseProvider.kt new file mode 100644 index 00000000..1803b408 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/DatabaseProvider.kt @@ -0,0 +1,19 @@ +package com.pasich.encly.data.database + +/** + * One opening of the encrypted database. Identity, not its contents, grants DAO access: creating + * one grants nothing, as [DatabaseProvider.getDatabase] accepts only the lease of the current opening. + */ +public class DatabaseLease + +/** Supplies Room only to callers still bound to the opening in which they were created. */ +public interface DatabaseProvider { + /** Null while locked. The returned lease is revoked before Room closes. */ + public fun captureLease(): DatabaseLease? + + /** + * Refuses a lease from an earlier opening, even when another database (the other vault's, + * real or decoy) is now open. + */ + public fun getDatabase(lease: DatabaseLease): AppDatabase +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/LeanDriver.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/LeanDriver.kt new file mode 100644 index 00000000..22e9fbfa --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/LeanDriver.kt @@ -0,0 +1,42 @@ +package com.pasich.encly.data.database + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.execSQL +import kotlinx.atomicfu.atomic + +/** + * The vault driver's wrapper that keeps every connection of Room's pool small. + * + * SQLite gives each connection its own page cache (2 MiB by default), and Room's pool in WAL mode + * opens up to four readers beside the writer. A notes vault reads a few kilobytes per screen, so + * each connection gets [CACHE_KIB] instead. The cache is a per-connection, in-memory setting: the + * file, and so the cipher settings that define its format (page size, KDF, HMAC), are not touched. + * + * It also counts the connections the pool opened ([opened]), which is how the tests check the pool + * stays at the size [SecureDatabaseManager] caps it to. + */ +internal class LeanDriver(private val delegate: SQLiteDriver) : SQLiteDriver { + private val count = atomic(0) + + /** How many connections were opened through this driver so far (closed ones included). */ + val opened: Int get() = count.value + + override fun open(fileName: String): SQLiteConnection { + val connection = delegate.open(fileName) + var ready = false + try { + connection.execSQL("PRAGMA cache_size = -$CACHE_KIB") + ready = true + } finally { + if (!ready) connection.close() + } + count.incrementAndGet() + return connection + } + + internal companion object { + /** A connection's page cache, in KiB (a negative `cache_size` is a size, not a page count). */ + const val CACHE_KIB = 1024 + } +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/SecureDatabaseManager.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/SecureDatabaseManager.kt new file mode 100644 index 00000000..5ea62b91 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/SecureDatabaseManager.kt @@ -0,0 +1,231 @@ +package com.pasich.encly.data.database + +import androidx.room.useWriterConnection +import com.pasich.encly.core.security.cipher.SQLCipherUtils +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.github.pasichdev.sealite.SealiteDriver +import io.github.pasichdev.sealite.SealiteKey +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem + +/** + * 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. + */ +public 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 (and the first opening of a decoy vault). + * + * Where the files are, and how Room is pointed at one, is the [platform]'s business. + */ +@SingleIn(AppScope::class) +@Inject +public class SecureDatabaseManager(private val platform: DatabasePlatform) : DatabaseProvider { + /** Guards every field below; reentrant, as [unlockDatabase] and [wipe] call into other guarded functions. */ + private val lock = SynchronizedObject() + + private var database: AppDatabase? = null + private var isUnlocked = false + private var lease: DatabaseLease? = null + private var openFile: VaultFile? = null + + /** + * The driver of the open [database]. It holds its own copy of the key and keys every new + * pool connection from it (WAL readers, reopen after a pool reset); closing it after the + * database wipes that copy. + */ + private var driver: SealiteDriver? = null + + /** The pool-facing wrapper of [driver]; only to count the connections the pool opened. */ + private var leanDriver: LeanDriver? = null + + /** A copy of the open database's key, only to check a repeated [unlockDatabase]; wiped in [reset]. */ + private var openKey: ByteArray? = null + + public fun isDatabaseUnlocked(): Boolean = isUnlocked + + /** How many connections the open database's pool opened so far; 0 when closed. For tests. */ + internal fun openedConnections(): Int = synchronized(lock) { leanDriver?.opened ?: 0 } + + public fun hasEncryptedDatabase(file: VaultFile = VaultFile.PRIMARY): Boolean = + SQLCipherUtils.getDatabaseState(platform.databasePath(file)) == SQLCipherUtils.State.ENCRYPTED + + override fun captureLease(): DatabaseLease? = synchronized(lock) { lease } + + override fun getDatabase(lease: DatabaseLease): AppDatabase = synchronized(lock) { + database?.takeIf { isUnlocked && this.lease === lease } ?: error("Database session expired") + } + + /** + * Opens the vault with the raw 32-byte [dek]; the caller keeps (and wipes) [dek]. + * + * The key reaches SQLCipher as a raw key ([SealiteKey.raw]), so SQLCipher uses it as 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. It is the same key derivation the files were + * written with before (`x'<64 hex>'` through sqlcipher-android), so existing vaults open. + * + * 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. + */ + public 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. */ + public fun unlockDatabase(dek: ByteArray, allowCreate: Boolean, file: VaultFile): Boolean = + synchronized(lock) { unlockLocked(dek, allowCreate, file) } + + @Suppress("ReturnCount") // Fail-closed vault-state/key gates are clearer as early exits. + private fun unlockLocked(dek: ByteArray, allowCreate: Boolean, file: VaultFile): Boolean { + if (dek.size != DEK_LENGTH) return false + if (isUnlocked) { + return openFile == file && openKey?.let { constantTimeEquals(it, dek) } == true + } + + var openedDatabase: AppDatabase? = null + var openedDriver: SealiteDriver? = null + return try { + val path = platform.databasePath(file) + when (SQLCipherUtils.getDatabaseState(path)) { + SQLCipherUtils.State.DOES_NOT_EXIST -> if (!allowCreate) return false + + // Never silently adopt/migrate a plaintext database into a committed vault. + SQLCipherUtils.State.UNENCRYPTED -> return false + + SQLCipherUtils.State.ENCRYPTED -> Unit + } + + // No destructive fallback: a schema change without a migration must fail to open + // (and be caught in tests), never drop the user's notes. + // The driver keeps its own copy of the key; this one is wiped at once. + val key = SealiteKey.raw(dek) + val newDriver = try { + SealiteDriver(key) + } finally { + key.close() + } + openedDriver = newDriver + val lean = LeanDriver(newDriver) + // Room's pool opens up to four readers beside the writer in WAL mode, and offers no setting + // for it. A query runs on this context, and a connection is opened only for a query that + // runs, so the pool never holds more than QUERY_PARALLELISM readers (and one writer). + val opened = VaultSchema.install(platform.databaseBuilder(path)) + .setDriver(lean) + .setQueryCoroutineContext(Dispatchers.IO.limitedParallelism(QUERY_PARALLELISM)) + .build() + openedDatabase = opened + + // One open, which is also the key check: a wrong key, a damaged file or a failed + // migration throws here, before the database is published as unlocked. + runBlocking { opened.useWriterConnection { } } + + database = opened + driver = newDriver + leanDriver = lean + openKey = dek.copyOf() + isUnlocked = true + openFile = file + lease = DatabaseLease() + true + } catch (_: Exception) { + // No log: failure events must not carry anything about the vault. + openedDatabase?.close() + openedDriver?.close() + database = null + leanDriver = null + isUnlocked = false + openFile = null + lease = null + false + } + } + + private fun deleteDatabaseFiles(file: VaultFile) { + val dbFile = try { + platform.databasePath(file).toString() + } catch (_: Exception) { + return + } + // Each file on its own: one that cannot be deleted must not spare the others. + FILE_SUFFIXES.forEach { suffix -> + try { + SystemFileSystem.delete(Path(dbFile + suffix), mustExist = false) + } catch (_: Exception) { + // Best effort, as File.delete was: the next wipe tries again. + } + } + } + + public fun reset(): Unit = synchronized(lock) { + lease = null + database?.close() + database = null + // After the database: closing the driver wipes its copy of the key. + driver?.close() + driver = null + leanDriver = null + isUnlocked = false + openFile = null + openKey?.fill(0) + openKey = null + } + + /** Closes and deletes both databases: the real vault's and the decoy vault's. */ + public fun wipe(): Unit = synchronized(lock) { + reset() + 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. + */ + public fun wipeIfClosed(only: VaultFile? = null): Boolean = synchronized(lock) { + if (isUnlocked && (only == null || openFile == only)) return@synchronized false + (only?.let(::listOf) ?: VaultFile.entries).forEach(::deleteDatabaseFiles) + true + } + + private companion object { + const val DEK_LENGTH = 32 + + /** + * The database file itself, the SQLite side files beside it and Room's lock file (which the + * desktop and iOS targets keep next to the database; Android keeps it elsewhere). + */ + val FILE_SUFFIXES = listOf("", "-wal", "-shm", "-journal", ".lck") + } +} + +/** Queries that run at once, so the most readers the pool opens. A notes screen needs two. */ +internal const val QUERY_PARALLELISM = 2 + +/** + * Whether [a] and [b] hold the same bytes, in time that depends on their lengths only and not + * on where they differ (what `MessageDigest.isEqual` does on the JVM). + */ +internal fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + var diff = a.size xor b.size + for (i in a.indices) { + val other = if (i < b.size) b[i].toInt() else 0 + diff = diff or (a[i].toInt() xor other) + } + return diff == 0 +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/VaultSchema.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/VaultSchema.kt new file mode 100644 index 00000000..e6832551 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/VaultSchema.kt @@ -0,0 +1,250 @@ +package com.pasich.encly.data.database + +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Schema pieces Room cannot express in entity annotations. + * + * Every note, tag, task and sub-task carries a stable `uid` (encrypted backups merge by it). + * Kotlin code never assigns one, so two triggers keep the invariant "uid is never blank" in the + * database itself: a blank uid is replaced with 128 random bits on insert, and an update that + * would blank an existing uid (an entity built without it) restores the old value. + */ +public object VaultSchema { + /** The tables that got a `uid` in version 2. Fixed: later tables are created with one. */ + private val V2_UID_TABLES = listOf("notes", "tags", "tasks") + + private const val SUBTASKS = "subtasks" + + /** Every table with a `uid` and its triggers, in the current schema. */ + private val TABLES = V2_UID_TABLES + SUBTASKS + + private const val RANDOM_UID = "lower(hex(randomblob(16)))" + + /** Database version that dropped `tasks.reminderDate`. */ + private const val VERSION_WITHOUT_TASK_REMINDERS = 3 + + /** Database version that added the `subtasks` table. */ + private const val VERSION_WITH_SUBTASKS = 4 + + /** Database version that added pin, archive and colour to notes. */ + private const val VERSION_WITH_NOTE_ORGANIZING = 5 + + /** Database version that added a note's own text size and line spacing. */ + private const val VERSION_WITH_NOTE_TEXT_SETTINGS = 6 + + /** Database version that added the time a note went to the trash. */ + private const val VERSION_WITH_TRASH_TIME = 7 + + /** Database version that moved a note's tag into `note_tags`, so a note can have several. */ + private const val VERSION_WITH_NOTE_TAGS = 8 + + private const val NOTES = "notes" + + private fun triggers(table: String) = listOf( + "CREATE TRIGGER IF NOT EXISTS `${table}_uid_on_insert` AFTER INSERT ON `$table` " + + "WHEN NEW.uid = '' BEGIN " + + "UPDATE `$table` SET uid = $RANDOM_UID WHERE id = NEW.id; END", + "CREATE TRIGGER IF NOT EXISTS `${table}_uid_keep` AFTER UPDATE OF uid ON `$table` " + + "WHEN NEW.uid = '' AND OLD.uid <> '' BEGIN " + + "UPDATE `$table` SET uid = OLD.uid WHERE id = NEW.id; END", + ) + + /** The uid triggers of every table in the current schema. */ + internal fun triggerStatements(): List = TABLES.flatMap(::triggers) + + public fun createTriggers(connection: SQLiteConnection) { + triggerStatements().forEach(connection::execSQL) + } + + /** 1 -> 2: add the stable `uid` to notes, tags and tasks and back-fill existing rows. */ + public val MIGRATION_1_2: Migration = object : Migration(1, 2) { + override fun migrate(connection: SQLiteConnection) { + V2_UID_TABLES.forEach { table -> + connection.execSQL("ALTER TABLE `$table` ADD COLUMN `uid` TEXT NOT NULL DEFAULT ''") + connection.execSQL("UPDATE `$table` SET uid = $RANDOM_UID WHERE uid = ''") + connection.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_${table}_uid` ON `$table` (`uid`)") + } + // Only these tables exist at version 2; later tables get their triggers in their + // own migration. + V2_UID_TABLES.flatMap(::triggers).forEach(connection::execSQL) + } + } + + /** + * 2 -> 3: drop `tasks.reminderDate` (task reminders were removed from the app). + * + * SQLite only has `DROP COLUMN` from 3.35 and Room's schema test runs on the platform + * SQLite, so the table is rebuilt instead. Dropping the old table drops its uid triggers + * and index; both are recreated on the new one. + */ + public val MIGRATION_2_3: Migration = object : Migration(2, VERSION_WITHOUT_TASK_REMINDERS) { + override fun migrate(connection: SQLiteConnection) { + val columns = "`id`, `title`, `description`, `isCompleted`, `createdDate`, " + + "`completedDate`, `priority`, `categoryId`, `position`, `uid`" + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `tasks_new` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, " + + "`description` TEXT, `isCompleted` INTEGER NOT NULL, `createdDate` INTEGER NOT NULL, " + + "`completedDate` INTEGER, `priority` INTEGER NOT NULL, `categoryId` INTEGER, " + + "`position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", + ) + connection.execSQL("INSERT INTO `tasks_new` ($columns) SELECT $columns FROM `tasks`") + connection.execSQL("DROP TABLE `tasks`") + connection.execSQL("ALTER TABLE `tasks_new` RENAME TO `tasks`") + connection.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_uid` ON `tasks` (`uid`)") + triggers("tasks").forEach(connection::execSQL) + } + } + + /** + * 3 -> 4: add the `subtasks` table (a task's checklist), with its index on `taskId`, the + * unique `uid` index and the uid triggers. Existing tasks simply have no sub-tasks. The + * statements match Room's export of version 4 (core/database/schemas), which the schema test checks. + */ + public val MIGRATION_3_4: Migration = object : Migration(VERSION_WITHOUT_TASK_REMINDERS, VERSION_WITH_SUBTASKS) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `$SUBTASKS` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `taskId` INTEGER NOT NULL, " + + "`title` TEXT NOT NULL, `isCompleted` INTEGER NOT NULL, `position` INTEGER NOT NULL, " + + "`uid` TEXT NOT NULL DEFAULT '', " + + "FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_subtasks_taskId` ON `$SUBTASKS` (`taskId`)") + connection.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_subtasks_uid` ON `$SUBTASKS` (`uid`)") + triggers(SUBTASKS).forEach(connection::execSQL) + } + } + + /** + * 4 -> 5: add `isPinned`, `isArchived` and `color` to notes. Plain `ADD COLUMN` with a + * default, so every existing note stays as it was: unpinned, not archived, no colour. The + * statements match Room's export of version 5 (core/database/schemas), which the schema test checks. + */ + public val MIGRATION_4_5: Migration = object : Migration(VERSION_WITH_SUBTASKS, VERSION_WITH_NOTE_ORGANIZING) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `notes` ADD COLUMN `isPinned` INTEGER NOT NULL DEFAULT 0") + connection.execSQL("ALTER TABLE `notes` ADD COLUMN `isArchived` INTEGER NOT NULL DEFAULT 0") + connection.execSQL("ALTER TABLE `notes` ADD COLUMN `color` TEXT NOT NULL DEFAULT 'none'") + } + } + + /** + * 5 -> 6: add a note's own `textSize` and `lineSpacing`. Both are nullable (null: the note + * follows the defaults of Settings), so every existing note stays as it was. Version 5 was + * already out on dev installs without them, so they are a step of their own: editing 5 in + * place would fail Room's identity check there. + */ + public val MIGRATION_5_6: Migration = object : Migration( + VERSION_WITH_NOTE_ORGANIZING, + VERSION_WITH_NOTE_TEXT_SETTINGS, + ) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `notes` ADD COLUMN `textSize` INTEGER") + connection.execSQL("ALTER TABLE `notes` ADD COLUMN `lineSpacing` INTEGER") + } + } + + /** + * 6 -> 7: add `trashedAt`, when a note went to the trash. Nullable, so every note outside the trash + * stays as it was. A note already in the trash is stamped with the time of the migration: its real + * trash time was never kept, and the trash must not empty a note the first time it is checked. + */ + public val MIGRATION_6_7: Migration = object : Migration( + VERSION_WITH_NOTE_TEXT_SETTINGS, + VERSION_WITH_TRASH_TIME, + ) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `notes` ADD COLUMN `trashedAt` INTEGER") + connection.execSQL( + "UPDATE `notes` SET `trashedAt` = CAST(strftime('%s', 'now') AS INTEGER) * 1000 WHERE `isTrash` = 1", + ) + } + } + + /** + * 7 -> 8: a note can have several tags. The links move to the new `note_tags` table (one row per note and + * tag, both foreign keys cascading, an index on `tagId`) and `notes.tagId` goes. Every note keeps the tag it + * had; a `tagId` that names no tag (left by an old version) was no tag on screen and is dropped, so the + * foreign key check Room runs after the migration passes. + * + * `notes` is rebuilt without the column, like [MIGRATION_2_3] (SQLite only has `DROP COLUMN` from 3.35), + * keeping every other column, the uid index and the uid triggers. The links are first copied to a plain + * table and only written to `note_tags` once `notes` is rebuilt, so nothing depends on whether foreign keys + * are enforced while the migration runs (a `DROP TABLE` of a parent would otherwise cascade). The + * AUTOINCREMENT counter of `notes` is carried over, so the id of a deleted note is never given out again. + * The statements match Room's export of version 8 (core/database/schemas), which the schema test checks. + */ + public val MIGRATION_7_8: Migration = object : Migration(VERSION_WITH_TRASH_TIME, VERSION_WITH_NOTE_TAGS) { + override fun migrate(connection: SQLiteConnection) { + val columns = "`id`, `title`, `value`, `description`, `date`, `dateCreate`, `isTrash`, `uid`, " + + "`isPinned`, `isArchived`, `color`, `textSize`, `lineSpacing`, `trashedAt`" + connection.execSQL("DROP TABLE IF EXISTS `note_tags_carry`") + connection.execSQL( + "CREATE TABLE `note_tags_carry` AS SELECT `id` AS `noteId`, `tagId` FROM `$NOTES` " + + "WHERE `tagId` IS NOT NULL AND `tagId` IN (SELECT `id` FROM `tags`)", + ) + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `notes_new` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, " + + "`value` TEXT NOT NULL, `description` TEXT NOT NULL, `date` INTEGER NOT NULL, " + + "`dateCreate` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '', " + + "`isPinned` INTEGER NOT NULL DEFAULT 0, `isArchived` INTEGER NOT NULL DEFAULT 0, " + + "`color` TEXT NOT NULL DEFAULT 'none', `textSize` INTEGER, `lineSpacing` INTEGER, " + + "`trashedAt` INTEGER)", + ) + connection.execSQL("INSERT INTO `notes_new` ($columns) SELECT $columns FROM `$NOTES`") + // The rename below carries this row over to `notes`. + connection.execSQL("DELETE FROM sqlite_sequence WHERE name = 'notes_new'") + connection.execSQL( + "INSERT INTO sqlite_sequence (name, seq) SELECT 'notes_new', seq FROM sqlite_sequence " + + "WHERE name = '$NOTES'", + ) + connection.execSQL("DROP TABLE `$NOTES`") + connection.execSQL("ALTER TABLE `notes_new` RENAME TO `$NOTES`") + connection.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_notes_uid` ON `$NOTES` (`uid`)") + triggers(NOTES).forEach(connection::execSQL) + + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `note_tags` (`noteId` INTEGER NOT NULL, `tagId` INTEGER NOT NULL, " + + "PRIMARY KEY(`noteId`, `tagId`), " + + "FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , " + + "FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_note_tags_tagId` ON `note_tags` (`tagId`)") + connection.execSQL( + "INSERT OR IGNORE INTO `note_tags` (`noteId`, `tagId`) SELECT `noteId`, `tagId` FROM `note_tags_carry`", + ) + connection.execSQL("DROP TABLE `note_tags_carry`") + } + } + + /** + * Adds every migration and the trigger callback to a vault database builder, and nothing + * destructive: Room's default (`requireMigration`) makes a missing migration fail loudly + * instead of recreating the tables, which would erase every note. List each new migration + * here explicitly. + */ + public fun install(builder: RoomDatabase.Builder): RoomDatabase.Builder = builder + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8, + ) + .addCallback(CALLBACK) + + /** Installs the triggers on a freshly created database. */ + public val CALLBACK: RoomDatabase.Callback = object : RoomDatabase.Callback() { + override fun onCreate(connection: SQLiteConnection) { + createTriggers(connection) + } + } +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/BackupDao.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/BackupDao.kt new file mode 100644 index 00000000..b4a41019 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/BackupDao.kt @@ -0,0 +1,70 @@ +package com.pasich.encly.data.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Tag +import com.pasich.encly.data.model.Task + +/** + * Whole-vault reads and writes for encrypted export/import. Inserts ABORT on a uid clash so a + * bad import fails (and its transaction rolls back) instead of silently replacing a row. + */ +@Suppress("TooManyFunctions") // One read, insert and delete per vault table. +@Dao +public interface BackupDao { + @Query("SELECT * FROM notes ORDER BY id") + public suspend fun allNotes(): List + + @Query("SELECT * FROM tags ORDER BY id") + public suspend fun allTags(): List + + @Query("SELECT * FROM note_tags ORDER BY noteId, tagId") + public suspend fun allNoteTags(): List + + @Query("SELECT * FROM note_tags WHERE noteId = :noteId ORDER BY tagId") + public suspend fun noteTagsOf(noteId: Long): List + + @Query("SELECT * FROM tags WHERE id IN (SELECT tagId FROM note_tags WHERE noteId = :noteId) ORDER BY id") + public suspend fun tagsOfNote(noteId: Long): List + + @Query("SELECT * FROM tasks ORDER BY id") + public suspend fun allTasks(): List + + @Query("SELECT * FROM subtasks ORDER BY taskId, position, id") + public suspend fun allSubtasks(): List + + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun insertNote(note: Note): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun insertTag(tag: Tag): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun insertNoteTag(link: NoteTag) + + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun insertTask(task: Task): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun insertSubtask(subtask: Subtask): Long + + @Query("DELETE FROM notes") + public suspend fun deleteAllNotes() + + @Query("DELETE FROM note_tags") + public suspend fun deleteAllNoteTags() + + @Query("DELETE FROM tags") + public suspend fun deleteAllTags() + + @Query("DELETE FROM tasks") + public suspend fun deleteAllTasks() + + @Query("DELETE FROM subtasks") + public suspend fun deleteAllSubtasks() +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/NotesDao.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/NotesDao.kt new file mode 100644 index 00000000..d56ba27c --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/NotesDao.kt @@ -0,0 +1,222 @@ +package com.pasich.encly.data.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.TagNoteCount +import kotlinx.coroutines.flow.Flow + +@Suppress("TooManyFunctions") // Reads per list, and one write per note column. +@Dao +public interface NotesDao { + + /** The notes of the main list: neither in the trash nor in the archive. */ + @Transaction + @Query("SELECT * FROM notes WHERE isTrash = 0 AND isArchived = 0") + public fun getAllNotesWithTags(): Flow> + + /** The archive: archived notes that are not in the trash (a trashed archived note is in the trash). */ + @Transaction + @Query("SELECT * FROM notes WHERE isTrash = 0 AND isArchived = 1") + public fun getArchivedNotesWithTags(): Flow> + + /** + * The notes (not in the trash) that carry the tag [tagId], archived ones included: the way to a hidden tag's + * archived notes. One query, so a note being archived or unarchived is never read twice, and a note is listed + * once however many tags it has. + */ + @Transaction + @Query("SELECT * FROM notes WHERE isTrash = 0 AND id IN (SELECT noteId FROM note_tags WHERE tagId = :tagId)") + public fun getNotesOfTagWithArchived(tagId: Long): Flow> + + @Transaction + @Query("SELECT * FROM notes WHERE id = :noteId") + public fun observeNoteWithTags(noteId: Long): Flow + + @Transaction + @Query("SELECT * FROM notes WHERE id = :noteId") + public suspend fun getNoteWithTags(noteId: Long): NoteWithTags? + + /** How many notes outside the trash, archived ones included, carry each tag that has any. */ + @Query( + "SELECT note_tags.tagId AS tagId, COUNT(*) AS count FROM note_tags " + + "INNER JOIN notes ON notes.id = note_tags.noteId WHERE notes.isTrash = 0 GROUP BY note_tags.tagId", + ) + public fun observeTagNoteCounts(): Flow> + + @Query("SELECT * FROM notes WHERE isTrash = 0 AND isArchived = 0") + public fun getAllNotes(): Flow> + + @Query("SELECT * FROM notes WHERE isTrash = 1") + public fun getTrashNotes(): Flow> + + @Query("SELECT * FROM notes WHERE id = :noteId") + public suspend fun getNoteById(noteId: Long): Note? + + // Single-column writes: an action on one property must never write back the rest of a row + // that a screen read earlier (a stale tag, colour or pin). + @Query("UPDATE notes SET color = :color WHERE id = :noteId") + public suspend fun updateColor(noteId: Long, color: String): Int + + /** Links that exist already are kept as they are. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + public suspend fun insertNoteTags(links: List) + + @Query("DELETE FROM note_tags WHERE noteId = :noteId AND tagId IN (:tagIds)") + public suspend fun deleteNoteTags(noteId: Long, tagIds: List): Int + + @Query("DELETE FROM note_tags WHERE noteId = :noteId AND tagId NOT IN (:keep)") + public suspend fun deleteNoteTagsExcept(noteId: Long, keep: List): Int + + @Query("DELETE FROM note_tags WHERE noteId = :noteId") + public suspend fun clearNoteTags(noteId: Long): Int + + /** + * Adds the tags [added] to the note and takes [removed] off it, leaving its other tags as they are: a tag + * changed meanwhile from another window or the list stays changed. + */ + @Transaction + public suspend fun changeNoteTags(noteId: Long, added: Collection, removed: Collection) { + if (removed.isNotEmpty()) deleteNoteTags(noteId, removed.toList()) + if (added.isNotEmpty()) insertNoteTags(added.map { NoteTag(noteId, it) }) + } + + /** Makes [tagIds] exactly the note's tags. */ + @Transaction + public suspend fun setNoteTags(noteId: Long, tagIds: Collection) { + deleteNoteTagsExcept(noteId, tagIds.toList()) + if (tagIds.isNotEmpty()) insertNoteTags(tagIds.map { NoteTag(noteId, it) }) + } + + @Query("UPDATE notes SET description = :description WHERE id = :noteId") + public suspend fun updateDescription(noteId: Long, description: String): Int + + /** An archived note is never pinned. */ + @Query("UPDATE notes SET isPinned = :pinned WHERE id = :noteId AND (:pinned = 0 OR isArchived = 0)") + public suspend fun updatePinned(noteId: Long, pinned: Boolean): Int + + /** Archiving unpins in the same statement. */ + @Query( + "UPDATE notes SET isArchived = :archived, " + + "isPinned = CASE WHEN :archived = 1 THEN 0 ELSE isPinned END WHERE id = :noteId", + ) + public suspend fun updateArchived(noteId: Long, archived: Boolean): Int + + /** Moves a note to the trash (with the time) or back, and writes nothing else of the row. */ + @Query("UPDATE notes SET isTrash = :trashed, trashedAt = :trashedAt WHERE id = :noteId") + public suspend fun updateTrashed(noteId: Long, trashed: Boolean, trashedAt: Long?): Int + + /** + * What an editor saves: the title and content always, and every other column only when its flag says the editor + * changed it. A note trashed, archived, pinned or recoloured from another window meanwhile keeps that: nothing + * here writes `isTrash`, `trashedAt` or `isArchived`, and a column the editor left alone is not written back. + * The tags are not a column: see [saveEdited]. + * [stamp] also writes [date] and [description] (the content changed). A pin never lands on an archived note. + */ + @Suppress("LongParameterList") // One value and one flag per column of the row. + @Query( + """ + UPDATE notes SET title = :title, value = :value, + description = CASE WHEN :stamp = 1 THEN :description ELSE description END, + date = CASE WHEN :stamp = 1 THEN :date ELSE date END, + isPinned = CASE WHEN :setPinned = 1 THEN (:pinned = 1 AND isArchived = 0) ELSE isPinned END, + color = CASE WHEN :setColor = 1 THEN :color ELSE color END, + textSize = CASE WHEN :setTextSize = 1 THEN :textSize ELSE textSize END, + lineSpacing = CASE WHEN :setLineSpacing = 1 THEN :lineSpacing ELSE lineSpacing END + WHERE id = :noteId + """, + ) + public suspend fun updateEdited( + noteId: Long, + title: String, + value: String, + stamp: Boolean, + description: String, + date: Long, + setPinned: Boolean, + pinned: Boolean, + setColor: Boolean, + color: String, + setTextSize: Boolean, + textSize: Int?, + setLineSpacing: Boolean, + lineSpacing: Int?, + ): Int + + /** + * [updateEdited] and the editor's tag changes ([addTags], [removeTags]) in one transaction. The tags change only + * when the row was there to update. + */ + @Suppress("LongParameterList") // updateEdited's columns and the tag changes. + @Transaction + public suspend fun saveEdited( + noteId: Long, + title: String, + value: String, + stamp: Boolean, + description: String, + date: Long, + setPinned: Boolean, + pinned: Boolean, + setColor: Boolean, + color: String, + setTextSize: Boolean, + textSize: Int?, + setLineSpacing: Boolean, + lineSpacing: Int?, + addTags: Collection, + removeTags: Collection, + ): Int { + val rows = updateEdited( + noteId, title, value, stamp, description, date, setPinned, pinned, setColor, color, + setTextSize, textSize, setLineSpacing, lineSpacing, + ) + if (rows > 0) changeNoteTags(noteId, addTags, removeTags) + return rows + } + + /** Writes every column the editor owns of [note], and makes [Note.tagIds] exactly its tags, in one transaction. */ + @Transaction + public suspend fun updateEditedWithAllTags(note: Note): Int { + val rows = updateEdited( + note.id, note.title, note.value, true, note.description, note.date, true, note.isPinned, true, note.color, + true, note.textSize, true, note.lineSpacing, + ) + if (rows > 0) setNoteTags(note.id, note.tagIds) + return rows + } + + /** Gives the trashed notes that have no trash time (imported ones) [at], so the trash can age them. */ + @Query("UPDATE notes SET trashedAt = :at WHERE isTrash = 1 AND trashedAt IS NULL") + public suspend fun stampTrashedAt(at: Long): Int + + /** + * Deletes the trashed notes that were trashed before [cutoff], in one statement: a note restored a moment ago is + * no longer trashed when the delete runs, so it stays. Returns the number of rows deleted. + */ + @Query("DELETE FROM notes WHERE isTrash = 1 AND trashedAt IS NOT NULL AND trashedAt < :cutoff") + public suspend fun deleteTrashedBefore(cutoff: Long): Int + + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun insertNote(note: Note): Long + + /** Inserts [note] with its tags [Note.tagIds] in one transaction; the new row id. */ + @Transaction + public suspend fun insertNoteWithTags(note: Note): Long { + val id = insertNote(note) + if (note.tagIds.isNotEmpty()) insertNoteTags(note.tagIds.map { NoteTag(id, it) }) + return id + } + + @Update + public suspend fun updateNote(note: Note): Int + + @Query("DELETE FROM notes WHERE id = :noteId") + public suspend fun deleteNoteById(noteId: Long): Int +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/TagsDao.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/TagsDao.kt new file mode 100644 index 00000000..789416c1 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/TagsDao.kt @@ -0,0 +1,48 @@ +package com.pasich.encly.data.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.pasich.encly.data.model.Tag +import kotlinx.coroutines.flow.Flow + +@Dao +public interface TagsDao { + @Query("SELECT * FROM tags") + public fun getTags(): Flow> + + // ABORT, never REPLACE: a replace deletes the old row first, and with it (the foreign key cascades) every + // note's link to the tag. + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun addTags(tags: List): List + + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun addTag(tag: Tag): Long + + @Update + public suspend fun updateTag(tag: Tag): Int + + @Update + public suspend fun updateTags(tags: List): Int + + @Delete + public suspend fun deleteTag(tag: Tag): Int + + @Query("DELETE FROM note_tags WHERE tagId = :tagId") + public suspend fun detachNotesFromTag(tagId: Long): Int + + /** + * Deletes [tag] and, in the same transaction, takes it off its notes; they keep their other tags. The foreign + * key cascades the links too; they are deleted explicitly all the same, so no note keeps pointing at a tag + * that no longer exists (and stays hidden by it). + */ + @Transaction + public suspend fun deleteTagDetachingNotes(tag: Tag): Int { + detachNotesFromTag(tag.id) + return deleteTag(tag) + } +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/TasksDao.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/TasksDao.kt new file mode 100644 index 00000000..19a3927d --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/database/dao/TasksDao.kt @@ -0,0 +1,121 @@ +package com.pasich.encly.data.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Task +import kotlinx.coroutines.flow.Flow + +@Dao +public interface TasksDao { + + @Query("SELECT * FROM tasks WHERE isCompleted = 0 ORDER BY priority DESC, createdDate DESC") + public fun getAllActiveTasks(): Flow> + + @Query("SELECT * FROM tasks WHERE isCompleted = 1 ORDER BY completedDate DESC") + public fun getAllCompletedTasks(): Flow> + + @Query("SELECT * FROM tasks ORDER BY isCompleted ASC, priority DESC, createdDate DESC") + public fun getAllTasks(): Flow> + + @Query("SELECT COUNT(*) FROM tasks WHERE isCompleted = 0") + public fun getActiveTasksCount(): Flow + + @Query("SELECT COUNT(*) FROM tasks WHERE isCompleted = 1") + public fun getCompletedTasksCount(): Flow + + @Query("SELECT * FROM tasks WHERE id = :id") + public suspend fun getTaskById(id: Long): Task? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + public suspend fun insertTask(task: Task): Long + + @Update + public suspend fun updateTask(task: Task): Int + + @Query("UPDATE tasks SET isCompleted = :isCompleted, completedDate = :completedDate WHERE id = :id") + public suspend fun updateTaskStatus(id: Long, isCompleted: Boolean, completedDate: Long?): Int + + @Delete + public suspend fun deleteTask(task: Task): Int + + @Query("DELETE FROM tasks WHERE isCompleted = 1") + public suspend fun deleteAllCompletedTasks(): Int + + @Query("DELETE FROM tasks WHERE id = :id") + public suspend fun deleteTaskById(id: Long): Int + + @Query("SELECT * FROM subtasks WHERE taskId = :taskId ORDER BY position, id") + public suspend fun getSubtasks(taskId: Long): List + + /** Every sub-task, grouped by task and in each task's order, for the Tasks list. */ + @Query("SELECT * FROM subtasks ORDER BY taskId, position, id") + public fun observeSubtasks(): Flow> + + @Query("UPDATE subtasks SET isCompleted = :done WHERE id = :id") + public suspend fun setSubtaskCompleted(id: Long, done: Boolean): Int + + @Insert(onConflict = OnConflictStrategy.ABORT) + public suspend fun insertSubtask(subtask: Subtask): Long + + /** The position after the task's last sub-task; 0 when it has none. */ + @Query("SELECT COALESCE(MAX(position) + 1, 0) FROM subtasks WHERE taskId = :taskId") + public suspend fun nextSubtaskPosition(taskId: Long): Int + + /** Adds a sub-task after the task's last one; its row id. */ + @Transaction + public suspend fun appendSubtask(taskId: Long, title: String): Long = + insertSubtask(Subtask(taskId = taskId, title = title, position = nextSubtaskPosition(taskId))) + + @Query("UPDATE subtasks SET title = :title WHERE id = :id") + public suspend fun renameSubtask(id: Long, title: String): Int + + @Query("DELETE FROM subtasks WHERE id = :id") + public suspend fun deleteSubtaskById(id: Long): Int + + @Query("UPDATE subtasks SET position = position + 1 WHERE taskId = :taskId AND position >= :position") + public suspend fun shiftSubtasksFrom(taskId: Long, position: Int): Int + + /** + * Puts a deleted sub-task back (undo) at its old position, with its uid: the rows from that + * position on move down one. It gets a new row id, as its old one may have been reused. + */ + @Transaction + public suspend fun restoreSubtask(subtask: Subtask) { + shiftSubtasksFrom(subtask.taskId, subtask.position) + insertSubtask(subtask.copy(id = 0)) + } + + @Update + public suspend fun updateSubtask(subtask: Subtask): Int + + @Query("DELETE FROM subtasks WHERE taskId = :taskId AND id NOT IN (:keepIds)") + public suspend fun deleteSubtasksExcept(taskId: Long, keepIds: List): Int + + /** + * Makes [subtasks] the task's whole checklist, in this order: rows that are no longer in + * the list are deleted, the others are updated in place (keeping their id and uid), and + * new ones (id 0) are inserted. + */ + @Transaction + public suspend fun replaceSubtasks(taskId: Long, subtasks: List) { + deleteSubtasksExcept(taskId, subtasks.map { it.id }.filter { it != 0L }) + subtasks.forEachIndexed { index, subtask -> + val row = subtask.copy(taskId = taskId, position = index) + // A row id that is gone (deleted meanwhile) is inserted again rather than lost. + if (row.id == 0L || updateSubtask(row) == 0) insertSubtask(row) + } + } + + /** Puts a deleted task back together with its sub-tasks (undo). */ + @Transaction + public suspend fun restoreTask(task: Task, subtasks: List) { + insertTask(task) + subtasks.forEach { insertSubtask(it.copy(taskId = task.id)) } + } +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Note.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Note.kt new file mode 100644 index 00000000..9ced61ee --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Note.kt @@ -0,0 +1,79 @@ +package com.pasich.encly.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * [uid] is the note's stable, device-independent identity used by encrypted backups (merge + * skips a uid that already exists). Code never assigns it: a blank uid is filled with a random + * one by the database on insert and restored on update (see VaultSchema). + */ +@Entity(tableName = "notes", indices = [Index(value = ["uid"], unique = true)]) +public data class Note( + @PrimaryKey(autoGenerate = true) + var id: Long = 0, + var title: String = "", + var value: String = "", + var description: String = "", + var date: Long = 0L, + var dateCreate: Long = 0L, + @Ignore var isChecked: Boolean = false, + var isTrash: Boolean = false, + @ColumnInfo(defaultValue = "''") + var uid: String = "", + @ColumnInfo(defaultValue = "0") + var isPinned: Boolean = false, + @ColumnInfo(defaultValue = "0") + var isArchived: Boolean = false, + /** A [com.pasich.encly.domain.model.NoteColor] key, never an ARGB value. */ + @ColumnInfo(defaultValue = "'none'") + var color: String = NO_COLOR, + /** The note's own text size in sp, or null to follow the default from Settings. */ + var textSize: Int? = null, + /** The note's own line spacing (a `NoteLineSpacing` ordinal), or null to follow the default. */ + var lineSpacing: Int? = null, + /** + * When the note went to the trash (epoch millis), or null while it is not in the trash. The trash empties a + * note 28 days after this; a trashed note without it (an imported one) gets the time of the next check. + */ + var trashedAt: Long? = null, + /** + * The ids of the note's tags, as the editor holds them. Not a column: the links live in `note_tags` + * ([NoteTag]), and a note read straight from `notes` has none here. Only a read that loads the links + * (the repository's) fills it, and only a write that stores the links saves it. + */ + @Ignore var tagIds: Set = emptySet(), +) { + + /** + * Returns true if the note contains neither a title nor content. + */ + public fun isEmpty(): Boolean = title.isBlank() && value.isBlank() + + /** + * Checks whether there are changes in the note's content or title. + */ + public fun hasContentChanged(newBlocksJson: String, newTitle: String): Boolean { + if (title != newTitle) { + return true + } + // Check for changes in the block content + return value != newBlocksJson + } + + public companion object { + /** The stored key of "no colour"; the palette lives in `domain.model.NoteColor`. */ + public const val NO_COLOR: String = "none" + + public fun new(title: String, value: String, tagIds: Set = emptySet()): Note = Note( + title = title, + value = value, + date = nowMillis(), + dateCreate = nowMillis(), + tagIds = tagIds, + ) + } +} diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteTag.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteTag.kt new file mode 100644 index 00000000..dcd664c0 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteTag.kt @@ -0,0 +1,33 @@ +package com.pasich.encly.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * One tag of one note: a note has any number of tags. Deleting the note or the tag deletes the link (the database + * cascades it), so a link never points at a row that is gone. + */ +@Entity( + tableName = "note_tags", + primaryKeys = ["noteId", "tagId"], + foreignKeys = [ + ForeignKey( + entity = Note::class, + parentColumns = ["id"], + childColumns = ["noteId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = Tag::class, + parentColumns = ["id"], + childColumns = ["tagId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["tagId"])], +) +public data class NoteTag(val noteId: Long, val tagId: Long) + +/** How many notes outside the trash (archived ones included) carry the tag [tagId]. */ +public data class TagNoteCount(val tagId: Long, val count: Int) diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteWithTags.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteWithTags.kt new file mode 100644 index 00000000..e4210629 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteWithTags.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.data.model + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +/** A note with its tags. The database gives the tags in no particular order; readers sort them. */ +public data class NoteWithTags( + @Embedded val note: Note, + @Relation( + parentColumn = "id", + entityColumn = "id", + associateBy = Junction(NoteTag::class, parentColumn = "noteId", entityColumn = "tagId"), + ) + val tags: List, +) diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NowMillis.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NowMillis.kt new file mode 100644 index 00000000..cfc81560 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NowMillis.kt @@ -0,0 +1,10 @@ +package com.pasich.encly.data.model + +import kotlin.time.Clock + +/** + * Milliseconds since the Unix epoch from the system clock, the default dates of new notes and tasks. + * Same value as `core.common.currentTimeMillis`; the module keeps its own copy so it depends on no + * other Encly module. + */ +internal fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/app/src/main/java/com/pasich/encly/data/model/Subtask.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Subtask.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/data/model/Subtask.kt rename to core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Subtask.kt index 6778b51c..efd66966 100644 --- a/app/src/main/java/com/pasich/encly/data/model/Subtask.kt +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Subtask.kt @@ -23,7 +23,7 @@ import androidx.room.PrimaryKey ], indices = [Index(value = ["taskId"]), Index(value = ["uid"], unique = true)], ) -data class Subtask( +public data class Subtask( @PrimaryKey(autoGenerate = true) val id: Long = 0, val taskId: Long, diff --git a/app/src/main/java/com/pasich/encly/data/model/Tag.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Tag.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/data/model/Tag.kt rename to core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Tag.kt index 5be6d879..3b121d02 100644 --- a/app/src/main/java/com/pasich/encly/data/model/Tag.kt +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Tag.kt @@ -7,7 +7,7 @@ import androidx.room.PrimaryKey /** [uid]: stable backup identity, see [Note.uid]. */ @Entity(tableName = "tags", indices = [Index(value = ["uid"], unique = true)]) -data class Tag( +public data class Tag( @PrimaryKey(autoGenerate = true) var id: Long = 0, @ColumnInfo(name = "name") diff --git a/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Task.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Task.kt new file mode 100644 index 00000000..744bb02f --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/Task.kt @@ -0,0 +1,33 @@ +package com.pasich.encly.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** [uid]: stable backup identity, see [Note.uid]. */ +@Entity(tableName = "tasks", indices = [Index(value = ["uid"], unique = true)]) +public data class Task( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val title: String, + val description: String? = null, + val isCompleted: Boolean = false, + val createdDate: Long = nowMillis(), + val completedDate: Long? = null, + val priority: Int = 0, // 0 - Low, 1 - Medium, 2 - High + val categoryId: Long? = null, + var position: Int = 0, + @ColumnInfo(defaultValue = "''") + val uid: String = "", +) { + public companion object { + public fun new(title: String, description: String? = null, priority: Int = 0, categoryId: Long? = null): Task = + Task( + title = title, + description = description, + priority = priority, + categoryId = categoryId, + ) + } +} diff --git a/core/database/src/commonTest/kotlin/com/pasich/encly/data/database/ConstantTimeEqualsTest.kt b/core/database/src/commonTest/kotlin/com/pasich/encly/data/database/ConstantTimeEqualsTest.kt new file mode 100644 index 00000000..376514e2 --- /dev/null +++ b/core/database/src/commonTest/kotlin/com/pasich/encly/data/database/ConstantTimeEqualsTest.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.data.database + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConstantTimeEqualsTest { + @Test + fun equalArraysMatch() { + assertTrue(constantTimeEquals(byteArrayOf(1, 2, 3), byteArrayOf(1, 2, 3))) + assertTrue(constantTimeEquals(ByteArray(0), ByteArray(0))) + } + + @Test + fun aDifferenceAnywhereIsFoundWhereverItIs() { + val base = ByteArray(32) { it.toByte() } + for (i in base.indices) { + val other = base.copyOf().also { it[i] = (it[i] + 1).toByte() } + assertFalse(constantTimeEquals(base, other), "difference at $i") + assertFalse(constantTimeEquals(other, base), "difference at $i") + } + } + + @Test + fun arraysOfOtherLengthsNeverMatchEvenWhenOneIsAPrefix() { + assertFalse(constantTimeEquals(byteArrayOf(1, 2), byteArrayOf(1, 2, 0))) + assertFalse(constantTimeEquals(byteArrayOf(1, 2, 0), byteArrayOf(1, 2))) + assertFalse(constantTimeEquals(ByteArray(0), byteArrayOf(0))) + } +} diff --git a/core/database/src/desktopMain/kotlin/com/pasich/encly/data/database/DesktopDatabasePlatform.kt b/core/database/src/desktopMain/kotlin/com/pasich/encly/data/database/DesktopDatabasePlatform.kt new file mode 100644 index 00000000..666f4304 --- /dev/null +++ b/core/database/src/desktopMain/kotlin/com/pasich/encly/data/database/DesktopDatabasePlatform.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.data.database + +import androidx.room.Room +import androidx.room.RoomDatabase +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem + +/** + * The desktop [DatabasePlatform]: both vault files live in [directory], which the app chooses + * (its per-user data directory). The directory is created on first use. + */ +public class DesktopDatabasePlatform(private val directory: Path) : DatabasePlatform { + override fun databasePath(file: VaultFile): Path { + SystemFileSystem.createDirectories(directory) + return Path(directory, file.fileName) + } + + override fun databaseBuilder(path: Path): RoomDatabase.Builder = Room.databaseBuilder( + name = path.toString(), + factory = { AppDatabaseConstructor.initialize() }, + ) +} diff --git a/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopDaoTest.kt b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopDaoTest.kt new file mode 100644 index 00000000..908b8d0f --- /dev/null +++ b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopDaoTest.kt @@ -0,0 +1,135 @@ +package com.pasich.encly.data.database + +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Tag +import com.pasich.encly.data.model.Task +import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The DAO transactions and the entity helpers, against a real encrypted vault on the desktop. */ +class DesktopDaoTest { + private lateinit var directory: java.io.File + private lateinit var manager: SecureDatabaseManager + private lateinit var db: AppDatabase + + @BeforeTest + fun setUp() { + directory = Files.createTempDirectory("encly-dao-test").toFile() + manager = SecureDatabaseManager(DesktopDatabasePlatform(Path(directory.path))) + assertTrue(manager.unlockDatabase(KEY, allowCreate = true)) + db = manager.getDatabase(checkNotNull(manager.captureLease())) + } + + @AfterTest + fun tearDown() { + manager.reset() + directory.deleteRecursively() + } + + @Test + fun deletingATagUntagsItsNotesAndLeavesOthersAlone() = runBlocking { + val work = db.tagsDao().addTag(Tag(nameTag = "Work")) + val home = db.tagsDao().addTag(Tag(nameTag = "Home")) + val inWork = db.notesDao().insertNoteWithTags(Note(title = "w", tagIds = setOf(work))) + val inBoth = db.notesDao().insertNoteWithTags(Note(title = "b", tagIds = setOf(work, home))) + val inHome = db.notesDao().insertNoteWithTags(Note(title = "h", tagIds = setOf(home))) + + db.tagsDao().deleteTagDetachingNotes(Tag(id = work, nameTag = "Work")) + + assertEquals(emptySet(), tagIdsOf(inWork)) + assertEquals(setOf(home), tagIdsOf(inBoth)) + assertEquals(setOf(home), tagIdsOf(inHome)) + } + + @Test + fun theDatabaseCascadesTheLinksOfADeletedTagOrNote() = runBlocking { + val work = db.tagsDao().addTag(Tag(nameTag = "Work")) + val home = db.tagsDao().addTag(Tag(nameTag = "Home")) + val note = db.notesDao().insertNoteWithTags(Note(title = "n", tagIds = setOf(work, home))) + val other = db.notesDao().insertNoteWithTags(Note(title = "o", tagIds = setOf(home))) + + // The plain delete, without the explicit unlinking: the foreign key alone must remove the link. + db.tagsDao().deleteTag(Tag(id = work, nameTag = "Work")) + assertEquals(setOf(home), tagIdsOf(note)) + + db.notesDao().deleteNoteById(note) + assertEquals(listOf(other), db.backupDao().allNoteTags().map { it.noteId }) + } + + @Test + fun aLinkToATagThatDoesNotExistIsRefused() = runBlocking { + val note = db.notesDao().insertNote(Note(title = "n")) + + val linked = runCatching { db.notesDao().changeNoteTags(note, added = listOf(404L), removed = emptyList()) } + assertTrue(linked.isFailure) + assertEquals(emptySet(), tagIdsOf(note)) + } + + private suspend fun tagIdsOf(noteId: Long) = + checkNotNull(db.notesDao().getNoteWithTags(noteId)).tags.map { it.id }.toSet() + + @Test + fun subtasksAreAppendedRestoredAndReplacedInOrder() = runBlocking { + val taskId = db.tasksDao().insertTask(Task(title = "task")) + db.tasksDao().appendSubtask(taskId, "a") + db.tasksDao().appendSubtask(taskId, "b") + assertEquals(listOf("a" to 0, "b" to 1), titlesAndPositions(taskId)) + + db.tasksDao().restoreSubtask(Subtask(taskId = taskId, title = "back", position = 0)) + assertEquals(listOf("back" to 0, "a" to 1, "b" to 2), titlesAndPositions(taskId)) + + val keep = db.tasksDao().getSubtasks(taskId).first { it.title == "a" } + db.tasksDao().replaceSubtasks(taskId, listOf(Subtask(taskId = taskId, title = "new"), keep.copy(title = "a2"))) + assertEquals(listOf("new" to 0, "a2" to 1), titlesAndPositions(taskId)) + assertEquals(keep.uid, db.tasksDao().getSubtasks(taskId).first { it.title == "a2" }.uid) + } + + @Test + fun aDeletedTaskComesBackWithItsSubtasks() = runBlocking { + val task = Task(id = 7, title = "gone") + + db.tasksDao().restoreTask( + task, + listOf(Subtask(taskId = 0, title = "s1"), Subtask(taskId = 0, title = "s2", position = 1)), + ) + + assertEquals("gone", checkNotNull(db.tasksDao().getTaskById(7)).title) + assertEquals(listOf("s1" to 0, "s2" to 1), titlesAndPositions(7)) + } + + @Test + fun theHelpersOfNotesAndTasksFillTheirDefaults() { + val note = Note.new("title", "[]", tagIds = setOf(3)) + assertTrue(note.date > 0 && note.dateCreate > 0) + assertEquals(setOf(3L), note.tagIds) + assertEquals(Note.NO_COLOR, note.color) + + val task = Task.new("t", description = "d", priority = 2, categoryId = 5) + assertTrue(task.createdDate > 0) + assertEquals(2, task.priority) + assertEquals(5L, task.categoryId) + + assertTrue(Note().isEmpty()) + assertFalse(Note(title = "x").isEmpty()) + assertTrue(note.hasContentChanged("[]", "other title")) + assertTrue(note.hasContentChanged("[1]", "title")) + assertFalse(note.hasContentChanged("[]", "title")) + } + + private suspend fun titlesAndPositions(taskId: Long) = db.tasksDao().getSubtasks(taskId).map { + it.title to + it.position + } + + private companion object { + val KEY = ByteArray(32) { (it + 3).toByte() } + } +} diff --git a/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopMigrationTest.kt b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopMigrationTest.kt new file mode 100644 index 00000000..8df0275c --- /dev/null +++ b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopMigrationTest.kt @@ -0,0 +1,289 @@ +package com.pasich.encly.data.database + +import androidx.room.useReaderConnection +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import io.github.pasichdev.sealite.SealiteDriver +import io.github.pasichdev.sealite.SealiteKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Upgrades real older vault files, encrypted, through [SecureDatabaseManager] on the desktop: the + * exported schema of an earlier version is written into a SQLCipher file through Sealite, the way + * that version created it, and the manager must migrate it to the current version without losing a note. + */ +class DesktopMigrationTest { + private lateinit var directory: File + private lateinit var manager: SecureDatabaseManager + + @BeforeTest + fun setUp() { + directory = Files.createTempDirectory("encly-migration-test").toFile() + manager = SecureDatabaseManager(DesktopDatabasePlatform(Path(directory.path))) + } + + @AfterTest + fun tearDown() { + manager.reset() + directory.deleteRecursively() + } + + private fun writeEncryptedVersion(version: Int, vararg rowsSql: String) { + val schema = Json.parseToJsonElement(File("schemas/$SCHEMA_DIR/$version.json").readText()) + .jsonObject.getValue("database").jsonObject + val statements = buildList { + schema.getValue("entities").jsonArray.forEach { entity -> + val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content + add(entity.jsonObject.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table)) + // Version 1 predates the indices. + entity.jsonObject["indices"]?.jsonArray.orEmpty().forEach { index -> + add(index.jsonObject.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table)) + } + } + schema.getValue("setupQueries").jsonArray.forEach { add(it.jsonPrimitive.content) } + } + val key = SealiteKey.raw(KEY) + val driver = SealiteDriver(key) + key.close() + driver.use { + val connection: SQLiteConnection = it.open(File(directory, VaultFile.PRIMARY.fileName).path) + connection.use { db -> + statements.forEach(db::execSQL) + // The uid columns, and with them the triggers, came in version 2; the subtasks in 4. + if (version >= 2) { + VaultSchema.triggerStatements() + .filter { version >= 4 || "subtasks" !in it } + .forEach(db::execSQL) + } + rowsSql.forEach(db::execSQL) + db.execSQL("PRAGMA user_version = $version") + } + } + } + + private suspend fun userVersion(db: AppDatabase): Int = db.useReaderConnection { connection -> + connection.usePrepared("PRAGMA user_version") { + it.step() + it.getLong(0).toInt() + } + } + + @Test + fun anEncryptedVersion4VaultMigratesToTheCurrentVersionAndKeepsItsNotes() = runBlocking { + writeEncryptedVersion( + 4, + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (5, 'Old note', '[]', 'd', 20, 10, NULL, 1, 'old-uid')", + ) + assertTrue(manager.hasEncryptedDatabase()) + + assertFalse(manager.unlockDatabase(ByteArray(32) { 9 }), "another key must not open it") + assertTrue(manager.unlockDatabase(KEY)) + val db = manager.getDatabase(checkNotNull(manager.captureLease())) + + val old = checkNotNull(db.notesDao().getNoteById(5)) + assertEquals("Old note", old.title) + assertEquals("old-uid", old.uid) + assertEquals(true, old.isTrash) + assertEquals("none", old.color) + assertNull(old.textSize) + assertEquals(DB_VERSION, userVersion(db)) + assertEquals(listOf("Old note"), db.notesDao().getTrashNotes().first().map { it.title }) + } + + @Test + fun anEncryptedVersion1VaultGetsUidsForEveryRowAndLosesTheTaskReminders() = runBlocking { + writeEncryptedVersion( + 1, + "INSERT INTO tags (id, name, visibility, position) VALUES (1, 'Work', 1, 0)", + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash) " + + "VALUES (1, 'n1', '[]', 'd', 1, 1, 1, 0)", + "INSERT INTO tasks (id, title, description, isCompleted, createdDate, completedDate, " + + "reminderDate, priority, categoryId, position) VALUES (1, 't1', NULL, 0, 5, NULL, 99, 1, NULL, 0)", + ) + + assertTrue(manager.unlockDatabase(KEY)) + val db = manager.getDatabase(checkNotNull(manager.captureLease())) + + assertEquals("n1", checkNotNull(db.notesDao().getNoteById(1)).title) + assertEquals(UID_LENGTH, checkNotNull(db.notesDao().getNoteById(1)).uid.length) + assertEquals(UID_LENGTH, db.tagsDao().getTags().first().single().uid.length) + val task = checkNotNull(db.tasksDao().getTaskById(1)) + assertEquals("t1", task.title) + assertEquals(UID_LENGTH, task.uid.length) + assertEquals(DB_VERSION, userVersion(db)) + + // The triggers of the migrated vault fill a blank uid, and the sub-tasks table is there. + val fresh = db.notesDao().insertNote(com.pasich.encly.data.model.Note(title = "new")) + assertEquals(UID_LENGTH, checkNotNull(db.notesDao().getNoteById(fresh)).uid.length) + db.tasksDao().appendSubtask(1, "step") + assertEquals(1, db.tasksDao().getSubtasks(1).size) + // The note's tag came all the way through to note_tags. + assertEquals(listOf("Work"), checkNotNull(db.notesDao().getNoteWithTags(1)).tags.map { it.nameTag }) + } + + @Test + fun anEncryptedVersion2VaultMigratesAndKeepsItsUids() = runBlocking { + writeEncryptedVersion( + 2, + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (1, 'n1', '[]', 'd', 1, 1, NULL, 0, 'note-uid')", + "INSERT INTO tasks (id, title, description, isCompleted, createdDate, completedDate, " + + "reminderDate, priority, categoryId, position, uid) " + + "VALUES (1, 't1', NULL, 0, 5, NULL, 99, 1, NULL, 0, 'task-uid')", + ) + + assertTrue(manager.unlockDatabase(KEY)) + val db = manager.getDatabase(checkNotNull(manager.captureLease())) + + assertEquals("note-uid", checkNotNull(db.notesDao().getNoteById(1)).uid) + assertEquals("task-uid", checkNotNull(db.tasksDao().getTaskById(1)).uid) + assertEquals(DB_VERSION, userVersion(db)) + } + + @Test + fun anEncryptedVersion3VaultGainsTheSubtasksTable() = runBlocking { + writeEncryptedVersion( + 3, + "INSERT INTO tasks (id, title, description, isCompleted, createdDate, completedDate, " + + "priority, categoryId, position, uid) VALUES (1, 't1', NULL, 0, 5, NULL, 1, NULL, 0, 'task-uid')", + ) + + assertTrue(manager.unlockDatabase(KEY)) + val db = manager.getDatabase(checkNotNull(manager.captureLease())) + + assertEquals("task-uid", checkNotNull(db.tasksDao().getTaskById(1)).uid) + db.tasksDao().appendSubtask(1, "step") + assertEquals(listOf("step"), db.tasksDao().getSubtasks(1).map { it.title }) + assertEquals(DB_VERSION, userVersion(db)) + } + + @Test + fun anEncryptedVersion5VaultGetsTheNoteTextSettings() = runBlocking { + writeEncryptedVersion( + 5, + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid, " + + "isPinned, isArchived, color) " + + "VALUES (5, 'Old note', '[]', 'd', 20, 10, NULL, 0, 'old-uid', 1, 0, 'red')", + ) + + assertTrue(manager.unlockDatabase(KEY)) + val db = manager.getDatabase(checkNotNull(manager.captureLease())) + + val old = checkNotNull(db.notesDao().getNoteById(5)) + assertEquals(true, old.isPinned) + assertEquals("red", old.color) + assertNull(old.textSize) + assertNull(old.lineSpacing) + assertEquals(DB_VERSION, userVersion(db)) + } + + @Test + fun anEncryptedVersion6VaultStampsItsTrashedNotesWithTheTimeOfTheMigration() = runBlocking { + writeEncryptedVersion( + 6, + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (1, 'Trashed', '[]', 'd', 20, 10, NULL, 1, 'trashed-uid')", + "INSERT INTO notes (id, title, value, description, date, dateCreate, tagId, isTrash, uid) " + + "VALUES (2, 'Kept', '[]', 'd', 20, 10, NULL, 0, 'kept-uid')", + ) + val before = System.currentTimeMillis() + + assertTrue(manager.unlockDatabase(KEY)) + val db = manager.getDatabase(checkNotNull(manager.captureLease())) + + val stamp = checkNotNull(db.notesDao().getNoteById(1)).trashedAt + assertTrue(stamp != null && stamp >= before - 1_000 && stamp <= before + 60_000, "stamp $stamp") + assertNull(db.notesDao().getNoteById(2)?.trashedAt) + assertEquals(DB_VERSION, userVersion(db)) + } + + /** Tagged, untagged and dangling notes of [version] (6 or 7: the same rows, 7 adds `trashedAt`). */ + private fun taggedNotes(version: Int): Array { + val columns = "id, title, value, description, date, dateCreate, tagId, isTrash, uid, isPinned, isArchived, " + + "color, textSize, lineSpacing" + if (version >= 7) ", trashedAt" else "" + val trashedAt = if (version >= 7) ", 77" else "" + return arrayOf( + "INSERT INTO tags (id, name, visibility, position, uid) VALUES (1, 'Work', 1, 0, 'tag-1')", + "INSERT INTO tags (id, name, visibility, position, uid) VALUES (2, 'Secret', 0, 1, 'tag-2')", + "INSERT INTO notes ($columns) VALUES (1, 'Work note', '[]', 'd', 20, 10, 1, 0, 'n-1', 1, 0, 'red', 18, 2" + + (if (version >= 7) ", NULL" else "") + ")", + "INSERT INTO notes ($columns) VALUES (2, 'Secret note', '[]', '', 20, 10, 2, 1, 'n-2', 0, 1, 'none', " + + "NULL, NULL$trashedAt)", + "INSERT INTO notes ($columns) VALUES (3, 'Untagged', '[]', '', 20, 10, NULL, 0, 'n-3', 0, 0, 'none', " + + "NULL, NULL" + (if (version >= 7) ", NULL" else "") + ")", + // A tag deleted by an old version without untagging its note. + "INSERT INTO notes ($columns) VALUES (4, 'Dangling', '[]', '', 20, 10, 99, 0, 'n-4', 0, 0, 'none', " + + "NULL, NULL" + (if (version >= 7) ", NULL" else "") + ")", + // A deleted note: its id must never come back. + "INSERT INTO notes ($columns) VALUES (40, 'Deleted', '[]', '', 20, 10, NULL, 0, 'n-40', 0, 0, 'none', " + + "NULL, NULL" + (if (version >= 7) ", NULL" else "") + ")", + "DELETE FROM notes WHERE id = 40", + ) + } + + private suspend fun assertTaggedNotesMigrated(db: AppDatabase) { + val notes = db.notesDao() + suspend fun names(id: Long) = checkNotNull(notes.getNoteWithTags(id)).tags.map { it.nameTag } + assertEquals(listOf("Work"), names(1)) + assertEquals(listOf("Secret"), names(2)) + assertEquals(emptyList(), names(3)) + assertEquals(emptyList(), names(4)) + val work = checkNotNull(notes.getNoteById(1)) + assertEquals("n-1", work.uid) + assertEquals(true, work.isPinned) + assertEquals("red", work.color) + assertEquals(18, work.textSize) + assertEquals(2, work.lineSpacing) + val secret = checkNotNull(notes.getNoteById(2)) + assertEquals(true, secret.isTrash) + assertEquals(true, secret.isArchived) + assertTrue(secret.trashedAt != null) + assertEquals(DB_VERSION, userVersion(db)) + // The uid trigger is back on the rebuilt table, and the id counter kept its place. + val fresh = notes.insertNoteWithTags(com.pasich.encly.data.model.Note(title = "new", tagIds = setOf(1, 2))) + assertEquals(41L, fresh) + assertEquals(UID_LENGTH, checkNotNull(notes.getNoteById(fresh)).uid.length) + val inWork = notes.getNotesOfTagWithArchived(1).first().map { it.note.title }.sorted() + assertEquals(listOf("Work note", "new"), inWork) + } + + @Suppress("SpreadOperator") // A handful of rows, once. + @Test + fun anEncryptedVersion7VaultKeepsEveryNotesTagInTheNewLinks() = runBlocking { + writeEncryptedVersion(7, *taggedNotes(7)) + + assertTrue(manager.unlockDatabase(KEY)) + assertTaggedNotesMigrated(manager.getDatabase(checkNotNull(manager.captureLease()))) + } + + @Suppress("SpreadOperator") // A handful of rows, once. + @Test + fun anEncryptedVersion6VaultKeepsEveryNotesTagThroughBothMigrations() = runBlocking { + writeEncryptedVersion(6, *taggedNotes(6)) + + assertTrue(manager.unlockDatabase(KEY)) + assertTaggedNotesMigrated(manager.getDatabase(checkNotNull(manager.captureLease()))) + } + + private companion object { + const val UID_LENGTH = 32 + const val SCHEMA_DIR = "com.pasich.encly.data.database.AppDatabase" + val KEY = ByteArray(32) { (it + 1).toByte() } + } +} diff --git a/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopPoolTest.kt b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopPoolTest.kt new file mode 100644 index 00000000..3da5ebbd --- /dev/null +++ b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopPoolTest.kt @@ -0,0 +1,81 @@ +package com.pasich.encly.data.database + +import androidx.room.useReaderConnection +import com.pasich.encly.data.model.Note +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The size of the vault's connection pool and what each connection of it costs in memory. */ +class DesktopPoolTest { + private lateinit var root: File + private lateinit var manager: SecureDatabaseManager + + @BeforeTest + fun setUp() { + root = Files.createTempDirectory("encly-pool-test").toFile() + manager = SecureDatabaseManager(DesktopDatabasePlatform(Path(File(root, "databases").path))) + assertTrue(manager.unlockDatabase(KEY, allowCreate = true)) + } + + @AfterTest + fun tearDown() { + manager.reset() + root.deleteRecursively() + } + + private fun database() = manager.getDatabase(checkNotNull(manager.captureLease())) + + @Test + fun manyReadsAtOnceOpenNoMoreThanTheCappedPool() = runBlocking { + val notes = database().notesDao() + repeat(NOTES) { notes.insertNote(Note(title = "note $it")) } + + // Far more readers than the cap, all started together on threads of their own. + val sizes = (1..READERS).map { async(Dispatchers.Default) { notes.getAllNotes().first().size } }.awaitAll() + + assertTrue(sizes.all { it == NOTES }) + // The writer, and no more readers than the queries that may run at once. + val cap = 1 + QUERY_PARALLELISM + assertTrue(manager.openedConnections() <= cap, "opened ${manager.openedConnections()}, cap $cap") + } + + @Test + fun everyConnectionKeepsAModestPageCache() = runBlocking { + val cacheSizes = (1..READERS).map { + async(Dispatchers.Default) { + database().useReaderConnection { connection -> + connection.usePrepared("PRAGMA cache_size") { + it.step() + it.getLong(0) + } + } + } + }.awaitAll() + + assertEquals(setOf(-LeanDriver.CACHE_KIB.toLong()), cacheSizes.toSet()) + } + + @Test + fun theCountStartsOverWithTheNextOpening() { + assertTrue(manager.openedConnections() >= 1) + manager.reset() + assertEquals(0, manager.openedConnections()) + } + + private companion object { + const val NOTES = 5 + const val READERS = 40 + val KEY = ByteArray(32) { (it + 7).toByte() } + } +} diff --git a/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopVaultTest.kt b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopVaultTest.kt new file mode 100644 index 00000000..43ebdbf3 --- /dev/null +++ b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopVaultTest.kt @@ -0,0 +1,251 @@ +package com.pasich.encly.data.database + +import com.pasich.encly.data.model.Note +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The encrypted vault on the desktop JVM: Room opened through Sealite's native SQLCipher, the real + * driver and not a stand-in, so these prove the desktop target keeps the vault encrypted and its + * lifecycle intact. + */ +class DesktopVaultTest { + private lateinit var root: File + + // The directory does not exist yet: the first run has to create it. + private val directory get() = File(root, "data/databases") + private lateinit var manager: SecureDatabaseManager + + @BeforeTest + fun setUp() { + root = Files.createTempDirectory("encly-vault-test").toFile() + manager = newManager() + } + + @AfterTest + fun tearDown() { + manager.reset() + root.deleteRecursively() + } + + private fun newManager() = SecureDatabaseManager(DesktopDatabasePlatform(Path(directory.path))) + + private fun database(m: SecureDatabaseManager = manager): AppDatabase = + m.getDatabase(checkNotNull(m.captureLease())) + + private fun fileOf(vault: VaultFile) = File(directory, vault.fileName) + + private fun allBytes(vault: VaultFile): ByteArray = + listOf("", "-wal").map { File(fileOf(vault).path + it) }.filter { it.exists() } + .fold(ByteArray(0)) { acc, file -> acc + file.readBytes() } + + @Test + fun theFirstRunCreatesTheDirectoryAndAnEncryptedFile() = runBlocking { + assertFalse(manager.hasEncryptedDatabase()) + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + database().notesDao().insertNote(Note(title = TITLE, value = "[]")) + manager.reset() + + assertTrue(fileOf(VaultFile.PRIMARY).isFile) + assertTrue(manager.hasEncryptedDatabase()) + val bytes = allBytes(VaultFile.PRIMARY) + assertFalse(bytes.copyOf(PLAIN_HEADER.size).contentEquals(PLAIN_HEADER), "the header must be encrypted too") + assertFalse(String(bytes, Charsets.ISO_8859_1).contains(TITLE), "the note must not be readable in the file") + } + + @Test + fun aClosedVaultReopensWithItsNotesAndRefusesAnotherKey() = runBlocking { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + database().notesDao().insertNote(Note(title = TITLE)) + manager.reset() + assertFalse(manager.isDatabaseUnlocked()) + assertNull(manager.captureLease()) + + assertFalse(manager.unlockDatabase(KEY_B), "another key must not open it") + assertFalse(manager.isDatabaseUnlocked()) + assertTrue(manager.unlockDatabase(KEY_A)) + assertEquals(listOf(TITLE), database().notesDao().getAllNotes().first().map { it.title }) + } + + @Test + fun aMissingVaultIsNotCreatedUnlessAllowed() { + assertFalse(manager.unlockDatabase(KEY_A)) + + assertFalse(manager.isDatabaseUnlocked()) + assertFalse(fileOf(VaultFile.PRIMARY).exists()) + } + + @Test + fun aPlaintextDatabaseIsRefusedAndLeftAlone() { + directory.mkdirs() + val plain = PLAIN_HEADER + ByteArray(PAGE) { 1 } + fileOf(VaultFile.PRIMARY).writeBytes(plain) + + assertFalse(manager.unlockDatabase(KEY_A, allowCreate = true)) + + assertFalse(manager.isDatabaseUnlocked()) + assertContentEquals(plain, fileOf(VaultFile.PRIMARY).readBytes()) + } + + @Test + fun aDamagedFileIsRefusedAndNeverDeleted() { + directory.mkdirs() + val junk = ByteArray(PAGE * 2) { (it * 31).toByte() } + fileOf(VaultFile.PRIMARY).writeBytes(junk) + + assertFalse(manager.unlockDatabase(KEY_A)) + + assertFalse(manager.isDatabaseUnlocked()) + assertContentEquals(junk, fileOf(VaultFile.PRIMARY).readBytes()) + } + + @Test + fun anOpenVaultAcceptsOnlyTheSameKeyAndFile() { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + + assertTrue(manager.unlockDatabase(KEY_A)) + assertFalse(manager.unlockDatabase(KEY_B)) + assertFalse(manager.unlockDatabase(KEY_A, allowCreate = true, file = VaultFile.DECOY)) + assertTrue(manager.isDatabaseUnlocked()) + } + + @Test + fun aKeyOfTheWrongSizeIsRefusedBeforeAnyFileIsTouched() { + assertFalse(manager.unlockDatabase(ByteArray(31), allowCreate = true)) + assertFalse(manager.unlockDatabase(ByteArray(33), allowCreate = true, file = VaultFile.DECOY)) + + assertFalse(directory.exists()) + } + + @Test + fun aLeaseDiesWithTheOpening() { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + val old = checkNotNull(manager.captureLease()) + assertNotNull(manager.getDatabase(old)) + + manager.reset() + assertFailsWith { manager.getDatabase(old) } + + assertTrue(manager.unlockDatabase(KEY_A)) + assertFailsWith { manager.getDatabase(old) } + assertNotNull(manager.getDatabase(checkNotNull(manager.captureLease()))) + } + + @Test + fun theDecoyVaultHasItsOwnFileAndKey() = runBlocking { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + database().notesDao().insertNote(Note(title = "real")) + manager.reset() + + assertFalse(manager.hasEncryptedDatabase(VaultFile.DECOY)) + assertTrue(manager.unlockDatabase(KEY_B, allowCreate = true, file = VaultFile.DECOY)) + database().notesDao().insertNote(Note(title = "decoy")) + manager.reset() + + assertTrue(fileOf(VaultFile.DECOY).isFile) + assertTrue(manager.hasEncryptedDatabase(VaultFile.DECOY)) + assertTrue(manager.unlockDatabase(KEY_A)) + assertEquals(listOf("real"), database().notesDao().getAllNotes().first().map { it.title }) + manager.reset() + assertTrue(manager.unlockDatabase(KEY_B, allowCreate = false, file = VaultFile.DECOY)) + assertEquals(listOf("decoy"), database().notesDao().getAllNotes().first().map { it.title }) + } + + @Test + fun wipeRemovesBothVaultsAndWipeIfClosedSpareAnOpenOne() { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + manager.reset() + assertTrue(manager.unlockDatabase(KEY_B, allowCreate = true, file = VaultFile.DECOY)) + manager.reset() + val unrelated = File(directory, "other.db").apply { writeBytes(byteArrayOf(1)) } + + assertTrue(manager.unlockDatabase(KEY_A)) + assertFalse(manager.wipeIfClosed(), "an open vault is not wiped") + assertFalse(manager.wipeIfClosed(only = VaultFile.PRIMARY)) + assertTrue(fileOf(VaultFile.PRIMARY).exists()) + assertTrue(manager.wipeIfClosed(only = VaultFile.DECOY), "the other vault's file may go") + assertFalse(fileOf(VaultFile.DECOY).exists()) + assertTrue(fileOf(VaultFile.PRIMARY).exists()) + + manager.wipe() + + assertFalse(manager.isDatabaseUnlocked()) + VaultFile.entries.forEach { assertFalse(fileOf(it).exists()) } + assertTrue(unrelated.exists()) + } + + @Test + fun theDatabaseDeletesItsSideFilesToo() { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + manager.reset() + listOf("-wal", "-shm", "-journal").forEach { + File(fileOf(VaultFile.PRIMARY).path + it).writeBytes(byteArrayOf(7)) + } + + assertTrue(manager.wipeIfClosed()) + + directory.listFiles().orEmpty().forEach { assertFalse(it.name.startsWith("database.db"), it.name) } + } + + @Test + fun theUidTriggersAreInstalledOnANewVault() = runBlocking { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + + val id = database().notesDao().insertNote(Note(title = "no uid")) + + assertEquals(32, checkNotNull(database().notesDao().getNoteById(id)).uid.length) + } + + @Test + fun parallelReadersOpenExtraConnectionsWithTheRealKey() = runBlocking { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + repeat(NOTES) { database().notesDao().insertNote(Note(title = "n$it")) } + + val counts = (1..READERS).map { + async(Dispatchers.IO) { database().notesDao().getAllNotes().first().size } + }.awaitAll() + + assertTrue(counts.all { it == NOTES }) + } + + @Test + fun aSecondManagerOpensTheSameFiles() = runBlocking { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + database().notesDao().insertNote(Note(title = TITLE)) + manager.reset() + + val other = newManager() + try { + assertTrue(other.unlockDatabase(KEY_A)) + assertEquals(listOf(TITLE), database(other).notesDao().getAllNotes().first().map { it.title }) + } finally { + other.reset() + } + } + + private companion object { + const val TITLE = "a distinctive secret title 9f3a" + const val PAGE = 4096 + const val NOTES = 40 + const val READERS = 8 + val PLAIN_HEADER = "SQLite format 3\u0000".encodeToByteArray() + val KEY_A = ByteArray(32) { (it + 1).toByte() } + val KEY_B = ByteArray(32) { (it + 101).toByte() } + } +} diff --git a/core/database/src/iosMain/kotlin/com/pasich/encly/data/database/IosDatabasePlatform.kt b/core/database/src/iosMain/kotlin/com/pasich/encly/data/database/IosDatabasePlatform.kt new file mode 100644 index 00000000..b9a090aa --- /dev/null +++ b/core/database/src/iosMain/kotlin/com/pasich/encly/data/database/IosDatabasePlatform.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.data.database + +import androidx.room.Room +import androidx.room.RoomDatabase +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem + +/** + * The iOS [DatabasePlatform]: both vault files live in [directory], which the app chooses (a + * directory inside its sandbox). The directory is created on first use. + */ +public class IosDatabasePlatform(private val directory: Path) : DatabasePlatform { + override fun databasePath(file: VaultFile): Path { + SystemFileSystem.createDirectories(directory) + return Path(directory, file.fileName) + } + + override fun databaseBuilder(path: Path): RoomDatabase.Builder = Room.databaseBuilder( + name = path.toString(), + factory = { AppDatabaseConstructor.initialize() }, + ) +} diff --git a/core/keystore/.gitignore b/core/keystore/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/core/keystore/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/src/test/resources/import-fixtures/joplin/md/Work/Call the bank.md b/core/keystore/api/android/keystore.api similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/md/Work/Call the bank.md rename to core/keystore/api/android/keystore.api diff --git a/core/keystore/api/desktop/keystore.api b/core/keystore/api/desktop/keystore.api new file mode 100644 index 00000000..30e2fddb --- /dev/null +++ b/core/keystore/api/desktop/keystore.api @@ -0,0 +1,17 @@ +public final class com/pasich/encly/core/keystore/DesktopPinFactor : com/pasich/encly/core/security/PinHardwareFactor { + public static final field Companion Lcom/pasich/encly/core/keystore/DesktopPinFactor$Companion; + public fun delete (Lcom/pasich/encly/core/security/PinKeySlot;)V + public fun ensureKey (Lcom/pasich/encly/core/security/PinKeySlot;)Z + public fun mac (Lcom/pasich/encly/core/security/PinKeySlot;[B)[B + public fun reset (Lcom/pasich/encly/core/security/PinKeySlot;)V +} + +public final class com/pasich/encly/core/keystore/DesktopPinFactor$Companion { + public final fun create (Ljava/nio/file/Path;)Lcom/pasich/encly/core/keystore/DesktopPinFactor; +} + +public final class com/pasich/encly/core/keystore/SecretStoreUnavailableException : java/lang/Exception { + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + diff --git a/core/keystore/api/keystore.klib.api b/core/keystore/api/keystore.klib.api new file mode 100644 index 00000000..b5819d45 --- /dev/null +++ b/core/keystore/api/keystore.klib.api @@ -0,0 +1,34 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final class com.pasich.encly.core.keystore/BiometricKeychain { // com.pasich.encly.core.keystore/BiometricKeychain|null[0] + constructor (kotlin/String = ..., kotlin/String = ...) // com.pasich.encly.core.keystore/BiometricKeychain.|(kotlin.String;kotlin.String){}[0] + + final fun delete() // com.pasich.encly.core.keystore/BiometricKeychain.delete|delete(){}[0] + final fun exists(): kotlin/Boolean // com.pasich.encly.core.keystore/BiometricKeychain.exists|exists(){}[0] + final fun read(platform.LocalAuthentication/LAContext): kotlin/ByteArray // com.pasich.encly.core.keystore/BiometricKeychain.read|read(platform.LocalAuthentication.LAContext){}[0] + final fun store(kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.keystore/BiometricKeychain.store|store(kotlin.ByteArray){}[0] +} + +final class com.pasich.encly.core.keystore/BiometricKeychainException : kotlin/Exception { // com.pasich.encly.core.keystore/BiometricKeychainException|null[0] + final val cancelled // com.pasich.encly.core.keystore/BiometricKeychainException.cancelled|{}cancelled[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.keystore/BiometricKeychainException.cancelled.|(){}[0] + final val gone // com.pasich.encly.core.keystore/BiometricKeychainException.gone|{}gone[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.keystore/BiometricKeychainException.gone.|(){}[0] +} + +final class com.pasich.encly.core.keystore/IosPinFactor : com.pasich.encly.core.security/PinHardwareFactor { // com.pasich.encly.core.keystore/IosPinFactor|null[0] + final fun delete(com.pasich.encly.core.security/PinKeySlot) // com.pasich.encly.core.keystore/IosPinFactor.delete|delete(com.pasich.encly.core.security.PinKeySlot){}[0] + final fun ensureKey(com.pasich.encly.core.security/PinKeySlot): kotlin/Boolean // com.pasich.encly.core.keystore/IosPinFactor.ensureKey|ensureKey(com.pasich.encly.core.security.PinKeySlot){}[0] + final fun mac(com.pasich.encly.core.security/PinKeySlot, kotlin/ByteArray): kotlin/ByteArray // com.pasich.encly.core.keystore/IosPinFactor.mac|mac(com.pasich.encly.core.security.PinKeySlot;kotlin.ByteArray){}[0] + final fun reset(com.pasich.encly.core.security/PinKeySlot) // com.pasich.encly.core.keystore/IosPinFactor.reset|reset(com.pasich.encly.core.security.PinKeySlot){}[0] + + final object Companion { // com.pasich.encly.core.keystore/IosPinFactor.Companion|null[0] + final fun create(): com.pasich.encly.core.keystore/IosPinFactor // com.pasich.encly.core.keystore/IosPinFactor.Companion.create|create(){}[0] + } +} diff --git a/core/keystore/build.gradle.kts b/core/keystore/build.gradle.kts new file mode 100644 index 00000000..f4675ddb --- /dev/null +++ b/core/keystore/build.gradle.kts @@ -0,0 +1,88 @@ +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit + +// The desktop implementation of the device-bound half of the PIN key (PinHardwareFactor): an HMAC key that +// lives only in the operating system's secret store of the signed-in user (Windows DPAPI, the macOS login +// Keychain, the freedesktop Secret Service on Linux). See DesktopPinFactor. +// +// Only the desktop JVM target has code. The Android target exists because the convention plugin adds it to +// every module (the app has its own Keystore implementation); it compiles nothing. +plugins { + id("encly.kmp-library") + id("encly.kmp-ios") + id("encly.quality") +} + +// Minimum line and branch coverage (%) of the unit-tested code, enforced by koverVerify. The three OS +// backends are excluded: they only run against a real secret store (the keystore-it CI job). +val keystoreMinLineCoverage = 90 +val keystoreMinBranchCoverage = 80 + +kotlin { + android { + namespace = "com.pasich.encly.core.keystore" + } + + sourceSets { + commonMain.dependencies { + // PinHardwareFactor and PinFactorException are part of the public contract of the PIN factors. + api(project(":core:security")) + implementation(project(":core:common")) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + getByName("desktopMain").dependencies { + implementation(libs.jna) + implementation(libs.jna.platform) + implementation(libs.dbus.java.core) + implementation(libs.dbus.java.transport) + } + getByName("desktopTest").dependencies { + implementation(kotlin("test")) + implementation(libs.junit) + // dbus-java logs through SLF4J; without a binding it prints a warning to stderr. + runtimeOnly(libs.slf4j.nop) + } + } +} + +// The shared test fixture (InMemorySecretStore) is compiled for the Android target too, which has no tests of its own. +tasks.withType().configureEach { + failOnNoDiscoveredTests = false +} + +kover { + reports { + total { + filters { + excludes { + classes( + "com.pasich.encly.core.keystore.WindowsDpapiSecretStore*", + "com.pasich.encly.core.keystore.MacKeychainSecretStore*", + "com.pasich.encly.core.keystore.LinuxSecretServiceStore*", + // The dbus-java interfaces and structs of the Secret Service. + "com.pasich.encly.core.keystore.Secret*Api*", + "com.pasich.encly.core.keystore.SecretStruct", + "com.pasich.encly.core.keystore.*Result", + ) + } + } + xml { onCheck.set(false) } + html { onCheck.set(false) } + verify { + rule("Keystore line coverage") { + bound { + minValue = keystoreMinLineCoverage + coverageUnits = CoverageUnit.LINE + } + } + rule("Keystore branch coverage") { + bound { + minValue = keystoreMinBranchCoverage + coverageUnits = CoverageUnit.BRANCH + } + } + } + } + } +} diff --git a/core/keystore/src/commonMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt b/core/keystore/src/commonMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt new file mode 100644 index 00000000..c92b05fb --- /dev/null +++ b/core/keystore/src/commonMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt @@ -0,0 +1,49 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinKeySlot + +/** + * The operating system's secret store, as far as the desktop PIN factor needs it ([DesktopPinFactor]): one 32-byte + * secret per [PinKeySlot], which only the signed-in OS user can read back. (iOS does not keep a secret at all, see + * IosPinFactor.) + * Implementations never write the secret anywhere unprotected, never log it, and wipe every copy they hold once they + * have handed it over. + */ +internal interface SecretStore { + /** The secret of [slot], or null when there is none. The caller wipes the result. */ + @Throws(SecretStoreException::class) + fun read(slot: PinKeySlot): ByteArray? + + /** + * Generates [SECRET_SIZE] random bytes from the platform CSPRNG, stores them as the secret of [slot] (replacing + * any earlier one) and returns them. The caller wipes the result. + */ + @Throws(SecretStoreException::class) + fun create(slot: PinKeySlot): ByteArray + + /** Deletes the secret of [slot]; deleting one that is not there is not an error. */ + @Throws(SecretStoreException::class) + fun delete(slot: PinKeySlot) + + companion object { + const val SECRET_SIZE = 32 + } +} + +/** What an entry is called in the store, per slot; slot A is the one every vault starts with. */ +internal fun PinKeySlot.entryName(): String = when (this) { + PinKeySlot.A -> ENTRY_NAME_A + PinKeySlot.B -> ENTRY_NAME_B +} + +internal const val ENTRY_NAME_A = "pin-factor-v1" +internal const val ENTRY_NAME_B = "pin-factor-v1-b" +internal const val APPLICATION_ID = "com.pasich.encly" + +/** + * A secret store failed. [lost] means the stored secret exists but can never be read again (for example a + * DPAPI blob that no longer decrypts); a missing entry is not an error (read returns null) and a store that + * is merely unavailable or locked is not lost. + */ +internal class SecretStoreException(val lost: Boolean, message: String, cause: Throwable? = null) : + Exception(message, cause) diff --git a/core/keystore/src/commonTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt b/core/keystore/src/commonTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt new file mode 100644 index 00000000..fadb8fd4 --- /dev/null +++ b/core/keystore/src/commonTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt @@ -0,0 +1,40 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.security.PinKeySlot + +/** A [SecretStore] in memory, with failures to inject and a record of every array it handed out. */ +internal class InMemorySecretStore : SecretStore { + private val secrets = mutableMapOf() + + /** Every array [read] and [create] returned, so that a test can check that the caller wiped it. */ + val handedOut = mutableListOf() + + var readFailure: SecretStoreException? = null + var createFailure: SecretStoreException? = null + var deleteFailure: SecretStoreException? = null + + fun put(slot: PinKeySlot, secret: ByteArray) { + secrets[slot] = secret + } + + /** The stored secret itself, without counting as handed out. */ + fun peek(slot: PinKeySlot): ByteArray? = secrets[slot] + + override fun read(slot: PinKeySlot): ByteArray? { + readFailure?.let { throw it } + return secrets[slot]?.copyOf()?.also { handedOut += it } + } + + override fun create(slot: PinKeySlot): ByteArray { + createFailure?.let { throw it } + val secret = Crypto.randomBytes(SecretStore.SECRET_SIZE) + secrets[slot] = secret + return secret.copyOf().also { handedOut += it } + } + + override fun delete(slot: PinKeySlot) { + deleteFailure?.let { throw it } + secrets.remove(slot) + } +} diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/DesktopPinFactor.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/DesktopPinFactor.kt new file mode 100644 index 00000000..b9d9f7d0 --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/DesktopPinFactor.kt @@ -0,0 +1,116 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.SensitiveDataCleaner +import java.nio.file.Path +import java.security.GeneralSecurityException +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * [PinHardwareFactor] for the desktop. The contract is the one of the Android Keystore factor: + * `hw = HMAC-SHA256(deviceSecret, data)`, where the caller passes `data = salt ‖ pin`. + * + * The `deviceSecret` is 32 random bytes from SecureRandom, created once per OS user and slot (on Linux also per + * data directory) and kept ONLY in the operating system's secret store of that user (Windows DPAPI, the macOS + * login Keychain, the freedesktop Secret Service); it never touches Encly's own files unprotected. Unlike + * Android StrongBox or TEE the secret is not hardware-bound: it is as safe as the OS user account (see + * SECURITY.md). + * + * States, mirroring the Android factor: + * - [ensureKey] creates the secret when there is none and returns true then. A secret that is there but + * unreadable counts as existing (like an invalidated Android key): [mac] reports it as lost, and + * [reset] replaces it. + * - [mac] with no secret in the store, or one of the wrong size, fails with `PinFactorException(lost = true)`; + * any other failure of the store (locked, not running, I/O) is `lost = false` and may be transient. + * - [delete] never throws. + * + * Every copy of the secret that this class controls is wiped after use. The JCA `Mac` and the native + * calls make copies of their own that no code here can reach. + */ +public class DesktopPinFactor internal constructor(private val store: SecretStore) : PinHardwareFactor { + @Suppress("ReturnCount") // Exists, unreadable (also exists), or created. + @Throws(PinFactorException::class) + override fun ensureKey(slot: PinKeySlot): Boolean { + val existing = try { + store.read(slot) + } catch (e: SecretStoreException) { + // Like an invalidated Android key: it is there, so it is not replaced here. [mac] reports it as + // lost and the caller decides (a PIN change resets it; an unlock goes to the recovery phrase). + if (e.lost) return false + throw PinFactorException(lost = false, cause = e) + } + if (existing != null) { + SensitiveDataCleaner.clear(existing) + return false + } + reset(slot) + return true + } + + @Throws(PinFactorException::class) + override fun reset(slot: PinKeySlot) { + try { + store.delete(slot) + SensitiveDataCleaner.clear(store.create(slot)) + } catch (e: SecretStoreException) { + throw PinFactorException(lost = false, cause = e) + } + } + + @Throws(PinFactorException::class) + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { + val secret = try { + store.read(slot) + } catch (e: SecretStoreException) { + throw PinFactorException(lost = e.lost, cause = e) + } ?: throw PinFactorException(lost = true) + try { + if (secret.size != SecretStore.SECRET_SIZE) throw PinFactorException(lost = true) + return hmacSha256(secret, data) + } finally { + SensitiveDataCleaner.clear(secret) + } + } + + override fun delete(slot: PinKeySlot) { + try { + store.delete(slot) + } catch (_: SecretStoreException) { + // Documented: delete never throws. + } + } + + private fun hmacSha256(secret: ByteArray, data: ByteArray): ByteArray = try { + Mac.getInstance(HMAC_ALGORITHM).run { + init(SecretKeySpec(secret, HMAC_ALGORITHM)) + doFinal(data) + } + } catch (e: GeneralSecurityException) { + throw PinFactorException(lost = false, cause = e) + } + + public companion object { + private const val HMAC_ALGORITHM = "HmacSHA256" + + /** + * The factor for the operating system this JVM runs on. Throws [SecretStoreUnavailableException] when + * the system has no supported secret store. On Linux, a missing Secret Service (a headless session) shows + * up later, on the first operation, as a [PinFactorException] with a [SecretStoreUnavailableException] + * cause: it can start later, so it is not a permanent state. [dataDirectory] is where this install keeps + * its vault; on Linux it scopes the secret, so two installs of one user keep their own (see + * [LinuxSecretServiceStore]). + */ + @Throws(SecretStoreUnavailableException::class) + public fun create(dataDirectory: Path): DesktopPinFactor = DesktopPinFactor( + selectSecretStore( + osName = System.getProperty("os.name").orEmpty(), + windows = { WindowsDpapiSecretStore(directory = dataDirectory) }, + macos = { MacKeychainSecretStore() }, + linux = { LinuxSecretServiceStore(vault = dataDirectory.toAbsolutePath().normalize().toString()) }, + ), + ) + } +} diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/DpapiErrors.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/DpapiErrors.kt new file mode 100644 index 00000000..6f237d37 --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/DpapiErrors.kt @@ -0,0 +1,45 @@ +package com.pasich.encly.core.keystore + +/** + * Which failures of `CryptUnprotectData` mean the blob can never be opened again, and which may pass. + * + * JNA's `Win32Exception` reports an HRESULT: a plain Win32 code such as `ERROR_INVALID_DATA` (13) arrives as + * `0x8007000D` (the Win32 facility with the high bit), while the `NTE_*` codes of the crypto API are HRESULTs already. + * Both spellings are told apart from a failure that may be temporary (the profile is not loaded yet, the service is + * busy, memory is short), which must not make the PIN look lost: a lost key sends the user to the recovery phrase. + */ +internal object DpapiErrors { + private const val FACILITY_WIN32_MASK = 0xFFFF0000.toInt() + private const val HRESULT_WIN32 = 0x80070000.toInt() + private const val WIN32_CODE_MASK = 0xFFFF + + /** The blob is damaged: ERROR_INVALID_DATA. */ + const val ERROR_INVALID_DATA = 13 + + /** The blob is damaged: NTE_BAD_DATA. */ + const val NTE_BAD_DATA = 0x80090005.toInt() + + /** The blob or its key is not usable: NTE_BAD_KEY. */ + const val NTE_BAD_KEY = 0x80090003.toInt() + + /** The master key is in a state that cannot decrypt, e.g. after a password reset: NTE_BAD_KEY_STATE. */ + const val NTE_BAD_KEY_STATE = 0x8009000B.toInt() + + /** The master key the blob names is not in this profile: NTE_NO_KEY. */ + const val NTE_NO_KEY = 0x8009000D.toInt() + + /** The blob was made by a version this system cannot read: NTE_BAD_VER. */ + const val NTE_BAD_VER = 0x80090006.toInt() + + private val FINAL = setOf(ERROR_INVALID_DATA, NTE_BAD_DATA, NTE_BAD_KEY, NTE_BAD_KEY_STATE, NTE_NO_KEY, NTE_BAD_VER) + + /** [code] as the Win32 error it wraps (`0x8007xxxx` becomes `xxxx`), or unchanged when it is another HRESULT. */ + fun win32Code(code: Int): Int = + if (code and FACILITY_WIN32_MASK == HRESULT_WIN32) code and WIN32_CODE_MASK else code + + /** True when the failure [code] (a Win32 error or an HRESULT) says the blob can never be opened. */ + fun isFinal(code: Int): Boolean = win32Code(code) in FINAL + + /** [code] for a message: hex for an HRESULT, the number for a plain Win32 error. */ + fun describe(code: Int): String = "0x" + Integer.toHexString(code).uppercase() +} diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/LinuxSecretServiceStore.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/LinuxSecretServiceStore.kt new file mode 100644 index 00000000..63697473 --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/LinuxSecretServiceStore.kt @@ -0,0 +1,194 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.SensitiveDataCleaner +import org.freedesktop.dbus.DBusPath +import org.freedesktop.dbus.connections.impl.DBusConnection +import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder +import org.freedesktop.dbus.types.Variant +import java.security.GeneralSecurityException +import java.security.SecureRandom + +/** + * Linux: the freedesktop Secret Service (`org.freedesktop.secrets`, served by GNOME Keyring, KWallet's + * Secret Service bridge, KeePassXC and others) over the D-Bus session bus. The secret is an item of the + * default collection (the alias `default`, which is the `login` keyring on GNOME) with the attributes + * `application = com.pasich.encly`, `purpose = pin-factor-v1` (`pin-factor-v1-b` for slot B) and `vault` = the + * absolute data directory of the install. The keyring is one per OS user while a user can have several + * installs with their own vaults (a Flatpak keeps its data under `~/.var/app`, a `.deb` under `~/.local/share`); + * without [vault] they would share one secret, and a PIN change or a wipe in one would cut the other off its PIN. + * + * The D-Bus client is dbus-java (pure JVM, over the JDK's own Unix domain sockets), chosen over JNA to + * libsecret because it needs no native library on the system and no GLib structures through JNA. The session + * uses the encrypted `dh-ietf1024-sha256-aes128-cbc-pkcs7` transfer algorithm (see [SecretServiceDh]), so the + * secret does not cross the session bus in the clear, where anything that can read the bus (a debugging tool, + * `dbus-monitor` run as the same user) could see it. There is no fallback to `plain`: a service that cannot do + * the exchange is reported as unavailable rather than silently asked for less protection. + * + * There is no file fallback. Without a session bus or a running Secret Service the operations fail with a + * [SecretStoreException] whose cause is a [SecretStoreUnavailableException]. A locked collection is not + * unlocked through the user's password prompt (an unlock that needs a prompt fails the same way); the login + * keyring is normally unlocked by the session login. + */ +@Suppress("TooGenericExceptionCaught") // dbus-java reports remote errors as various runtime exceptions. +internal class LinuxSecretServiceStore( + private val vault: String, + private val application: String = APPLICATION_ID, + private val random: SecureRandom = SecureRandom(), +) : SecretStore { + override fun read(slot: PinKeySlot): ByteArray? = session { connection, service, opened -> + var items = findItems(connection, service, slot) + if (items.isEmpty()) { + // Nothing found. Before that means "gone for good", make sure the keyring that would hold it is there + // and open: a locked or missing default collection hides its items, and that passes. + requireDefaultCollectionOpen(connection, service) + items = findItems(connection, service, slot) + } + val item = items.firstOrNull() ?: return@session null + val secret = item.GetSecret(opened.path) + try { + opened.cipher.decrypt(secret.parameters, secret.value) + } catch (e: GeneralSecurityException) { + throw unavailable("The Secret Service sent a secret that could not be decrypted", e) + } finally { + SensitiveDataCleaner.clear(secret.value) + } + } + + override fun create(slot: PinKeySlot): ByteArray { + val secret = ByteArray(SecretStore.SECRET_SIZE).also(random::nextBytes) + val sent = secret.copyOf() + try { + session { connection, service, opened -> + val collectionPath = service.ReadAlias(DEFAULT_ALIAS) + if (collectionPath.path == NO_OBJECT_PATH) { + throw unavailable("The Secret Service has no default collection") + } + requireNoPrompt(service.Unlock(listOf(collectionPath)).prompt) + val collection = connection.getRemoteObject( + SECRETS_BUS_NAME, + collectionPath.path, + SecretCollectionApi::class.java, + true, + ) + val properties = mapOf>( + ITEM_LABEL to Variant(LABEL), + ITEM_ATTRIBUTES to Variant(attributesOf(slot), "a{ss}"), + ) + val sealed = opened.cipher.encrypt(sent, random) + val struct = SecretStruct(opened.path, sealed.iv, sealed.encrypted, CONTENT_TYPE) + requireNoPrompt(collection.CreateItem(properties, struct, true).prompt) + } + return secret + } catch (e: Exception) { + SensitiveDataCleaner.clear(secret) + throw e + } finally { + SensitiveDataCleaner.clear(sent) + } + } + + override fun delete(slot: PinKeySlot) { + session { connection, service, _ -> + findItems(connection, service, slot).forEach { requireNoPrompt(it.Delete()) } + } + } + + /** The items with the slot's attributes, their collections unlocked. */ + private fun findItems( + connection: DBusConnection, + service: SecretServiceApi, + slot: PinKeySlot, + ): List { + val found = service.SearchItems(attributesOf(slot)) + if (found.locked.isNotEmpty()) requireNoPrompt(service.Unlock(found.locked).prompt) + return (found.unlocked + found.locked).map { + connection.getRemoteObject(SECRETS_BUS_NAME, it.path, SecretItemApi::class.java, true) + } + } + + /** + * An open Secret Service session: its object [path] and the [cipher] of the secrets that go through it. Closing it + * ends the session on the service (the session ends with the connection anyway) and wipes the cipher's key. + */ + private class OpenSession( + val path: DBusPath, + val cipher: SecretServiceCipher, + private val connection: DBusConnection, + ) : AutoCloseable { + override fun close() { + cipher.close() + try { + connection.getRemoteObject(SECRETS_BUS_NAME, path.path, SecretSessionApi::class.java, true).Close() + } catch (_: Exception) { + // The session ends with the connection anyway. + } + } + } + + /** Opens a bus connection and an encrypted Secret Service session, runs [block], and closes both. */ + private fun session(block: (DBusConnection, SecretServiceApi, OpenSession) -> T): T { + try { + DBusConnectionBuilder.forSessionBus().withShared(false).build().use { connection -> + val service = connection.getRemoteObject( + SECRETS_BUS_NAME, + SECRETS_SERVICE_PATH, + SecretServiceApi::class.java, + true, + ) + val dh = SecretServiceDh.create(random) + val opened = service.OpenSession(DH_ALGORITHM, Variant(dh.publicKey)) + val cipher = dh.cipher(serverPublicKeyOf(opened.output)) + return OpenSession(opened.session, cipher, connection).use { block(connection, service, it) } + } + } catch (e: SecretStoreException) { + throw e + } catch (e: Exception) { + throw unavailable("The Secret Service could not be used: ${e.javaClass.simpleName}", e) + } + } + + /** + * The default collection must exist and be unlocked (without a prompt) for "not found" to be an answer: a locked + * or missing keyring only hides what is in it, which is a temporary failure and not a lost key. + */ + private fun requireDefaultCollectionOpen(connection: DBusConnection, service: SecretServiceApi) { + val collectionPath = service.ReadAlias(DEFAULT_ALIAS) + if (collectionPath.path == NO_OBJECT_PATH) throw unavailable("The Secret Service has no default collection") + val properties = connection.getRemoteObject( + SECRETS_BUS_NAME, + collectionPath.path, + org.freedesktop.dbus.interfaces.Properties::class.java, + true, + ) + val locked = properties.Get(COLLECTION_INTERFACE, "Locked") + if (locked) requireNoPrompt(service.Unlock(listOf(collectionPath)).prompt) + } + + private fun requireNoPrompt(prompt: DBusPath) { + if (prompt.path != NO_OBJECT_PATH) { + throw unavailable("The Secret Service needs the user to unlock the keyring first") + } + } + + private fun unavailable(message: String, cause: Throwable? = null) = SecretStoreException( + lost = false, + message = message, + cause = SecretStoreUnavailableException(message, cause), + ) + + private fun attributesOf(slot: PinKeySlot): Map = + mapOf(ATTRIBUTE_APPLICATION to application, ATTRIBUTE_PURPOSE to slot.entryName(), ATTRIBUTE_VAULT to vault) + + private companion object { + const val DEFAULT_ALIAS = "default" + const val COLLECTION_INTERFACE = "org.freedesktop.Secret.Collection" + const val CONTENT_TYPE = "application/octet-stream" + const val LABEL = "Encly PIN factor" + const val ITEM_LABEL = "org.freedesktop.Secret.Item.Label" + const val ITEM_ATTRIBUTES = "org.freedesktop.Secret.Item.Attributes" + const val ATTRIBUTE_APPLICATION = "application" + const val ATTRIBUTE_PURPOSE = "purpose" + const val ATTRIBUTE_VAULT = "vault" + } +} diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/MacKeychainSecretStore.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/MacKeychainSecretStore.kt new file mode 100644 index 00000000..bc2e0730 --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/MacKeychainSecretStore.kt @@ -0,0 +1,167 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.NativeLibrary +import com.sun.jna.Pointer +import com.sun.jna.ptr.PointerByReference +import java.security.SecureRandom + +/** + * macOS: a generic password in the user's login Keychain (service `com.pasich.encly`, account `pin-factor-v1`, + * `pin-factor-v1-b` for slot B), written and read through Security.framework with CoreFoundation dictionaries. + * The calls are made through JNA; the `security` command line tool is not used, so the secret never passes + * through a process listing or a pipe. + * + * The item is created by the running Java launcher, which the Keychain then trusts to read it without a + * prompt; another launcher (a different JDK, an unsigned build) gets the system's access prompt. A locked + * Keychain, or a refused prompt, is a failure that is not "lost": it may pass. + * + * Secure Enclave keys are not used: they need a signed app with entitlements, which Encly does not have on the + * desktop yet. + */ +internal class MacKeychainSecretStore( + private val service: String = APPLICATION_ID, + private val random: SecureRandom = SecureRandom(), +) : SecretStore { + private val cf: CoreFoundation by lazy { Native.load(CF_PATH, CoreFoundation::class.java) } + private val sec: SecurityFramework by lazy { Native.load(SECURITY_PATH, SecurityFramework::class.java) } + private val cfLib: NativeLibrary by lazy { NativeLibrary.getInstance(CF_PATH) } + private val secLib: NativeLibrary by lazy { NativeLibrary.getInstance(SECURITY_PATH) } + + override fun read(slot: PinKeySlot): ByteArray? = withRefs { refs -> + val query = baseQuery(refs, slot) + cf.CFDictionarySetValue(query, secGlobal("kSecReturnData"), cfGlobal("kCFBooleanTrue")) + cf.CFDictionarySetValue(query, secGlobal("kSecMatchLimit"), secGlobal("kSecMatchLimitOne")) + val result = PointerByReference() + val status = sec.SecItemCopyMatching(query, result) + when (status) { + ERR_SEC_SUCCESS -> { + val data = result.value ?: throw failure("The Keychain returned no data", status) + refs.add(data) + val length = cf.CFDataGetLength(data).toInt() + if (length == 0) null else cf.CFDataGetBytePtr(data).getByteArray(0, length) + } + + ERR_SEC_ITEM_NOT_FOUND -> null + + else -> throw failure("The Keychain item could not be read", status) + } + } + + override fun create(slot: PinKeySlot): ByteArray { + val secret = ByteArray(SecretStore.SECRET_SIZE).also(random::nextBytes) + try { + withRefs { refs -> + val attributes = baseQuery(refs, slot) + val data = cf.CFDataCreate(null, secret, secret.size.toLong()) + ?: throw failure("CoreFoundation could not wrap the secret", 0) + refs.add(data) + cf.CFDictionarySetValue(attributes, secGlobal("kSecValueData"), data) + cf.CFDictionarySetValue(attributes, secGlobal("kSecAttrLabel"), string(refs, LABEL)) + var status = sec.SecItemAdd(attributes, null) + if (status == ERR_SEC_DUPLICATE_ITEM) { + delete(slot) + status = sec.SecItemAdd(attributes, null) + } + if (status != ERR_SEC_SUCCESS) throw failure("The Keychain item could not be stored", status) + } + return secret + } catch (e: SecretStoreException) { + SensitiveDataCleaner.clear(secret) + throw e + } + } + + override fun delete(slot: PinKeySlot) = withRefs { refs -> + val status = sec.SecItemDelete(baseQuery(refs, slot)) + if (status != ERR_SEC_SUCCESS && status != ERR_SEC_ITEM_NOT_FOUND) { + throw failure("The Keychain item could not be deleted", status) + } + } + + /** class = generic password, service, account: what identifies the item. */ + private fun baseQuery(refs: MutableList, slot: PinKeySlot): Pointer { + val dictionary = cf.CFDictionaryCreateMutable( + null, + 0, + cfLib.getGlobalVariableAddress("kCFTypeDictionaryKeyCallBacks"), + cfLib.getGlobalVariableAddress("kCFTypeDictionaryValueCallBacks"), + ) ?: throw failure("CoreFoundation could not create a dictionary", 0) + refs.add(dictionary) + cf.CFDictionarySetValue(dictionary, secGlobal("kSecClass"), secGlobal("kSecClassGenericPassword")) + cf.CFDictionarySetValue(dictionary, secGlobal("kSecAttrService"), string(refs, service)) + cf.CFDictionarySetValue(dictionary, secGlobal("kSecAttrAccount"), string(refs, slot.entryName())) + return dictionary + } + + private fun string(refs: MutableList, value: String): Pointer { + val string = cf.CFStringCreateWithCString(null, value, K_CF_STRING_ENCODING_UTF8) + ?: throw failure("CoreFoundation could not create a string", 0) + refs.add(string) + return string + } + + /** The value of a `CFTypeRef` constant exported by CoreFoundation. */ + private fun cfGlobal(name: String): Pointer = cfLib.getGlobalVariableAddress(name).getPointer(0) + + /** The value of a `CFTypeRef` constant exported by Security.framework. */ + private fun secGlobal(name: String): Pointer = secLib.getGlobalVariableAddress(name).getPointer(0) + + /** Runs [block] and releases every CoreFoundation object it registered, whatever happens. */ + private fun withRefs(block: (MutableList) -> T): T { + val refs = mutableListOf() + try { + return block(refs) + } finally { + refs.forEach(cf::CFRelease) + } + } + + private fun failure(message: String, status: Int) = + SecretStoreException(lost = false, message = if (status == 0) message else "$message (OSStatus $status)") + + @Suppress("FunctionNaming", "ktlint:standard:function-naming") // Names are the C symbols. + private interface CoreFoundation : Library { + fun CFRelease(cf: Pointer) + + fun CFStringCreateWithCString(alloc: Pointer?, cStr: String, encoding: Int): Pointer? + + fun CFDataCreate(alloc: Pointer?, bytes: ByteArray, length: Long): Pointer? + + fun CFDataGetLength(data: Pointer): Long + + fun CFDataGetBytePtr(data: Pointer): Pointer + + fun CFDictionaryCreateMutable( + alloc: Pointer?, + capacity: Long, + keyCallBacks: Pointer, + valueCallBacks: Pointer, + ): Pointer? + + fun CFDictionarySetValue(dictionary: Pointer, key: Pointer, value: Pointer) + } + + @Suppress("FunctionNaming", "ktlint:standard:function-naming") + private interface SecurityFramework : Library { + fun SecItemAdd(attributes: Pointer, result: PointerByReference?): Int + + fun SecItemCopyMatching(query: Pointer, result: PointerByReference): Int + + fun SecItemDelete(query: Pointer): Int + } + + private companion object { + const val CF_PATH = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + const val SECURITY_PATH = "/System/Library/Frameworks/Security.framework/Security" + const val LABEL = "Encly PIN factor" + const val K_CF_STRING_ENCODING_UTF8 = 0x08000100 + + const val ERR_SEC_SUCCESS = 0 + const val ERR_SEC_ITEM_NOT_FOUND = -25300 + const val ERR_SEC_DUPLICATE_ITEM = -25299 + } +} diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretServiceApi.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretServiceApi.kt new file mode 100644 index 00000000..ce8a4e88 --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretServiceApi.kt @@ -0,0 +1,88 @@ +@file:Suppress("MagicNumber") // The @Position indexes of the D-Bus structs. + +package com.pasich.encly.core.keystore + +import org.freedesktop.dbus.DBusPath +import org.freedesktop.dbus.Struct +import org.freedesktop.dbus.Tuple +import org.freedesktop.dbus.annotations.DBusInterfaceName +import org.freedesktop.dbus.annotations.Position +import org.freedesktop.dbus.interfaces.DBusInterface +import org.freedesktop.dbus.types.Variant + +// The part of the freedesktop Secret Service API (https://specifications.freedesktop.org/secret-service-spec/) +// that LinuxSecretServiceStore uses, as dbus-java interfaces. The names of the methods and fields are the +// D-Bus names and are read by reflection; the structs and tuples are marshalled in @Position order. + +internal const val SECRETS_BUS_NAME = "org.freedesktop.secrets" +internal const val SECRETS_SERVICE_PATH = "/org/freedesktop/secrets" + +/** "No object", as the spec writes it: a prompt path of "/" means that no prompt is needed. */ +internal const val NO_OBJECT_PATH = "/" + +@DBusInterfaceName("org.freedesktop.Secret.Service") +@Suppress("FunctionNaming", "ktlint:standard:function-naming") +internal interface SecretServiceApi : DBusInterface { + fun OpenSession(algorithm: String, input: Variant<*>): OpenSessionResult + + @JvmSuppressWildcards + fun SearchItems(attributes: Map): SearchItemsResult + + @JvmSuppressWildcards + fun Unlock(objects: List): UnlockResult + + fun ReadAlias(name: String): DBusPath +} + +@DBusInterfaceName("org.freedesktop.Secret.Collection") +@Suppress("FunctionNaming", "ktlint:standard:function-naming") +internal interface SecretCollectionApi : DBusInterface { + @JvmSuppressWildcards + fun CreateItem(properties: Map>, secret: SecretStruct, replace: Boolean): CreateItemResult +} + +@DBusInterfaceName("org.freedesktop.Secret.Item") +@Suppress("FunctionNaming", "ktlint:standard:function-naming") +internal interface SecretItemApi : DBusInterface { + fun GetSecret(session: DBusPath): SecretStruct + + fun Delete(): DBusPath +} + +@DBusInterfaceName("org.freedesktop.Secret.Session") +@Suppress("FunctionNaming", "ktlint:standard:function-naming") +internal interface SecretSessionApi : DBusInterface { + fun Close() +} + +/** `(oayays)`: the session the secret belongs to, the algorithm's parameters, the value, its content type. */ +internal class SecretStruct( + @field:Position(0) @JvmField val session: DBusPath, + @field:Position(1) @JvmField val parameters: ByteArray, + @field:Position(2) @JvmField val value: ByteArray, + @field:Position(3) @JvmField val contentType: String, +) : Struct() + +/** `OpenSession` returns `(v output, o result)`. */ +internal class OpenSessionResult( + @field:Position(0) @JvmField val output: Variant<*>, + @field:Position(1) @JvmField val session: DBusPath, +) : Tuple() + +/** `SearchItems` returns `(ao unlocked, ao locked)`. */ +internal class SearchItemsResult( + @field:Position(0) @JvmField val unlocked: List<@JvmSuppressWildcards DBusPath>, + @field:Position(1) @JvmField val locked: List<@JvmSuppressWildcards DBusPath>, +) : Tuple() + +/** `Unlock` returns `(ao unlocked, o prompt)`. */ +internal class UnlockResult( + @field:Position(0) @JvmField val unlocked: List<@JvmSuppressWildcards DBusPath>, + @field:Position(1) @JvmField val prompt: DBusPath, +) : Tuple() + +/** `CreateItem` returns `(o item, o prompt)`. */ +internal class CreateItemResult( + @field:Position(0) @JvmField val item: DBusPath, + @field:Position(1) @JvmField val prompt: DBusPath, +) : Tuple() diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretServiceSession.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretServiceSession.kt new file mode 100644 index 00000000..326a72d8 --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretServiceSession.kt @@ -0,0 +1,150 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.SensitiveDataCleaner +import org.freedesktop.dbus.types.Variant +import java.math.BigInteger +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.Mac +import javax.crypto.spec.DHParameterSpec +import javax.crypto.spec.DHPublicKeySpec +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +// The encrypted transfer algorithm of the freedesktop Secret Service, `dh-ietf1024-sha256-aes128-cbc-pkcs7` +// (https://specifications.freedesktop.org/secret-service-spec/latest/transfer-secrets.html): a Diffie-Hellman +// exchange in the 1024-bit Oakley group 2 (RFC 2409, generator 2), the shared secret as 128 big-endian bytes, a +// 16-byte AES key from HKDF-SHA256 over it (no salt, no info), and AES-128-CBC with PKCS#7 padding for the secret. +// Only the JDK's DH, HMAC and AES-CBC are used; the code here is the composition the specification describes. + +/** The name of the algorithm, as `OpenSession` takes it. */ +internal const val DH_ALGORITHM = "dh-ietf1024-sha256-aes128-cbc-pkcs7" + +private const val PUBLIC_KEY_SIZE = 128 +private const val AES_KEY_SIZE = 16 +private const val IV_SIZE = 16 +private const val SHA256_SIZE = 32 + +// RFC 2409 section 6.2, "Second Oakley Group": 2^1024 - 2^960 - 1 + 2^64 * { [2^894 pi] + 129093 }. +private const val HEX = 16 + +private val OAKLEY_GROUP_2_PRIME = BigInteger( + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DD" + + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF", + HEX, +) +private val OAKLEY_GROUP_2_GENERATOR = BigInteger.TWO + +/** Our half of the key exchange of one Secret Service session. */ +internal class SecretServiceDh private constructor(private val keyPair: KeyPair) { + /** Our public key: 128 bytes, unsigned big-endian (what `OpenSession` sends). */ + val publicKey: ByteArray = unsigned( + (keyPair.public as javax.crypto.interfaces.DHPublicKey).y, + PUBLIC_KEY_SIZE, + ) + + /** + * The session cipher, once the service's public key [serverPublic] (128 bytes, unsigned big-endian) is known. + * Throws [GeneralSecurityException] for a key that is not a valid element of the group. + */ + fun cipher(serverPublic: ByteArray): SecretServiceCipher { + val y = BigInteger(1, serverPublic) + // 1 < y < p - 1: anything else gives a shared secret an observer could guess. + if (serverPublic.isEmpty() || y <= BigInteger.ONE || y >= OAKLEY_GROUP_2_PRIME - BigInteger.ONE) { + throw GeneralSecurityException("The Secret Service sent an invalid public key") + } + val peer = KeyFactory.getInstance("DH") + .generatePublic(DHPublicKeySpec(y, OAKLEY_GROUP_2_PRIME, OAKLEY_GROUP_2_GENERATOR)) + val agreement = KeyAgreement.getInstance("DH").apply { + init(keyPair.private) + doPhase(peer, true) + } + val raw = agreement.generateSecret() + val shared = unsigned(BigInteger(1, raw), PUBLIC_KEY_SIZE) + SensitiveDataCleaner.clear(raw) + val key = hkdfSha256NoSalt(shared, AES_KEY_SIZE) + SensitiveDataCleaner.clear(shared) + return SecretServiceCipher(key) + } + + companion object { + fun create(random: SecureRandom = SecureRandom()): SecretServiceDh { + val generator = KeyPairGenerator.getInstance("DH") + generator.initialize(DHParameterSpec(OAKLEY_GROUP_2_PRIME, OAKLEY_GROUP_2_GENERATOR), random) + return SecretServiceDh(generator.generateKeyPair()) + } + } +} + +/** AES-128-CBC with PKCS#7 padding under the session key; [key] is wiped by [close]. */ +internal class SecretServiceCipher(private val key: ByteArray) : AutoCloseable { + /** What goes on the wire: the random [iv] (the struct's parameters) and the [encrypted] secret (its value). */ + class Sealed(val iv: ByteArray, val encrypted: ByteArray) + + fun encrypt(secret: ByteArray, random: SecureRandom = SecureRandom()): Sealed { + val iv = ByteArray(IV_SIZE).also(random::nextBytes) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return Sealed(iv, cipher.doFinal(secret)) + } + + /** The secret, or a [GeneralSecurityException] for a wrong IV length or damaged padding. */ + fun decrypt(iv: ByteArray, encrypted: ByteArray): ByteArray { + if (iv.size != IV_SIZE) throw GeneralSecurityException("The Secret Service sent an invalid IV") + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(encrypted) + } + + override fun close() = SensitiveDataCleaner.clear(key) +} + +/** + * The service's public key from the output of `OpenSession` (`ay`, however the D-Bus library typed it); throws + * [GeneralSecurityException] when the service did not give one. + */ +internal fun serverPublicKeyOf(output: Variant<*>): ByteArray { + val value = output.value + val bytes = when (value) { + is ByteArray -> value + + is Array<*> -> value.filterIsInstance().toByteArray().takeIf { it.size == value.size } + + // dbus-java hands an `ay` inside a variant over as a list of boxed bytes. + is List<*> -> value.filterIsInstance().toByteArray().takeIf { it.size == value.size } + + else -> null + } + return bytes ?: throw GeneralSecurityException( + "The Secret Service did not answer the key exchange (${output.sig}: ${value?.javaClass?.name})", + ) +} + +/** + * HKDF (RFC 5869) with SHA-256 for one block of output, without salt (the RFC then uses [SHA256_SIZE] zero bytes) + * and without info, as the Secret Service specification asks. + */ +internal fun hkdfSha256NoSalt(secret: ByteArray, length: Int): ByteArray { + require(length in 1..SHA256_SIZE) + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(ByteArray(SHA256_SIZE), "HmacSHA256")) + val pseudoRandomKey = mac.doFinal(secret) + mac.init(SecretKeySpec(pseudoRandomKey, "HmacSHA256")) + val block = mac.doFinal(byteArrayOf(1)) + SensitiveDataCleaner.clear(pseudoRandomKey) + return block.copyOf(length).also { SensitiveDataCleaner.clear(block) } +} + +/** [value] as [size] unsigned big-endian bytes: no sign byte, zero-padded on the left. */ +internal fun unsigned(value: BigInteger, size: Int): ByteArray { + val bytes = value.toByteArray() + val significant = if (bytes.size > 1 && bytes[0] == 0.toByte()) bytes.copyOfRange(1, bytes.size) else bytes + require(significant.size <= size) { "The number does not fit in $size bytes" } + return ByteArray(size).also { significant.copyInto(it, size - significant.size) } +} diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStoreSelection.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStoreSelection.kt new file mode 100644 index 00000000..1d62c14f --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStoreSelection.kt @@ -0,0 +1,20 @@ +package com.pasich.encly.core.keystore + +/** + * Picks the backend for the operating system named by `os.name`. The factories are lazy so that only the + * chosen backend's native libraries are loaded. + */ +internal fun selectSecretStore( + osName: String, + windows: () -> SecretStore, + macos: () -> SecretStore, + linux: () -> SecretStore, +): SecretStore { + val os = osName.lowercase() + return when { + os.startsWith("windows") -> windows() + os.startsWith("mac") || os.startsWith("darwin") -> macos() + os.contains("linux") -> linux() + else -> throw SecretStoreUnavailableException("No supported secret store on this operating system") + } +} diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStoreUnavailableException.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStoreUnavailableException.kt new file mode 100644 index 00000000..ab38083a --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStoreUnavailableException.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.core.keystore + +/** + * There is no usable secret store: the operating system has none Encly supports, or (on Linux) no Secret + * Service is running. Encly never falls back to keeping the secret in a file. + */ +public class SecretStoreUnavailableException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/WindowsDpapiSecretStore.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/WindowsDpapiSecretStore.kt new file mode 100644 index 00000000..385e0a2c --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/WindowsDpapiSecretStore.kt @@ -0,0 +1,135 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.sun.jna.platform.win32.Crypt32Util +import com.sun.jna.platform.win32.Win32Exception +import com.sun.jna.platform.win32.WinCrypt +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.security.SecureRandom + +/** + * Windows: the secret is encrypted with DPAPI (`CryptProtectData`, user scope, no UI) and the blob is kept in + * `%LOCALAPPDATA%\Encly\` (the app's data folder; never the roaming `%APPDATA%`, which is copied to profile + * servers and other machines). Only the same Windows user can unprotect it, so the file is useless to anyone who + * copies it away. + * + * The blob is bound to an Encly-specific entropy as well, so another program of the same user cannot unprotect + * it by calling `CryptUnprotectData` without knowing it. That is defence against a careless program, not against + * malware running as the user, which DPAPI cannot stop. + * + * A blob that DPAPI rejects as damaged or as sealed under a key state this user does not have (see + * [DpapiErrors]) is reported as lost; any other DPAPI failure and every I/O failure is not, and carries its code. + */ +@Suppress("TooGenericExceptionCaught") // JNA reports a failed native call as a RuntimeException (Win32Exception). +internal class WindowsDpapiSecretStore( + private val directory: Path = defaultDirectory(), + private val random: SecureRandom = SecureRandom(), +) : SecretStore { + override fun read(slot: PinKeySlot): ByteArray? { + val file = fileOf(slot) + val blob = try { + if (!Files.exists(file)) return null + Files.readAllBytes(file) + } catch (e: IOException) { + throw SecretStoreException(lost = false, message = "The DPAPI blob could not be read", cause = e) + } + try { + return Crypt32Util.cryptUnprotectData(blob, ENTROPY, WinCrypt.CRYPTPROTECT_UI_FORBIDDEN, null) + } catch (e: Win32Exception) { + // Only the codes that say the data or its key state is bad are final; anything else may pass. + throw SecretStoreException( + lost = isFinal(e), + message = "The DPAPI blob does not unprotect (error ${DpapiErrors.describe(e.errorCode)})", + cause = e, + ) + } + } + + override fun create(slot: PinKeySlot): ByteArray { + val secret = ByteArray(SecretStore.SECRET_SIZE).also(random::nextBytes) + try { + val blob = try { + Crypt32Util.cryptProtectData( + secret, + ENTROPY, + WinCrypt.CRYPTPROTECT_UI_FORBIDDEN, + DESCRIPTION, + null, + ) + } catch (e: Win32Exception) { + throw SecretStoreException(lost = false, message = "DPAPI could not protect the secret", cause = e) + } + write(fileOf(slot), blob) + return secret + } catch (e: Exception) { + SensitiveDataCleaner.clear(secret) + throw e + } + } + + override fun delete(slot: PinKeySlot) { + try { + Files.deleteIfExists(fileOf(slot)) + } catch (e: IOException) { + throw SecretStoreException(lost = false, message = "The DPAPI blob could not be deleted", cause = e) + } + } + + private fun isFinal(e: Win32Exception): Boolean = + DpapiErrors.isFinal(e.errorCode) || DpapiErrors.isFinal(e.hr.toInt()) + + private fun fileOf(slot: PinKeySlot): Path = directory.resolve(slot.entryName() + FILE_EXTENSION) + + /** Writes next to the target and renames over it, so a crash leaves the old blob or the new one, never half. */ + private fun write(target: Path, blob: ByteArray) { + val temp = target.resolveSibling(target.fileName.toString() + TEMP_EXTENSION) + try { + Files.createDirectories(directory) + Files.newByteChannel( + temp, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE, + ).use { channel -> + channel.write(ByteBuffer.wrap(blob)) + (channel as? FileChannel)?.force(true) + } + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: IOException) { + runCatching { Files.deleteIfExists(temp) } + throw SecretStoreException(lost = false, message = "The DPAPI blob could not be written", cause = e) + } + } + + private companion object { + const val FILE_EXTENSION = ".dpapi" + const val TEMP_EXTENSION = ".tmp" + const val DESCRIPTION = "Encly PIN factor" + + /** Not a secret: it scopes the blob to Encly. */ + val ENTROPY: ByteArray = "$APPLICATION_ID/pin-factor-v1".toByteArray(Charsets.UTF_8) + + fun defaultDirectory(): Path { + val appData = System.getenv("LOCALAPPDATA") + val base = if (!appData.isNullOrBlank()) { + Paths.get(appData) + } else { + Paths.get(System.getProperty("user.home"), "AppData", "Local") + } + return base.resolve("Encly") + } + } +} diff --git a/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/DesktopPinFactorTest.kt b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/DesktopPinFactorTest.kt new file mode 100644 index 00000000..074ed621 --- /dev/null +++ b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/DesktopPinFactorTest.kt @@ -0,0 +1,228 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinKeySlot +import org.junit.Assume.assumeTrue +import java.nio.file.Path +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopPinFactorTest { + private val store = InMemorySecretStore() + private val factor = DesktopPinFactor(store) + + private val salt = ByteArray(16) { (0xA0 + it).toByte() } + private val pin = "482913".toByteArray(Charsets.UTF_8) + private val data = salt + pin + + @Test + fun macIsHMACSHA256OfTheDataUnderTheStoredSecretGolden() { + store.put(PinKeySlot.A, ByteArray(32) { it.toByte() }) + + val hw = factor.mac(PinKeySlot.A, data) + + assertEquals(32, hw.size) + assertEquals("25b11968d03777c4e2519917fd8757ebaed13eeb6a9378e58e9fab63c4962f36", hw.toHex()) + assertContentEquals(referenceHmac(ByteArray(32) { it.toByte() }, data), hw) + } + + @Test + fun aSecondGoldenVectorWithAnotherSecret() { + store.put(PinKeySlot.B, ByteArray(32) { 0xFF.toByte() }) + + assertEquals( + "412acd40567e42ba035be3fd49c094c6cc015c5a9921e633e52bf0235e0f0755", + factor.mac(PinKeySlot.B, data).toHex(), + ) + } + + @Test + fun macIsDeterministicAndDependsOnTheData() { + factor.ensureKey(PinKeySlot.A) + + val first = factor.mac(PinKeySlot.A, data) + assertContentEquals(first, factor.mac(PinKeySlot.A, data)) + assertFalse(first.contentEquals(factor.mac(PinKeySlot.A, salt + "482914".toByteArray()))) + } + + @Test + fun macOfTheRealRandomSecretMatchesAPlainJavaxCryptoHMAC() { + factor.ensureKey(PinKeySlot.A) + + assertContentEquals(referenceHmac(store.peek(PinKeySlot.A)!!, data), factor.mac(PinKeySlot.A, data)) + } + + @Test + fun firstUseCreatesA32ByteSecretAndReportsIt() { + assertTrue(factor.ensureKey(PinKeySlot.A)) + assertEquals(32, store.peek(PinKeySlot.A)!!.size) + assertFalse(factor.ensureKey(PinKeySlot.A)) + assertNull(store.peek(PinKeySlot.B)) + } + + @Test + fun theSlotsHoldSeparateSecrets() { + factor.ensureKey(PinKeySlot.A) + factor.ensureKey(PinKeySlot.B) + + assertFalse(store.peek(PinKeySlot.A)!!.contentEquals(store.peek(PinKeySlot.B)!!)) + assertFalse(factor.mac(PinKeySlot.A, data).contentEquals(factor.mac(PinKeySlot.B, data))) + } + + @Test + fun resetReplacesTheSecret() { + factor.ensureKey(PinKeySlot.A) + val before = factor.mac(PinKeySlot.A, data) + + factor.reset(PinKeySlot.A) + + assertFalse(before.contentEquals(factor.mac(PinKeySlot.A, data))) + } + + @Test + fun macWithoutASecretIsLost() { + val e = assertFailsWith { factor.mac(PinKeySlot.A, data) } + assertTrue(e.lost) + } + + @Test + fun macAfterDeleteIsLostAndEnsureKeyMakesANewSecret() { + factor.ensureKey(PinKeySlot.A) + val before = factor.mac(PinKeySlot.A, data) + + factor.delete(PinKeySlot.A) + + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + assertTrue(factor.ensureKey(PinKeySlot.A)) + assertNotEquals(before.toHex(), factor.mac(PinKeySlot.A, data).toHex()) + } + + @Test + fun aSecretOfTheWrongSizeIsLostForMacAndKeptByEnsureKey() { + store.put(PinKeySlot.A, ByteArray(7)) + + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + assertFalse(factor.ensureKey(PinKeySlot.A)) + assertEquals(7, store.peek(PinKeySlot.A)!!.size) + + factor.reset(PinKeySlot.A) + assertEquals(32, store.peek(PinKeySlot.A)!!.size) + factor.mac(PinKeySlot.A, data) + } + + @Test + fun anUnreadableSecretIsLostForMacKeptByEnsureKeyAndReplacedByReset() { + factor.ensureKey(PinKeySlot.A) + val before = store.peek(PinKeySlot.A)!!.copyOf() + store.readFailure = SecretStoreException(lost = true, message = "does not decrypt") + + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + assertFalse(factor.ensureKey(PinKeySlot.A)) + assertContentEquals(before, store.peek(PinKeySlot.A)) + + factor.reset(PinKeySlot.A) + store.readFailure = null + assertFalse(before.contentEquals(store.peek(PinKeySlot.A))) + factor.mac(PinKeySlot.A, data) + } + + @Test + fun anUnavailableStoreIsNotLost() { + factor.ensureKey(PinKeySlot.A) + val unavailable = SecretStoreException(lost = false, message = "locked") + store.readFailure = unavailable + + assertFalse(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + assertFalse(assertFailsWith { factor.ensureKey(PinKeySlot.A) }.lost) + assertEquals(unavailable, assertFailsWith { factor.ensureKey(PinKeySlot.A) }.cause) + } + + @Test + fun resetFailureIsNotLost() { + store.createFailure = SecretStoreException(lost = false, message = "full") + + assertFalse(assertFailsWith { factor.reset(PinKeySlot.A) }.lost) + assertFalse(assertFailsWith { factor.ensureKey(PinKeySlot.A) }.lost) + } + + @Test + fun deleteNeverThrows() { + store.deleteFailure = SecretStoreException(lost = false, message = "locked") + + factor.delete(PinKeySlot.A) + } + + @Test + fun macWipesItsCopyOfTheSecretAndLeavesTheDataAlone() { + store.put(PinKeySlot.A, ByteArray(32) { it.toByte() }) + val input = data.copyOf() + + factor.mac(PinKeySlot.A, input) + + assertTrue(store.handedOut.all { copy -> copy.all { it == 0.toByte() } }) + assertContentEquals(data, input) + } + + @Test + fun ensureKeyAndResetWipeTheSecretsTheyHandled() { + factor.ensureKey(PinKeySlot.A) + factor.ensureKey(PinKeySlot.A) + factor.reset(PinKeySlot.A) + + assertTrue(store.handedOut.isNotEmpty()) + assertTrue(store.handedOut.all { copy -> copy.all { it == 0.toByte() } }) + } + + @Test + fun macWipesTheSecretWhenTheSizeIsWrong() { + store.put(PinKeySlot.A, ByteArray(7) { 1 }) + + assertFailsWith { factor.mac(PinKeySlot.A, data) } + + assertTrue(store.handedOut.all { copy -> copy.all { it == 0.toByte() } }) + } + + @Test + fun selectionPicksTheBackendByOsName() { + val windows = InMemorySecretStore() + val macos = InMemorySecretStore() + val linux = InMemorySecretStore() + fun pick(os: String) = selectSecretStore(os, { windows }, { macos }, { linux }) + + assertEquals(windows, pick("Windows 11")) + assertEquals(macos, pick("Mac OS X")) + assertEquals(linux, pick("Linux")) + assertFailsWith { pick("FreeBSD") } + assertFailsWith { pick("") } + } + + @Test + fun createBuildsTheFactorOfThisOsWithoutTouchingTheStore() { + // Constructing a backend loads no native library and reads nothing; only the first operation does. + val os = System.getProperty("os.name").lowercase() + assumeTrue(listOf("windows", "mac", "linux").any { os.startsWith(it) }) + + DesktopPinFactor.create(Path.of("build", "encly-test-data")) + } + + @Test + fun entriesAreNamedPerSlot() { + assertEquals("pin-factor-v1", PinKeySlot.A.entryName()) + assertEquals("pin-factor-v1-b", PinKeySlot.B.entryName()) + } + + private fun referenceHmac(key: ByteArray, message: ByteArray): ByteArray = Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(message) + } +} + +internal fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/DpapiErrorsTest.kt b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/DpapiErrorsTest.kt new file mode 100644 index 00000000..2c5dde75 --- /dev/null +++ b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/DpapiErrorsTest.kt @@ -0,0 +1,49 @@ +package com.pasich.encly.core.keystore + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DpapiErrorsTest { + @Test + fun aDamagedBlobIsFinalWhicheverWayJnaSpellsTheCode() { + // ERROR_INVALID_DATA arrives from JNA as HRESULT_FROM_WIN32(13) = 0x8007000D. + assertTrue(DpapiErrors.isFinal(13)) + assertTrue(DpapiErrors.isFinal(0x8007000D.toInt())) + assertTrue(DpapiErrors.isFinal(0x80090005.toInt())) + } + + @Test + fun aBadOrMissingKeyOrAnUnreadableVersionIsFinal() { + assertTrue(DpapiErrors.isFinal(0x80090003.toInt())) + assertTrue(DpapiErrors.isFinal(0x8009000B.toInt())) + assertTrue(DpapiErrors.isFinal(0x8009000D.toInt())) + assertTrue(DpapiErrors.isFinal(0x80090006.toInt())) + } + + @Test + fun aFailureThatMayPassIsNotFinal() { + // ERROR_ACCESS_DENIED, ERROR_NOT_ENOUGH_MEMORY, ERROR_GEN_FAILURE, RPC_S_SERVER_UNAVAILABLE, in both spellings. + for (code in listOf(5, 8, 31, 1722)) { + assertFalse(DpapiErrors.isFinal(code), "win32 $code") + assertFalse(DpapiErrors.isFinal(0x80070000.toInt() or code), "hresult $code") + } + // ERROR_PROFILE_NOT_FOUND-like and unknown HRESULTs. + assertFalse(DpapiErrors.isFinal(0x80090020.toInt())) + assertFalse(DpapiErrors.isFinal(0)) + } + + @Test + fun theWin32CodeIsTakenOutOfItsHresultOnly() { + assertEquals(13, DpapiErrors.win32Code(0x8007000D.toInt())) + assertEquals(13, DpapiErrors.win32Code(13)) + assertEquals(0x80090005.toInt(), DpapiErrors.win32Code(0x80090005.toInt())) + } + + @Test + fun aCodeIsDescribedInHex() { + assertEquals("0x8007000D", DpapiErrors.describe(0x8007000D.toInt())) + assertEquals("0xD", DpapiErrors.describe(13)) + } +} diff --git a/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/RealBackendTest.kt b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/RealBackendTest.kt new file mode 100644 index 00000000..3c096f03 --- /dev/null +++ b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/RealBackendTest.kt @@ -0,0 +1,124 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinKeySlot +import org.junit.Assume.assumeTrue +import java.nio.file.Files +import java.nio.file.Path +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The three OS backends against the real secret store of the machine the tests run on. Each test runs only on + * its own operating system and only when `ENCLY_KEYSTORE_IT=1` is set (the `keystore-it` CI job sets it; on a + * development machine it uses the real login Keychain / keyring, under a test namespace that is not Encly's). + */ +class RealBackendTest { + @Test + fun windowsDPAPIRoundTrip() { + assumeBackend("windows") + val directory = Files.createTempDirectory("encly-dpapi-it") + try { + roundTrip(WindowsDpapiSecretStore(directory = directory)) + damagedBlobIsLost(WindowsDpapiSecretStore(directory = directory), directory) + } finally { + directory.toFile().deleteRecursively() + } + } + + @Test + fun macOSKeychainRoundTrip() { + assumeBackend("mac") + roundTrip(MacKeychainSecretStore(service = TEST_NAMESPACE)) + } + + @Test + fun linuxSecretServiceRoundTrip() { + assumeBackend("linux") + roundTrip(LinuxSecretServiceStore(vault = "/vault/one", application = TEST_NAMESPACE)) + installsKeepTheirOwnSecret( + LinuxSecretServiceStore(vault = "/vault/one", application = TEST_NAMESPACE), + LinuxSecretServiceStore(vault = "/vault/two", application = TEST_NAMESPACE), + ) + } + + /** Two installs of one user (a Flatpak and a .deb): a PIN change in one leaves the other's secret alone. */ + private fun installsKeepTheirOwnSecret(one: SecretStore, two: SecretStore) { + try { + val first = one.create(PinKeySlot.A) + assertTrue(DesktopPinFactor(two).ensureKey(PinKeySlot.A)) + two.delete(PinKeySlot.A) + assertContentEquals(first, one.read(PinKeySlot.A)) + } finally { + runCatching { one.delete(PinKeySlot.A) } + runCatching { two.delete(PinKeySlot.A) } + } + } + + /** A damaged DPAPI blob must be "lost" (so that the lock screen offers the recovery phrase). */ + private fun damagedBlobIsLost(store: SecretStore, directory: Path) { + val factor = DesktopPinFactor(store) + try { + assertTrue(factor.ensureKey(PinKeySlot.A)) + val file = directory.resolve(PinKeySlot.A.entryName() + ".dpapi") + val bytes = Files.readAllBytes(file) + bytes[bytes.size / 2] = (bytes[bytes.size / 2].toInt() xor 0x55).toByte() + Files.write(file, bytes) + + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, ByteArray(4)) }.lost) + assertFalse(factor.ensureKey(PinKeySlot.A)) + factor.reset(PinKeySlot.A) + factor.mac(PinKeySlot.A, ByteArray(4)) + } finally { + runCatching { store.delete(PinKeySlot.A) } + } + } + + private fun assumeBackend(osPrefix: String) { + assumeTrue("ENCLY_KEYSTORE_IT is not set", System.getenv("ENCLY_KEYSTORE_IT") == "1") + assumeTrue(System.getProperty("os.name").lowercase().startsWith(osPrefix)) + } + + /** create, read, HMAC, replace, delete: the whole life of the secret, ending with nothing left behind. */ + private fun roundTrip(store: SecretStore) { + val factor = DesktopPinFactor(store) + val data = ByteArray(16) { it.toByte() } + "123456".toByteArray() + try { + store.delete(PinKeySlot.A) + store.delete(PinKeySlot.B) + + assertTrue(factor.ensureKey(PinKeySlot.A)) + assertFalse(factor.ensureKey(PinKeySlot.A)) + + val secret = store.read(PinKeySlot.A)!! + assertTrue(secret.size == SecretStore.SECRET_SIZE) + val expected = Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(secret, "HmacSHA256")) + doFinal(data) + } + assertContentEquals(expected, factor.mac(PinKeySlot.A, data)) + + factor.reset(PinKeySlot.A) + assertFalse(expected.contentEquals(factor.mac(PinKeySlot.A, data))) + + assertTrue(factor.ensureKey(PinKeySlot.B)) + factor.delete(PinKeySlot.B) + assertTrue(assertFailsWith { factor.mac(PinKeySlot.B, data) }.lost) + + factor.delete(PinKeySlot.A) + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + } finally { + runCatching { store.delete(PinKeySlot.A) } + runCatching { store.delete(PinKeySlot.B) } + } + } + + private companion object { + const val TEST_NAMESPACE = "com.pasich.encly.test" + } +} diff --git a/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/SecretServiceSessionTest.kt b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/SecretServiceSessionTest.kt new file mode 100644 index 00000000..646488e4 --- /dev/null +++ b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/SecretServiceSessionTest.kt @@ -0,0 +1,151 @@ +package com.pasich.encly.core.keystore + +import java.math.BigInteger +import java.security.GeneralSecurityException +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** The encrypted transfer of the Secret Service, played against itself: the client and the "service" are both ours. */ +class SecretServiceSessionTest { + @Test + fun theKeyExchangeGivesBothSidesTheSameCipher() { + repeat(ROUNDS) { + val client = SecretServiceDh.create() + val service = SecretServiceDh.create() + assertEquals(KEY_SIZE, client.publicKey.size) + assertEquals(KEY_SIZE, service.publicKey.size) + val secret = ByteArray(SECRET_SIZE) { (it * 7 + 1).toByte() } + + client.cipher(service.publicKey).use { clientCipher -> + service.cipher(client.publicKey).use { serviceCipher -> + val sealed = clientCipher.encrypt(secret) + assertContentEquals(secret, serviceCipher.decrypt(sealed.iv, sealed.encrypted)) + } + } + } + } + + @Test + fun aPublicKeyIsAlways128UnsignedBytesWhateverItsLeadingBits() { + assertContentEquals(ByteArray(KEY_SIZE), unsigned(BigInteger.ZERO, KEY_SIZE)) + val one = unsigned(BigInteger.ONE, KEY_SIZE) + assertEquals(1, one.last().toInt()) + assertTrue(one.dropLast(1).all { it == 0.toByte() }) + // A value whose top bit is set has a sign byte in BigInteger.toByteArray(); it is not sent. + val high = BigInteger.ONE.shiftLeft(KEY_SIZE * 8 - 1) + val bytes = unsigned(high, KEY_SIZE) + assertEquals(KEY_SIZE, bytes.size) + assertEquals(0x80, bytes[0].toInt() and 0xff) + assertFailsWith { unsigned(BigInteger.ONE.shiftLeft(KEY_SIZE * 8), KEY_SIZE) } + } + + @Test + fun theKeyIsHkdfSha256WithoutSaltAndInfoOverTheSharedSecret() { + val shared = ByteArray(KEY_SIZE) { it.toByte() } + // RFC 5869: PRK = HMAC(zeros(32), IKM); T(1) = HMAC(PRK, 0x01); the key is its first 16 bytes. + val prk = hmac(ByteArray(SHA256_SIZE), shared) + val expected = hmac(prk, byteArrayOf(1)).copyOf(AES_SIZE) + + assertContentEquals(expected, hkdfSha256NoSalt(shared, AES_SIZE)) + assertEquals(SHA256_SIZE, hkdfSha256NoSalt(shared, SHA256_SIZE).size) + assertFailsWith { hkdfSha256NoSalt(shared, SHA256_SIZE + 1) } + } + + @Test + fun theHkdfMatchesRfc5869TestCase3() { + // Test case 3 of RFC 5869 (SHA-256, empty salt and info). Our salt is the RFC's default for none: zeros. + val ikm = ByteArray(22) { 0x0b } + val expected = "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8" + val okm = hkdfSha256NoSalt(ikm, SHA256_SIZE) + + assertEquals(expected.take(SHA256_SIZE * 2), okm.joinToString("") { "%02x".format(it) }) + } + + @Test + fun aSecretOfAnyLengthSurvivesAndTheIvIsFreshEveryTime() { + val cipher = SecretServiceCipher(ByteArray(AES_SIZE) { it.toByte() }) + for (size in listOf(0, 1, 15, 16, 17, SECRET_SIZE, 100)) { + val secret = ByteArray(size) { it.toByte() } + val first = cipher.encrypt(secret) + val second = cipher.encrypt(secret) + assertEquals(AES_SIZE, first.iv.size) + // PKCS#7 always pads: the ciphertext is the next multiple of 16 above the length. + assertEquals((size / AES_SIZE + 1) * AES_SIZE, first.encrypted.size) + assertFalse(first.iv.contentEquals(second.iv)) + assertContentEquals(secret, cipher.decrypt(first.iv, first.encrypted)) + } + } + + @Test + fun aDamagedSecretOrIvIsRefusedNotMisread() { + val cipher = SecretServiceCipher(ByteArray(AES_SIZE) { it.toByte() }) + val sealed = cipher.encrypt(ByteArray(SECRET_SIZE) { 9 }) + + assertFailsWith { cipher.decrypt(ByteArray(AES_SIZE - 1), sealed.encrypted) } + assertFailsWith { cipher.decrypt(sealed.iv, sealed.encrypted.copyOf(AES_SIZE - 1)) } + val wrongKey = SecretServiceCipher(ByteArray(AES_SIZE) { 1 }) + // A wrong key almost always fails the padding check; if it does not, it still never returns the secret. + val outcome = runCatching { wrongKey.decrypt(sealed.iv, sealed.encrypted) } + assertTrue(outcome.isFailure || !outcome.getOrThrow().contentEquals(ByteArray(SECRET_SIZE) { 9 })) + } + + @Test + fun aPublicKeyOutsideTheGroupIsRefused() { + val client = SecretServiceDh.create() + val primeMinusOne = BigInteger(OAKLEY_PRIME_HEX, 16) - BigInteger.ONE + + for (bad in listOf(BigInteger.ZERO, BigInteger.ONE, primeMinusOne, primeMinusOne + BigInteger.ONE)) { + assertFailsWith { client.cipher(unsigned(bad, KEY_SIZE)) } + } + assertFailsWith { client.cipher(ByteArray(0)) } + } + + @Test + fun theServersPublicKeyIsReadFromTheOutputOfOpenSession() { + val key = ByteArray(KEY_SIZE) { it.toByte() } + + assertContentEquals(key, serverPublicKeyOf(org.freedesktop.dbus.types.Variant(key))) + assertContentEquals(key, serverPublicKeyOf(org.freedesktop.dbus.types.Variant(key.toList(), "ay"))) + assertFailsWith { serverPublicKeyOf(org.freedesktop.dbus.types.Variant("none")) } + } + + @Test + fun theGroupIsTheSecondOakleyGroupOfRfc2409() { + val prime = BigInteger(OAKLEY_PRIME_HEX, 16) + assertEquals(1024, prime.bitLength()) + // A safe prime: p and (p - 1) / 2 are both prime. + assertTrue(prime.isProbablePrime(CERTAINTY)) + assertTrue(prime.shiftRight(1).isProbablePrime(CERTAINTY)) + assertNotEquals(BigInteger.ZERO, prime) + // The public keys are elements of the group generated by 2. + val key = BigInteger(1, SecretServiceDh.create().publicKey) + assertTrue(key > BigInteger.ONE && key < prime) + } + + private fun hmac(key: ByteArray, data: ByteArray): ByteArray = Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(data) + } + + private companion object { + const val ROUNDS = 25 + const val KEY_SIZE = 128 + const val AES_SIZE = 16 + const val SHA256_SIZE = 32 + const val SECRET_SIZE = 32 + const val CERTAINTY = 40 + + // The prime of RFC 2409 section 6.2, written out here independently of the code under test. + const val OAKLEY_PRIME_HEX = + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DD" + + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF" + } +} diff --git a/core/keystore/src/iosArm64Main/kotlin/com/pasich/encly/core/keystore/SecureEnclavePinEnclave.kt b/core/keystore/src/iosArm64Main/kotlin/com/pasich/encly/core/keystore/SecureEnclavePinEnclave.kt new file mode 100644 index 00000000..0516314d --- /dev/null +++ b/core/keystore/src/iosArm64Main/kotlin/com/pasich/encly/core/keystore/SecureEnclavePinEnclave.kt @@ -0,0 +1,35 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.core.keystore + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Security.SecAccessControlCreateWithFlags +import platform.Security.kSecAccessControlPrivateKeyUsage +import platform.Security.kSecAttrAccessControl +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly +import platform.Security.kSecAttrTokenID +import platform.Security.kSecAttrTokenIDSecureEnclave + +internal actual fun platformPinEnclave(service: String): PinEnclave = SecureEnclavePinEnclave(service) + +/** + * The key is an EC P-256 private key created inside the Secure Enclave (`kSecAttrTokenIDSecureEnclave`): it cannot + * be exported, not even by this process, and every use is a call into the Enclave. Access control is + * `privateKeyUsage` only, with `WhenUnlockedThisDeviceOnly`: usable while the device is unlocked, never + * synchronized, never in a backup restored to another device, and no biometry (a changed Face ID must not + * invalidate the PIN). Removing the device passcode makes iOS drop the key, which reads as `lost`. + */ +internal class SecureEnclavePinEnclave(service: String) : SecKeyPinEnclave(service) { + override val refusal: String = "Secure Enclave key refused (Encly never falls back to a software key on a device)" + + override fun configure(attributes: CfDictionary, privateAttributes: CfDictionary) { + val accessControl = SecAccessControlCreateWithFlags( + null, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAccessControlPrivateKeyUsage, + null, + ) ?: throw SecretStoreException(lost = false, message = refusal) + privateAttributes.putOwned(kSecAttrAccessControl, accessControl) + attributes.put(kSecAttrTokenID, kSecAttrTokenIDSecureEnclave) + } +} diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/BiometricKeychain.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/BiometricKeychain.kt new file mode 100644 index 00000000..7083b366 --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/BiometricKeychain.kt @@ -0,0 +1,154 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.pasich.encly.core.keystore + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.value +import platform.CoreFoundation.CFDataRef +import platform.CoreFoundation.CFErrorRefVar +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFTypeRefVar +import platform.Foundation.CFBridgingRetain +import platform.LocalAuthentication.LAContext +import platform.Security.SecAccessControlCreateWithFlags +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.errSecInteractionNotAllowed +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.errSecUserCanceled +import platform.Security.kSecAccessControlBiometryCurrentSet +import platform.Security.kSecAttrAccessControl +import platform.Security.kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecAttrSynchronizable +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecReturnAttributes +import platform.Security.kSecReturnData +import platform.Security.kSecUseAuthenticationContext +import platform.Security.kSecValueData + +/** Why [BiometricKeychain.read] gave no secret. */ +public class BiometricKeychainException internal constructor( + /** The user dismissed the prompt (or the system did, because the app left the screen). */ + public val cancelled: Boolean, + /** The item is gone for good: it never existed, or the biometric set changed and the system dropped it. */ + public val gone: Boolean, + status: Int, +) : Exception("biometric Keychain item: $status") + +/** + * The key behind the biometric unlock of the vault on iOS: one generic-password item whose access control is + * `biometryCurrentSet`. The Secure Enclave releases the item's data only after a Face ID or Touch ID match for the + * prompt of the [LAContext] given to [read], never on a plain "the prompt succeeded" answer; adding a finger or a + * face (a change of the biometric set) makes the system drop the item, like Android's key that is invalidated by + * biometric enrollment. The item is available only while a device passcode is set (`WhenPasscodeSetThisDeviceOnly`: + * removing the passcode deletes it), never synchronized, never in a backup that is restored to another device. + * There is no device-passcode fallback: the access control has no `devicePasscode` flag, the same as Android's + * BIOMETRIC_STRONG only. + * + * What it holds is a random 32-byte wrap key, not the vault's data key: the data key is wrapped by it in the + * vault's slot file, so neither the item nor the slot entry opens anything alone. The caller wipes what it reads. + */ +public class BiometricKeychain(private val service: String = APPLICATION_ID, private val account: String = ACCOUNT) { + /** + * Whether the item is there. Asks for its attributes only, which the system answers without a prompt; an item + * that would need one is answered with `errSecInteractionNotAllowed`, which also means it exists. + */ + public fun exists(): Boolean = withCfDictionary { query -> + val context = LAContext().apply { interactionNotAllowed = true } + itemQuery(query) + .putBoolean(kSecReturnAttributes, true) + .put(kSecMatchLimit, kSecMatchLimitOne) + .putOwned(kSecUseAuthenticationContext, CFBridgingRetain(context)) + memScoped { + val result = alloc() + when (SecItemCopyMatching(query.ref, result.ptr)) { + errSecSuccess -> { + result.value?.let { CFRelease(it) } + true + } + + errSecInteractionNotAllowed -> true + + else -> false + } + } + } + + /** Stores [secret] under the biometric access control, replacing the item; false if the system refused. */ + public fun store(secret: ByteArray): Boolean { + delete() + return withCfDictionary { item -> + val control = memScoped { + val error = alloc() + SecAccessControlCreateWithFlags( + null, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + kSecAccessControlBiometryCurrentSet, + error.ptr, + ).also { error.value?.let { CFRelease(it) } } + } ?: return@withCfDictionary false + itemQuery(item).putOwned(kSecAttrAccessControl, control).putBytes(kSecValueData, secret) + SecItemAdd(item.ref, null) == errSecSuccess + } + } + + /** + * The secret, after the biometric prompt of [context] was passed. Blocks until the user answers, so it must not + * run on the main thread. Throws a [BiometricKeychainException] when there is no secret. + */ + @kotlin.Throws(BiometricKeychainException::class) + public fun read(context: LAContext): ByteArray = withCfDictionary { query -> + itemQuery(query) + .putBoolean(kSecReturnData, true) + .put(kSecMatchLimit, kSecMatchLimitOne) + .putOwned(kSecUseAuthenticationContext, CFBridgingRetain(context)) + memScoped { + val result = alloc() + when (val status = SecItemCopyMatching(query.ref, result.ptr)) { + errSecSuccess -> { + val data: CFDataRef = checkNotNull(result.value).reinterpret() + try { + cfDataToBytes(data) + } finally { + CFRelease(data) + } + } + + errSecUserCanceled -> + throw BiometricKeychainException(cancelled = true, gone = false, status = status) + + errSecItemNotFound -> + throw BiometricKeychainException(cancelled = false, gone = true, status = status) + + else -> throw BiometricKeychainException(cancelled = false, gone = false, status = status) + } + } + } + + /** Removes the item; never throws. */ + public fun delete() { + withCfDictionary { query -> SecItemDelete(itemQuery(query).ref) } + } + + private fun itemQuery(query: CfDictionary): CfDictionary = query + .put(kSecClass, kSecClassGenericPassword) + .putString(kSecAttrService, service) + .putString(kSecAttrAccount, account) + .putBoolean(kSecAttrSynchronizable, false) + + private companion object { + const val ACCOUNT = "biometric-wrap-v2" + } +} diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/CfDictionary.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/CfDictionary.kt new file mode 100644 index 00000000..8df97305 --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/CfDictionary.kt @@ -0,0 +1,118 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.pasich.encly.core.keystore + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.IntVar +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import platform.CoreFoundation.CFDataAppendBytes +import platform.CoreFoundation.CFDataCreateMutable +import platform.CoreFoundation.CFDataGetBytePtr +import platform.CoreFoundation.CFDataGetLength +import platform.CoreFoundation.CFDataGetMutableBytePtr +import platform.CoreFoundation.CFDataRef +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFDictionaryRef +import platform.CoreFoundation.CFNumberCreate +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFStringRef +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.kCFBooleanFalse +import platform.CoreFoundation.kCFBooleanTrue +import platform.CoreFoundation.kCFNumberSInt32Type +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks +import platform.Foundation.CFBridgingRetain +import platform.posix.memcpy +import platform.posix.memset + +/** + * A mutable CoreFoundation dictionary for the Security framework's queries and attribute lists, with the CF objects + * made for it, released together by [release]. Kotlin maps cannot stand in for it: their keys would be the + * framework's own `CFStringRef` constants, which are not Objective-C objects. + * + * A secret put in with [putBytes] lives in a mutable CFData that [release] zeroizes before it lets go of it. + */ +internal class CfDictionary { + private val owned = mutableListOf() + private val secrets = mutableListOf() + + val ref: CFDictionaryRef = checkNotNull( + CFDictionaryCreateMutable(null, 0, kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr), + ) + + /** A constant, or an object someone else owns. */ + fun put(key: CFStringRef?, value: CFTypeRef?): CfDictionary = apply { CFDictionaryAddValue(ref, key, value) } + + fun putBoolean(key: CFStringRef?, value: Boolean): CfDictionary = + put(key, if (value) kCFBooleanTrue else kCFBooleanFalse) + + fun putString(key: CFStringRef?, value: String): CfDictionary = putOwned(key, CFBridgingRetain(value)) + + fun putInt(key: CFStringRef?, value: Int): CfDictionary = memScoped { + val holder = alloc() + holder.value = value + putOwned(key, CFNumberCreate(null, kCFNumberSInt32Type, holder.ptr)) + } + + fun putBytes(key: CFStringRef?, value: ByteArray): CfDictionary { + val data = newSecretData(value) + secrets += data + return put(key, data) + } + + /** An object the dictionary now shares with the caller; [release] drops the reference made here. */ + fun putOwned(key: CFStringRef?, value: CFTypeRef?): CfDictionary = apply { + if (value != null) owned += value + CFDictionaryAddValue(ref, key, value) + } + + fun release() { + secrets.forEach(::zeroizeAndRelease) + owned.forEach { CFRelease(it) } + CFRelease(ref) + } +} + +/** Runs [block] with a dictionary that is released afterwards. */ +internal inline fun withCfDictionary(block: (CfDictionary) -> R): R { + val dictionary = CfDictionary() + try { + return block(dictionary) + } finally { + dictionary.release() + } +} + +/** A mutable CFData holding a copy of [value], to be dropped with [zeroizeAndRelease]. */ +internal fun newSecretData(value: ByteArray): CFDataRef { + val data = checkNotNull(CFDataCreateMutable(null, 0)) + if (value.isNotEmpty()) { + value.usePinned { CFDataAppendBytes(data, it.addressOf(0).reinterpret(), value.size.convert()) } + } + return data +} + +/** The bytes of [data] in a fresh array; [data] stays the caller's. */ +internal fun cfDataToBytes(data: CFDataRef): ByteArray { + val length = CFDataGetLength(data).toInt() + val bytes = ByteArray(length) + if (length > 0) bytes.usePinned { memcpy(it.addressOf(0), CFDataGetBytePtr(data), length.convert()) } + return bytes +} + +/** Overwrites a CFData made by [newSecretData] with zeros and releases it. */ +internal fun zeroizeAndRelease(data: CFDataRef) { + CFDataGetMutableBytePtr(data)?.let { memset(it, 0, CFDataGetLength(data).convert()) } + CFRelease(data) +} diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/IosPinFactor.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/IosPinFactor.kt new file mode 100644 index 00000000..c3f4e645 --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/IosPinFactor.kt @@ -0,0 +1,116 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.SensitiveDataCleaner + +/** + * [PinHardwareFactor] for iOS, with the property Android has: every PIN guess needs this device's secure hardware. + * + * Android computes `HMAC(keystoreKey, salt ‖ pin)` inside the Keystore. The Secure Enclave has no HMAC; it holds + * only EC keys and answers ECDH. So the factor is an oblivious-PRF-shaped construction over the Enclave's key `d` + * (a non-extractable P-256 key, one per [PinKeySlot], made by [PinEnclave]): + * ``` + * Q = hash_to_curve(domain ‖ slot ‖ data) data = salt ‖ PIN, as the caller passes it (PinPoint) + * Z = x(d · Q) one ECDH inside the Secure Enclave, per guess + * hw = HKDF-SHA-256(ikm = Z, salt = domain, info = domain ‖ data, 32 bytes) + * ``` + * - Deterministic: the same slot key and `data` give the same `hw`, as the vault needs to open its PIN slot again. + * - Needs the Enclave per guess: `Q` has no known discrete logarithm, so `Z = d·Q` cannot be computed from the + * public key, the Keychain or the vault file. A copy of everything on the phone except the Enclave tests no PINs; + * each guess made with the phone costs an Enclave call. Only someone who can run code in this process while it is + * unlocked can query the Enclave, and that guess rate is the Enclave's, not a GPU's. + * - What stays in memory after an unlock: the point `Q` of the last real guess (it gives that PIN back by hashing every + * possible PIN to a point: 10^6 tries for six digits, no Enclave involved) and its agreement `Z` can survive in + * memory that cannot be wiped (Swift `Data`, the `SecKey` made for the peer point, the CFData of the + * agreement). A dump of this process taken after an unlock, together with + * the vault file, therefore holds the PIN and the hardware half of that one unlock without the Enclave. It gives + * nothing for any other guess. + * - Not the same bytes as the desktop or Android factor for the same data (the vault is bound to one device anyway). + * + * Two constructions that look simpler were rejected because they let the PIN be searched without the Enclave: + * - `Q = k·G` with `k` derived from the PIN: ECDH commutes, `d·(k·G) = k·(d·G)`, and `d·G` is the public key. + * - A fixed peer key kept in the Keychain, with the PIN mixed in only after the agreement: the agreement is then a + * per-slot constant, and one capture of it (it passes through this process's memory) turns into an offline search. + * + * States, as on Android and the desktop: + * - [ensureKey] makes the key when there is none and returns true then. + * - [reset] replaces the key; slots sealed with the old one can no longer be opened. + * - [mac] with no key fails with `PinFactorException(lost = true)`; any other failure (a locked device, the Enclave + * refusing) is `lost = false` and may pass. + * - [delete] never throws. + * + * On a device the key is in the Secure Enclave and a device whose Enclave refuses to make one cannot set a PIN up + * (`ensureKey`/`reset` fail with `lost = false`): there is no weaker fallback. Only the Simulator, which has no + * Enclave, uses a software key (see [platformPinEnclave]). Every array this class holds (the point, the agreement, + * the key material) is wiped after use; the copies the system and Swift keep (see above) are immutable and are not. + */ +public class IosPinFactor internal constructor(private val enclave: PinEnclave) : PinHardwareFactor { + @Throws(PinFactorException::class) + override fun ensureKey(slot: PinKeySlot): Boolean { + try { + if (enclave.exists(slot)) return false + enclave.create(slot) + return true + } catch (e: SecretStoreException) { + throw PinFactorException(lost = false, cause = e) + } + } + + @Throws(PinFactorException::class) + override fun reset(slot: PinKeySlot) { + try { + enclave.create(slot) + } catch (e: SecretStoreException) { + throw PinFactorException(lost = false, cause = e) + } + } + + @Throws(PinFactorException::class) + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { + val point = try { + PinPoint.derive(slot, data) + } catch (e: IllegalStateException) { + throw PinFactorException(lost = false, cause = e) + } + try { + val agreement = try { + enclave.agree(slot, point) + } catch (e: SecretStoreException) { + throw PinFactorException(lost = e.lost, cause = e) + } ?: throw PinFactorException(lost = true) + return derive(agreement, data) + } finally { + SensitiveDataCleaner.clear(point) + } + } + + override fun delete(slot: PinKeySlot) { + try { + enclave.delete(slot) + } catch (_: SecretStoreException) { + // Documented: delete never throws. + } + } + + private fun derive(agreement: ByteArray, data: ByteArray): ByteArray { + val info = KDF_INFO + data + try { + if (agreement.size != AGREEMENT_BYTES) throw PinFactorException(lost = false) + return Crypto.hkdfSha256(agreement, KDF_INFO, info, HW_BYTES) + } finally { + SensitiveDataCleaner.clear(agreement) + SensitiveDataCleaner.clear(info) + } + } + + public companion object { + /** The factor on this device's Secure Enclave (on the Simulator, a software key). */ + public fun create(): IosPinFactor = IosPinFactor(platformPinEnclave(APPLICATION_ID)) + + private val KDF_INFO = "encly.ios.pin-factor.v2/hw".encodeToByteArray() + private const val HW_BYTES = 32 + } +} diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PinEnclave.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PinEnclave.kt new file mode 100644 index 00000000..0c5a2d20 --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PinEnclave.kt @@ -0,0 +1,206 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinKeySlot +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import platform.CoreFoundation.CFErrorGetCode +import platform.CoreFoundation.CFErrorRefVar +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFTypeRefVar +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.SecKeyCopyKeyExchangeResult +import platform.Security.SecKeyCreateRandomKey +import platform.Security.SecKeyCreateWithData +import platform.Security.SecKeyRef +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.kSecAttrApplicationTag +import platform.Security.kSecAttrIsPermanent +import platform.Security.kSecAttrKeyClass +import platform.Security.kSecAttrKeyClassPublic +import platform.Security.kSecAttrKeySizeInBits +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeECSECPrimeRandom +import platform.Security.kSecClass +import platform.Security.kSecClassKey +import platform.Security.kSecKeyAlgorithmECDHKeyExchangeStandard +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecPrivateKeyAttrs +import platform.Security.kSecReturnRef + +/** + * The P-256 key of a [PinKeySlot] that the PIN factor asks for one key agreement per PIN guess (see [IosPinFactor]). + * The private half never leaves where it was made: on a device the Secure Enclave, which only hands out the + * result of an agreement; on the Simulator, which has none, a software key in the Keychain + * ([platformPinEnclave]). Failures are [SecretStoreException]s, `lost` only where the key can never be used again. + */ +internal interface PinEnclave { + /** Whether the key of [slot] exists. */ + @Throws(SecretStoreException::class) + fun exists(slot: PinKeySlot): Boolean + + /** Makes a new key for [slot], replacing the one it had. Refuses (never falls back to something weaker). */ + @Throws(SecretStoreException::class) + fun create(slot: PinKeySlot) + + /** Removes the key of [slot]; none is not an error. */ + @Throws(SecretStoreException::class) + fun delete(slot: PinKeySlot) + + /** + * ECDH of the key of [slot] with the public point [peer] (ANSI X9.63, 65 bytes): the 32-byte x coordinate of + * the shared point, or null when [slot] has no key. The caller wipes [peer] and the result. + */ + @Throws(SecretStoreException::class) + fun agree(slot: PinKeySlot, peer: ByteArray): ByteArray? +} + +/** + * [PinEnclave] over Security.framework keys that live in the Keychain, one permanent EC P-256 private key per slot, + * found by its application tag. What differs between the Secure Enclave and the Simulator's software key is + * only how the key is made ([configure]), which each platform source set supplies, so a device binary holds + * no software-key code. + */ +internal abstract class SecKeyPinEnclave(private val service: String) : PinEnclave { + /** + * Adds what makes the key hardware or software (token, access control or accessibility) to the attributes of the + * key and of its private half. The tag and permanence are set by the caller. + */ + protected abstract fun configure(attributes: CfDictionary, privateAttributes: CfDictionary) + + /** What [create] says when the key cannot be made. */ + protected abstract val refusal: String + + @Throws(SecretStoreException::class) + override fun exists(slot: PinKeySlot): Boolean { + val key = findKey(slot) ?: return false + CFRelease(key) + return true + } + + @Throws(SecretStoreException::class) + override fun create(slot: PinKeySlot) { + delete(slot) + val privateAttributes = CfDictionary() + val attributes = CfDictionary() + try { + privateAttributes.putBoolean(kSecAttrIsPermanent, true).putBytes(kSecAttrApplicationTag, tag(slot)) + attributes.put(kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom) + .putInt(kSecAttrKeySizeInBits, KEY_BITS) + .put(kSecPrivateKeyAttrs, privateAttributes.ref) + configure(attributes, privateAttributes) + memScoped { + val error = alloc() + val key = SecKeyCreateRandomKey(attributes.ref, error.ptr) + ?: throw SecretStoreException(lost = false, message = "$refusal: ${errorCode(error)}") + CFRelease(key) + } + } finally { + attributes.release() + privateAttributes.release() + } + } + + @Throws(SecretStoreException::class) + override fun delete(slot: PinKeySlot) { + val status = withCfDictionary { query -> + SecItemDelete(query.put(kSecClass, kSecClassKey).putBytes(kSecAttrApplicationTag, tag(slot)).ref) + } + if (status != errSecSuccess && status != errSecItemNotFound) { + throw SecretStoreException(lost = false, message = "key delete: $status") + } + } + + @Throws(SecretStoreException::class) + override fun agree(slot: PinKeySlot, peer: ByteArray): ByteArray? { + val key = findKey(slot) ?: return null + try { + return keyAgreement(key, peer) + } finally { + CFRelease(key) + } + } + + private fun findKey(slot: PinKeySlot): SecKeyRef? = withCfDictionary { query -> + query.put(kSecClass, kSecClassKey) + .put(kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom) + .putBytes(kSecAttrApplicationTag, tag(slot)) + .putBoolean(kSecReturnRef, true) + .put(kSecMatchLimit, kSecMatchLimitOne) + memScoped { + val result = alloc() + when (val status = SecItemCopyMatching(query.ref, result.ptr)) { + errSecSuccess -> result.value as SecKeyRef? + errSecItemNotFound -> null + else -> throw SecretStoreException(lost = false, message = "key lookup: $status") + } + } + } + + private fun tag(slot: PinKeySlot): ByteArray = "$service.pin-key-v2.${slot.entryName()}".encodeToByteArray() + + private companion object { + const val KEY_BITS = 256 + } +} + +/** + * `SecKeyCopyKeyExchangeResult(privateKey, ecdhKeyExchangeStandard, peer)`: the x coordinate of `d · peer`, 32 + * bytes. It needs no Keychain, so the tests run it on a key made in memory. The CFData Apple returns is immutable + * and cannot be wiped; the copy handed back is the caller's to wipe. + */ +@Throws(SecretStoreException::class) +internal fun keyAgreement(privateKey: SecKeyRef, peer: ByteArray): ByteArray = memScoped { + val error = alloc() + val publicKey = peerKey(peer, error) + val parameters = CfDictionary() + try { + val shared = SecKeyCopyKeyExchangeResult( + privateKey, + kSecKeyAlgorithmECDHKeyExchangeStandard, + publicKey, + parameters.ref, + error.ptr, + ) ?: throw SecretStoreException(lost = false, message = "key agreement: ${errorCode(error)}") + try { + cfDataToBytes(shared).also { + if (it.size != AGREEMENT_BYTES) { + it.fill(0) + throw SecretStoreException(lost = false, message = "key agreement of ${it.size} bytes") + } + } + } finally { + CFRelease(shared) + } + } finally { + parameters.release() + CFRelease(publicKey) + } +} + +internal const val AGREEMENT_BYTES = 32 + +/** The public key of the X9.63 point [peer]; a point that is not on the curve is refused by the framework. */ +private fun peerKey(peer: ByteArray, error: CFErrorRefVar): SecKeyRef { + val attributes = CfDictionary() + val data = newSecretData(peer) + try { + attributes.put(kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom).put(kSecAttrKeyClass, kSecAttrKeyClassPublic) + return SecKeyCreateWithData(data, attributes.ref, error.ptr) + ?: throw SecretStoreException(lost = false, message = "peer point: ${errorCode(error)}") + } finally { + attributes.release() + zeroizeAndRelease(data) + } +} + +private fun errorCode(error: CFErrorRefVar): Long = + error.value?.let { CFErrorGetCode(it).also { _ -> CFRelease(it) } } ?: 0 diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PinPoint.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PinPoint.kt new file mode 100644 index 00000000..3ed22900 --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PinPoint.kt @@ -0,0 +1,52 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.crypto.P256Points +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.SensitiveDataCleaner + +/** + * The point of the P-256 curve that a PIN guess is turned into before it goes to the Secure Enclave + * (see [IosPinFactor]): a hash of the guess onto the curve, so that nobody, the app included, knows its discrete + * logarithm. That is the whole point: with `Q = k·G` for a `k` derived from the PIN, the agreement + * `d·Q = k·(d·G)` would need only the Enclave key's *public* half, and the PIN could be searched without the Enclave. + * + * "Try and increment": `x = SHA-256(domain ‖ 0 ‖ slot ‖ 0 ‖ data ‖ counter)` for counter 0, 1, 2, … until + * CryptoKit finds a curve point with that abscissa ([P256Points.fromX], roughly every other x does), whose + * result is the point `04 ‖ X ‖ Y`. The counter is one byte; 256 misses in a row have a probability of 2^-256. + * + * Not constant-time: how many tries a PIN needed shows in the time of a guess. Whoever can measure that inside this + * process already sees the PIN. The point is as sensitive as the PIN (it falls to 10^6 SHA-256 calls): callers wipe + * it and nothing stores or logs it. + */ +internal object PinPoint { + private val DOMAIN = "encly.ios.pin-factor.v2/hash-to-point".encodeToByteArray() + private const val SEPARATOR: Byte = 0 + private const val MAX_TRIES = 256 + + /** The point of [data] (`salt ‖ PIN`, as the vault passes it) for [slot]. The caller wipes it. */ + fun derive(slot: PinKeySlot, data: ByteArray): ByteArray { + val slotName = slot.entryName().encodeToByteArray() + // One buffer for every try: only the counter at its end changes, and it is wiped once. + val input = DOMAIN + SEPARATOR + slotName + SEPARATOR + data + 0.toByte() + try { + for (counter in 0 until MAX_TRIES) { + pointFor(input, counter)?.let { return it } + } + } finally { + SensitiveDataCleaner.clear(input) + } + error("no curve point in $MAX_TRIES tries") + } + + /** The point of try [counter] (the last byte of [input]), or null when the hash is no abscissa of a point. */ + private fun pointFor(input: ByteArray, counter: Int): ByteArray? { + input[input.lastIndex] = counter.toByte() + val x = Crypto.sha256(input) + try { + return P256Points.fromX(x) + } finally { + SensitiveDataCleaner.clear(x) + } + } +} diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PlatformPinEnclave.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PlatformPinEnclave.kt new file mode 100644 index 00000000..908f7da2 --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PlatformPinEnclave.kt @@ -0,0 +1,11 @@ +package com.pasich.encly.core.keystore + +/** + * The [PinEnclave] of this build. Two actuals, one per iOS target, so the two never share a binary: + * - `iosArm64` (a device): the Secure Enclave, and nothing else. Where it cannot make a key, [PinEnclave.create] + * fails; the PIN is never protected by a weaker key instead. Every iOS version the app supports (15+) runs on + * hardware with an Enclave. + * - `iosSimulatorArm64`: a software key in the Keychain, because the Simulator has no Enclave. It exists so the + * app and its tests can run there; it protects nothing against someone who can read the Mac. + */ +internal expect fun platformPinEnclave(service: String): PinEnclave diff --git a/core/keystore/src/iosSimulatorArm64Main/kotlin/com/pasich/encly/core/keystore/SoftwarePinEnclave.kt b/core/keystore/src/iosSimulatorArm64Main/kotlin/com/pasich/encly/core/keystore/SoftwarePinEnclave.kt new file mode 100644 index 00000000..5e512f22 --- /dev/null +++ b/core/keystore/src/iosSimulatorArm64Main/kotlin/com/pasich/encly/core/keystore/SoftwarePinEnclave.kt @@ -0,0 +1,22 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.core.keystore + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly + +internal actual fun platformPinEnclave(service: String): PinEnclave = SoftwarePinEnclave(service) + +/** + * The Simulator has no Secure Enclave, so its key is an ordinary EC P-256 key in the Keychain + * (`WhenUnlockedThisDeviceOnly`). The construction is the same, but nothing makes the key hard to extract from + * the Mac that hosts the Simulator: this class is for development and for tests, and is not compiled for a device. + */ +internal class SoftwarePinEnclave(service: String) : SecKeyPinEnclave(service) { + override val refusal: String = "software key refused" + + override fun configure(attributes: CfDictionary, privateAttributes: CfDictionary) { + privateAttributes.put(kSecAttrAccessible, kSecAttrAccessibleWhenUnlockedThisDeviceOnly) + } +} diff --git a/core/keystore/src/iosSimulatorArm64Test/kotlin/com/pasich/encly/core/keystore/SoftwarePinEnclaveTest.kt b/core/keystore/src/iosSimulatorArm64Test/kotlin/com/pasich/encly/core/keystore/SoftwarePinEnclaveTest.kt new file mode 100644 index 00000000..4682a8c8 --- /dev/null +++ b/core/keystore/src/iosSimulatorArm64Test/kotlin/com/pasich/encly/core/keystore/SoftwarePinEnclaveTest.kt @@ -0,0 +1,72 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinKeySlot +import platform.Foundation.NSUUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Simulator's software key in the real Keychain (under a service name of its own, removed afterwards). A plain + * test executable has no application identifier and the Simulator then refuses every SecItem call + * (errSecNotAvailable, -25291); those tests return early (the build log shows them as passed, which here means + * skipped) and [withoutAKeychainEveryCallIsAStoreFailureThatIsNotLost] checks the refusal instead. Hosted in an + * app, they run. The Secure Enclave key of a device cannot be tested here at all. + */ +class SoftwarePinEnclaveTest { + private val service = "com.pasich.encly.test." + NSUUID().UUIDString + private val enclave = SoftwarePinEnclave(service) + private val factor = IosPinFactor(enclave) + private val data = "482913".encodeToByteArray() + + private val keychainAvailable: Boolean = try { + enclave.exists(PinKeySlot.A) + true + } catch (_: SecretStoreException) { + false + } + + @AfterTest + fun cleanUp() { + if (keychainAvailable) PinKeySlot.entries.forEach { enclave.delete(it) } + } + + @Test + fun withoutAKeychainEveryCallIsAStoreFailureThatIsNotLost() { + if (keychainAvailable) return + + assertFalse(assertFailsWith { enclave.exists(PinKeySlot.A) }.lost) + assertFalse(assertFailsWith { factor.ensureKey(PinKeySlot.A) }.lost) + assertFalse(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + } + + @Test + fun aMissingKeyHasNoAgreement() { + if (!keychainAvailable) return + + assertFalse(enclave.exists(PinKeySlot.A)) + assertNull(enclave.agree(PinKeySlot.A, PinPoint.derive(PinKeySlot.A, data))) + } + + @Test + fun theFactorOnTheRealKeychainFollowsTheContract() { + if (!keychainAvailable) return + + assertTrue(factor.ensureKey(PinKeySlot.A)) + assertFalse(factor.ensureKey(PinKeySlot.A)) + val before = factor.mac(PinKeySlot.A, data) + assertContentEquals(before, factor.mac(PinKeySlot.A, data)) + + factor.reset(PinKeySlot.A) + assertFalse(before.contentEquals(factor.mac(PinKeySlot.A, data))) + + factor.delete(PinKeySlot.A) + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + assertTrue(factor.ensureKey(PinKeySlot.A)) + } +} diff --git a/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/IosPinFactorTest.kt b/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/IosPinFactorTest.kt new file mode 100644 index 00000000..7fd7d4b6 --- /dev/null +++ b/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/IosPinFactorTest.kt @@ -0,0 +1,247 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinKeySlot +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The iOS factor's states over a [FakeEnclave], so they are tested without a Keychain or a Secure Enclave. What the + * real key agreement does is in [PinFactorGoldenTest]. + */ +class IosPinFactorTest { + private val enclave = FakeEnclave() + private val factor = IosPinFactor(enclave) + + private val salt = ByteArray(16) { (0xA0 + it).toByte() } + private val pin = "482913".encodeToByteArray() + private val data = salt + pin + + @Test + fun macIsDeterministicAndDependsOnTheData() { + factor.ensureKey(PinKeySlot.A) + + val first = factor.mac(PinKeySlot.A, data) + + assertEquals(32, first.size) + assertContentEquals(first, factor.mac(PinKeySlot.A, data)) + assertFalse(first.contentEquals(factor.mac(PinKeySlot.A, salt + "482914".encodeToByteArray()))) + assertFalse(first.contentEquals(factor.mac(PinKeySlot.A, "482913".encodeToByteArray() + salt))) + } + + @Test + fun everyGuessIsAnAgreementOfTheEnclaveOnAPointOfItsOwn() { + factor.ensureKey(PinKeySlot.A) + + factor.mac(PinKeySlot.A, data) + factor.mac(PinKeySlot.A, data) + factor.mac(PinKeySlot.A, salt + "000000".encodeToByteArray()) + + assertEquals(3, enclave.peers.size) + assertTrue(enclave.peers.all { it.size == 65 && it[0] == 4.toByte() }) + assertContentEquals(enclave.peers[0], enclave.peers[1]) + assertFalse(enclave.peers[0].contentEquals(enclave.peers[2])) + } + + @Test + fun theSameDataOnTheOtherSlotIsAnotherPointAndAnotherFactor() { + factor.ensureKey(PinKeySlot.A) + factor.ensureKey(PinKeySlot.B) + + val a = factor.mac(PinKeySlot.A, data) + val b = factor.mac(PinKeySlot.B, data) + + assertFalse(a.contentEquals(b)) + assertFalse(enclave.peers[0].contentEquals(enclave.peers[1])) + } + + @Test + fun firstUseCreatesTheKeyAndReportsIt() { + assertTrue(factor.ensureKey(PinKeySlot.A)) + assertTrue(enclave.has(PinKeySlot.A)) + assertFalse(factor.ensureKey(PinKeySlot.A)) + assertFalse(enclave.has(PinKeySlot.B)) + assertEquals(1, enclave.created) + } + + @Test + fun resetReplacesTheKey() { + factor.ensureKey(PinKeySlot.A) + val before = factor.mac(PinKeySlot.A, data) + + factor.reset(PinKeySlot.A) + + assertFalse(before.contentEquals(factor.mac(PinKeySlot.A, data))) + assertEquals(2, enclave.created) + } + + @Test + fun resetOfAMissingSlotCreatesIt() { + factor.reset(PinKeySlot.B) + + assertTrue(enclave.has(PinKeySlot.B)) + factor.mac(PinKeySlot.B, data) + } + + @Test + fun macWithoutAKeyIsLostAndAsksNoOneButTheEnclave() { + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + assertEquals(1, enclave.peers.size) + } + + @Test + fun macAfterDeleteIsLostAndEnsureKeyMakesANewKey() { + factor.ensureKey(PinKeySlot.A) + val before = factor.mac(PinKeySlot.A, data) + + factor.delete(PinKeySlot.A) + + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + assertTrue(factor.ensureKey(PinKeySlot.A)) + assertNotEquals(before.toHex(), factor.mac(PinKeySlot.A, data).toHex()) + } + + @Test + fun aKeyTheEnclaveCallsGoneIsLost() { + factor.ensureKey(PinKeySlot.A) + enclave.agreeFailure = SecretStoreException(lost = true, message = "gone") + + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + } + + @Test + fun anEnclaveThatIsBusyOrLockedIsNotLost() { + factor.ensureKey(PinKeySlot.A) + val locked = SecretStoreException(lost = false, message = "locked") + enclave.agreeFailure = locked + + val failure = assertFailsWith { factor.mac(PinKeySlot.A, data) } + + assertFalse(failure.lost) + assertEquals(locked, failure.cause) + enclave.agreeFailure = null + factor.mac(PinKeySlot.A, data) + } + + @Test + fun anAgreementOfTheWrongSizeIsNotLostAndIsWiped() { + factor.ensureKey(PinKeySlot.A) + enclave.agreementSize = 31 + + assertFalse(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) + + assertTrue(enclave.handedOut.all { copy -> copy.all { it == 0.toByte() } }) + } + + @Test + fun anEnclaveThatRefusesToMakeAKeyFailsSetupAndNeverFallsBack() { + enclave.createFailure = SecretStoreException(lost = false, message = "no Secure Enclave") + + assertFalse(assertFailsWith { factor.ensureKey(PinKeySlot.A) }.lost) + assertFalse(assertFailsWith { factor.reset(PinKeySlot.A) }.lost) + assertFalse(enclave.has(PinKeySlot.A)) + } + + @Test + fun anUnreadableLookupIsNotLost() { + enclave.existsFailure = SecretStoreException(lost = false, message = "locked") + + assertFalse(assertFailsWith { factor.ensureKey(PinKeySlot.A) }.lost) + } + + @Test + fun deleteNeverThrows() { + enclave.deleteFailure = SecretStoreException(lost = false, message = "locked") + + factor.delete(PinKeySlot.A) + } + + @Test + fun macWipesThePointAndTheAgreementAndLeavesTheDataAlone() { + factor.ensureKey(PinKeySlot.A) + val input = data.copyOf() + + factor.mac(PinKeySlot.A, input) + + assertTrue(enclave.peerArrays.isNotEmpty() && enclave.peerArrays.all { it.all { b -> b == 0.toByte() } }) + assertTrue(enclave.handedOut.isNotEmpty() && enclave.handedOut.all { it.all { b -> b == 0.toByte() } }) + assertContentEquals(data, input) + } + + @Test + fun theFailurePathsWipeToo() { + factor.ensureKey(PinKeySlot.A) + enclave.agreeFailure = SecretStoreException(lost = false, message = "locked") + + assertFailsWith { factor.mac(PinKeySlot.A, data) } + + assertTrue(enclave.peerArrays.all { it.all { b -> b == 0.toByte() } }) + } + + @Test + fun entriesAreNamedPerSlot() { + assertEquals("pin-factor-v1", PinKeySlot.A.entryName()) + assertEquals("pin-factor-v1-b", PinKeySlot.B.entryName()) + } + + @Test + fun createBuildsTheFactorWithoutTouchingTheKeychain() { + IosPinFactor.create() + } +} + +/** A [PinEnclave] in memory: a key is a secret seed, and an agreement is a hash of the seed and the point. */ +internal class FakeEnclave : PinEnclave { + private val seeds = mutableMapOf() + + /** Every point [agree] was asked about, as it was then (the factor wipes its own array afterwards). */ + val peers = mutableListOf() + + /** The very arrays [agree] was handed, so that a test can check that they were wiped. */ + val peerArrays = mutableListOf() + + /** Every agreement [agree] returned. */ + val handedOut = mutableListOf() + var created = 0 + var existsFailure: SecretStoreException? = null + var createFailure: SecretStoreException? = null + var deleteFailure: SecretStoreException? = null + var agreeFailure: SecretStoreException? = null + var agreementSize = AGREEMENT_BYTES + + fun has(slot: PinKeySlot): Boolean = slot in seeds + + override fun exists(slot: PinKeySlot): Boolean { + existsFailure?.let { throw it } + return slot in seeds + } + + override fun create(slot: PinKeySlot) { + createFailure?.let { throw it } + seeds[slot] = Crypto.randomBytes(32) + created++ + } + + override fun delete(slot: PinKeySlot) { + deleteFailure?.let { throw it } + seeds.remove(slot) + } + + override fun agree(slot: PinKeySlot, peer: ByteArray): ByteArray? { + peers += peer.copyOf() + peerArrays += peer + agreeFailure?.let { throw it } + val seed = seeds[slot] ?: return null + val agreement = Crypto.hkdfSha256(seed, "fake".encodeToByteArray(), peer, 32).copyOf(agreementSize) + handedOut += agreement + return agreement + } +} + +internal fun ByteArray.toHex(): String = joinToString("") { it.toUByte().toString(16).padStart(2, '0') } diff --git a/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/PinFactorGoldenTest.kt b/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/PinFactorGoldenTest.kt new file mode 100644 index 00000000..a0d4f013 --- /dev/null +++ b/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/PinFactorGoldenTest.kt @@ -0,0 +1,155 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.crypto.P256Points +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinKeySlot +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.CoreFoundation.CFErrorRefVar +import platform.Security.SecKeyCreateWithData +import platform.Security.SecKeyRef +import platform.Security.kSecAttrKeyClass +import platform.Security.kSecAttrKeyClassPrivate +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeECSECPrimeRandom +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The real construction, end to end, with the one thing a test cannot have swapped out: the key agreement is + * Security.framework's ECDH, on keys made in memory from fixed scalars (no Keychain, so this runs in the plain test + * executable). The expected values come from a separate computation of the same specification with CryptoKit's + * `P256.KeyAgreement` and `HKDF`, so they also catch a change in how the system decodes a point or runs ECDH. + */ +class PinFactorGoldenTest { + private val salt = ByteArray(16) { (0xA0 + it).toByte() } + private val data = salt + "482913".encodeToByteArray() + + private val enclave = InMemoryKeyEnclave( + PinKeySlot.A to KEY_1, + PinKeySlot.B to KEY_1, + ) + private val factor = IosPinFactor(enclave) + + @Test + fun theFactorIsTheSpecifiedHkdfOfTheEnclavesAgreement() { + assertEquals( + "7f60a4188104296bd29f6c95dbeea5b127da38315a7e64ea0b5a8a740ac8ce4c", + factor.mac(PinKeySlot.A, data).toHex(), + ) + assertEquals( + "b761bf14ee6f83a64d392142a6d49becc99451960f04bdd70186a9650ba3e429", + factor.mac(PinKeySlot.A, salt + "482914".encodeToByteArray()).toHex(), + ) + } + + @Test + fun theSlotIsPartOfThePointSoTwoSlotsWithOneKeyStillDiffer() { + assertEquals( + "7f3b60b13c513cdc754ab0dac445dfbc6f6ccd0b6b3c93a3ca12233f25f16275", + factor.mac(PinKeySlot.B, data).toHex(), + ) + } + + @Test + fun anotherKeyGivesAnotherFactorForTheSamePin() { + val other = IosPinFactor(InMemoryKeyEnclave(PinKeySlot.A to KEY_2)) + + assertEquals( + "cc9991a8790aeeb1460fbef5153ffb43bf0c004a0732936e35a803d23b5e3811", + other.mac(PinKeySlot.A, data).toHex(), + ) + } + + @Test + fun theSamePinAndKeyAlwaysGiveTheSameFactor() { + assertContentEquals(factor.mac(PinKeySlot.A, data), factor.mac(PinKeySlot.A, data)) + } + + @Test + fun thePointOfAPinHasTheDocumentedAbscissaAndIsOnTheCurve() { + val point = PinPoint.derive(PinKeySlot.A, data) + + assertEquals(65, point.size) + assertEquals(4, point[0].toInt()) + // The y of the pair is CryptoKit's choice and does not matter to an ECDH; the x is fixed by the hash. + assertEquals( + "f90b5e4c28b108e58da861a893c84bbd4cb15a0af9c6f981f03a485e7a876b12", + point.copyOfRange(1, 33).toHex(), + ) + assertNotNull(P256Points.fromX(point.copyOfRange(1, 33))) + } + + @Test + fun pointsOfPinsOneDigitApartAreUnrelated() { + // The point is a hash, not k·G for a k derived from the PIN (then d·Q = k·(d·G) would follow from the + // public key alone); the golden x above pins the hashing, this checks that the PIN reaches the point. + val a = PinPoint.derive(PinKeySlot.A, data) + val b = PinPoint.derive(PinKeySlot.A, salt + "482914".encodeToByteArray()) + + assertFalse(a.copyOfRange(1, 33).contentEquals(b.copyOfRange(1, 33))) + } + + @Test + fun aPointOffTheCurveIsRefusedByTheAgreementAndIsNotLost() { + val key = privateKey(KEY_1) + val failure = assertFailsWith { keyAgreement(key, ByteArray(65).also { it[0] = 4 }) } + + assertFalse(failure.lost) + } + + @Test + fun anUnknownSlotIsLost() { + val onlyA = IosPinFactor(InMemoryKeyEnclave(PinKeySlot.A to KEY_1)) + + assertTrue(assertFailsWith { onlyA.mac(PinKeySlot.B, data) }.lost) + } +} + +/** [PinEnclave] whose keys are Security.framework software keys made in memory (never in the Keychain). */ +internal class InMemoryKeyEnclave(vararg keys: Pair) : PinEnclave { + private val keys = keys.associate { (slot, x963) -> slot to privateKey(x963) }.toMutableMap() + + override fun exists(slot: PinKeySlot): Boolean = slot in keys + + override fun create(slot: PinKeySlot) = throw UnsupportedOperationException() + + override fun delete(slot: PinKeySlot) { + keys.remove(slot) + } + + override fun agree(slot: PinKeySlot, peer: ByteArray): ByteArray? = keys[slot]?.let { keyAgreement(it, peer) } +} + +/** A private key from `04 ‖ X ‖ Y ‖ K` in hex, as CryptoKit's `x963Representation` writes it. */ +internal fun privateKey(x963Hex: String): SecKeyRef { + val bytes = ByteArray(x963Hex.length / 2) { x963Hex.substring(2 * it, 2 * it + 2).toInt(16).toByte() } + val attributes = CfDictionary() + val data = newSecretData(bytes) + try { + attributes.put(kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom).put(kSecAttrKeyClass, kSecAttrKeyClassPrivate) + return memScoped { + val error = alloc() + checkNotNull(SecKeyCreateWithData(data, attributes.ref, error.ptr)) { "test key refused" } + } + } finally { + attributes.release() + zeroizeAndRelease(data) + } +} + +/** The scalar 0x0102..20 and the scalar 0xFEFD..DF, as `04 ‖ X ‖ Y ‖ K`. */ +private const val KEY_1 = + "04515c3d6eb9e396b904d3feca7f54fdcd0cc1e997bf375dca515ad0a6c3b4035f4536be3a50f318fbf9a5475902a221502bef0d57e08c53b2cc0a56f17d9f93540102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" +private const val KEY_2 = + "04f6485850ce56925f57afe2cb80c91d65c02d3c7a2ab114f4a04cb1e6c08dd250680d456025bad34af7d6a7092bb1a3acb95613e5723286108e823646fc6df9c8fefdfcfbfaf9f8f7f6f5f4f3f2f1f0efeeedecebeae9e8e7e6e5e4e3e2e1e0df" diff --git a/core/resources/.gitignore b/core/resources/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/core/resources/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/resources/README.md b/core/resources/README.md new file mode 100644 index 00000000..2ed92eeb --- /dev/null +++ b/core/resources/README.md @@ -0,0 +1,21 @@ +# :core:resources + +Every string and plural the UI shows, in English and nine translations (ar de es fr it nl pl pt uk), +as Compose Multiplatform resources. Android, desktop JVM and iOS targets. + +- Files: `src/commonMain/strings/values[-]/{strings,strings_handoff,strings_import,strings_security}.xml`, + in Android's `strings.xml` format, exactly what Weblate reads and writes. The build step + `convertAndroidStrings` (`ConvertAndroidStrings` in `build-logic`) turns them into the + `composeResources` directory Compose resources read: it drops Android's backslash escapes (`\'`) and + numbers `%d` / `%s`, and `%%` becomes `%`. +- API: the generated, public `com.pasich.encly.core.resources.Res` (`Res.string.`, + `Res.plurals.`). In Compose use `stringResource` / `pluralStringResource`; elsewhere the suspend + `getString` / `getPluralString`, or `UiText` / `AppStrings` in the app. +- Language: the resources follow the process's default locale. On Android `AppLocales.apply` keeps it on + the in-app language. +- Tests: `AndroidStringsConversionTest` holds the converted resources to what Android would have shown, + for every string and plural of every language. +- Not here: the launcher label (`app_name`), which Android reads from `app/src/main/res/values/`. +- Depends on nothing of Encly. + +See [CONTRIBUTING.md → Translations](../../CONTRIBUTING.md#translations). diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts new file mode 100644 index 00000000..631fffb4 --- /dev/null +++ b/core/resources/build.gradle.kts @@ -0,0 +1,60 @@ +// Every string and plural the UI shows, in all ten languages, as Compose Multiplatform resources. The +// translations are kept in Android's strings.xml format, which is what Weblate reads and writes +// (src/commonMain/strings/values[-]/*.xml); a build step turns them into the files Compose +// resources read. The generated `Res` class is public, so the app and the feature modules read +// `Res.string.*` / `Res.plurals.*`. Strings that Android itself needs (the launcher label) stay in the +// app's res/. +plugins { + id("encly.kmp-library") + id("encly.kmp-ios") + id("encly.compose") +} + +kotlin { + android { + namespace = "com.pasich.encly.core.resources" + // The Android target packages the composeResources files as assets, which needs the AGP + // resource pipeline (off by default in the KMP library plugin). + androidResources { + enable = true + } + } + + sourceSets { + commonMain.dependencies { + // StringResource and PluralStringResource are part of the API of the generated Res class. + api(libs.compose.components.resources) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + getByName("androidHostTest").dependencies { + // Reading the resources from the Android assets runs on the JVM under Robolectric. + implementation(libs.junit) + implementation(libs.androidx.test.core) + implementation(libs.robolectric) + } + } +} + +// Android's escapes and unnumbered formats are not Compose resources' (see ConvertAndroidStrings in +// build-logic), so the Weblate-managed files are converted, and the result is the composeResources +// directory of commonMain. +val convertAndroidStrings by tasks.registering(ConvertAndroidStrings::class) { + sources.set(layout.projectDirectory.dir("src/commonMain/strings")) + output.set(layout.buildDirectory.dir("generated/androidStrings/composeResources")) +} + +compose.resources { + customDirectory(sourceSetName = "commonMain", directoryProvider = convertAndroidStrings.flatMap { it.output }) + publicResClass = true + packageOfResClass = "com.pasich.encly.core.resources" + generateResClass = always +} + +// The public API is the generated Res class: one accessor per string, so an API dump would change with +// every string a translator or a developer adds. The module has no hand-written API to guard. +tasks.matching { it.name.contains("KotlinAbi") }.configureEach { + enabled = false +} diff --git a/core/resources/src/androidHostTest/kotlin/com/pasich/encly/core/resources/AndroidAssetsTest.kt b/core/resources/src/androidHostTest/kotlin/com/pasich/encly/core/resources/AndroidAssetsTest.kt new file mode 100644 index 00000000..7e492efe --- /dev/null +++ b/core/resources/src/androidHostTest/kotlin/com/pasich/encly/core/resources/AndroidAssetsTest.kt @@ -0,0 +1,42 @@ +package com.pasich.encly.core.resources + +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getPluralString +import org.jetbrains.compose.resources.getString +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +/** The resources are packaged as Android assets and read back through the Android reader. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidAssetsTest { + private val saved = Locale.getDefault() + + @After + fun restoreLocale() = Locale.setDefault(saved) + + @Test + fun theDefaultLanguageIsEnglish() { + Locale.setDefault(Locale.ENGLISH) + assertEquals("Cancel", runBlocking { getString(Res.string.cancel) }) + } + + @Test + fun theDefaultLocaleSelectsTheTranslation() { + Locale.setDefault(Locale.forLanguageTag("uk")) + assertEquals("Скасувати", runBlocking { getString(Res.string.cancel) }) + Locale.setDefault(Locale.ENGLISH) + assertEquals("Today 10:15", runBlocking { getString(Res.string.date_today_at, "10:15") }) + } + + @Test + fun pluralsPickTheirCategory() { + Locale.setDefault(Locale.forLanguageTag("uk")) + assertEquals("Вибрано 3", runBlocking { getPluralString(Res.plurals.trash_selected, 3, 3) }) + } +} diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml new file mode 100644 index 00000000..dfc05098 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -0,0 +1,739 @@ + + + حول + مساحة محلية مشفرة للملاحظات والأفكار والمهام، بلا مزامنة سحابية. + حماية البيانات + تُخزَّن بيانات الخزنة محليًا في قاعدة بيانات SQLCipher مشفرة + الأسئلة الشائعة + اقتراحات وبلاغات + أبلغ عن خلل أو اقترح تحسينًا على GitHub + لماذا هذا مهم وكيف تساهم عبر Ko-fi + لم يُعثر على متصفح. افتح الرابط يدويًا: %1$s + لم يُعثر على تطبيق بريد إلكتروني + سياسة الخصوصية + كيف نخزّن بياناتك ونحميها + قيّم التطبيق + اترك تقييمًا في متجر Google Play + الدعم والتواصل + تواصل مع المطوّر + إن كانت لديك أسئلة أو اقتراحات فيمكنك مراسلتي عبر البريد الإلكتروني. + + متابعة + + إضافة + + الكل + + من 6 إلى 12 رقمًا. اضغط لتغيير رمز PIN. + رمز PIN + المصادقة + + رجوع + + لم يتم تأكيد تغيير الحماية بالقياسات الحيوية + تعذّر إنشاء مفتاح القياسات الحيوية + أكّد هويتك + المصادقة + أكّد قياساتك الحيوية + الوصول إلى المفتاح + أكّد قياساتك الحيوية لتغيير الحماية + تأكيد الأمان + أكّد قياساتك الحيوية للوصول إلى المفتاح المشفر + فتح Encly + استخدم رمز PIN + المصادقة بالقياسات الحيوية غير متاحة + + المزيد من أدوات الكتلة + قائمة نقطية + قائمة تحقق + عنوان + عنوان %1$d + رابط + قائمة + قفل قائمة التحقق + فتح قفل قائمة التحقق + نقل الكتلة لأسفل + نقل الكتلة لأعلى + قائمة مرقّمة + فاصل + + إلغاء + + هل تريد حقًا حذف هذه الملاحظات نهائيًا؟ لا يمكن التراجع عن ذلك. + هل تريد حقًا تفريغ سلة المحذوفات نهائيًا؟ لا يمكن التراجع عن ذلك. + تفريغ سلة المحذوفات؟ + + مسح + + إغلاق + + طيّ + + إنشاء وسم جديد + + اليوم %1$s + أمس %1$s + + حذف + حذف الكتلة + حذف + حذف الوسم + سيُزال هذا الوسم من كل ملاحظاتك. لن تُحذف الملاحظات نفسها. + + حذف + إلغاء + هل تريد حقًا حذف هذه الملاحظة نهائيًا؟ لا يمكن التراجع عن ذلك. + حذف الملاحظة؟ + + Encly مجاني ومفتوح المصدر، بلا إعلانات ولا تتبّع ولا حسابات. التبرعات هي دخله الوحيد. + ادعم التطوير + + تم + + تكرار + + اللون الديناميكي + + تعديل + + قفل التحرير + + لا توجد ملاحظات بعد + اضغط «ملاحظة جديدة» لتكتب ملاحظتك الأولى. + تبقى الملاحظات المحذوفة هنا إلى أن تستعيدها أو تحذفها نهائيًا. + + أدخل رابطًا + + تعذّر فتح قاعدة البيانات + + توسيع + + تعذّر تحميل المحتوى + + يحفظ Encly تغييراتك، وبعد المهلة المحددة في الإعدادات ← الأمان (15 ثانية افتراضيًا) يغلق قاعدة البيانات المشفرة ويمحو مفتاح الجلسة. وإطفاء الشاشة أو قفل الهاتف يفعل ذلك فورًا. بعدها تفتح القفل من جديد. + ماذا يحدث عند مغادرة التطبيق؟ + استخدم مفتاح القياسات الحيوية في الإعدادات ← الأمان. يتطلب إنشاء خانة القياسات الحيوية أو إزالتها مصادقة قوية بالقياسات الحيوية. + كيف أُشغّل القياسات الحيوية أو أوقفها؟ + افتح الإعدادات ← الأمان، وأكّد رمز PIN الحالي، ثم أدخل الرمز الجديد مرتين. + كيف أغيّر رمز PIN؟ + ينشئ Encly مفتاحًا عشوائيًا لقاعدة البيانات طوله 256 بت. يحمي رمز PIN خانة فتح منفصلة بتشفير AES-GCM، وتستخدم القياسات الحيوية مفتاحًا في Android Keystore يتطلب مصادقة عند كل استخدام. + كيف يُحمى مفتاح قاعدة البيانات؟ + في شاشة القفل اضغط «استعادة الوصول» وأدخل كلماتك الـ12. إن كان رمز PIN يعمل بعدُ فيمكنك الاحتفاظ به أو تعيين رمز PIN جديد، وإلا فتعيّن رمز PIN جديدًا فورًا. بدون عبارة الاسترداد لا توجد وسيلة أخرى للدخول: لا أحد، ولا حتى المطوّر، يستطيع إعادة تعيينها. + ماذا لو نسيت رمز PIN؟ + افتح الإعدادات ← اللغة واختر إحدى اللغات المتاحة، أو «افتراضي النظام» لاتّباع لغة جهازك. يُطبَّق التغيير فورًا. + كيف أغيّر اللغة؟ + اضغط «ملاحظة جديدة» في الشاشة الرئيسية. يضيف شريط الأدوات فوق لوحة المفاتيح العناوين وقوائم التحقق والقوائم النقطية والمرقّمة والاقتباسات والروابط والفواصل. + كيف أنشئ ملاحظة جديدة؟ + هي مفتاح احتياطي من 12 كلمة. تفتح الخزنة إن نسيت رمز PIN، وهي المفتاح الوحيد لملفات نسخك الاحتياطية. تنشئها أثناء الإعداد، أو لاحقًا في الإعدادات ← الأمان إن تخطيتها. احتفظ بها على الورق، دون اتصال. + ما فائدة عبارة الاسترداد؟ + افتح «حول» واستخدم خيار الاقتراحات والبلاغات، أو راسل المطوّر. لا تُدرج أبدًا عبارة الاسترداد أو رمز PIN أو نص ملاحظاتك الخاصة في بلاغ الخلل. + كيف أبلّغ عن خلل؟ + تلقائيًا أثناء الكتابة، ومغادرة المحرر تُنهي آخر حفظ. وإن فشل الحفظ يومًا ما يعرض المحرر «لم يُحفظ» باللون الأحمر. + كيف تُحفظ التغييرات؟ + الملاحظات والمهام والتشفير المحلي واستعادة الوصول، بلا مزامنة سحابية. + إجابات عن أكثر الأسئلة شيوعًا + ليس افتراضيًا. حماية الشاشة مفعّلة: يمنع Encly لقطات الشاشة وتسجيلها والمعاينات غير المحمية في قائمة التطبيقات الأخيرة. وعندما تحتاج إلى لقطة، يرفع الحظر خيار «السماح بلقطات الشاشة حتى يُقفل التطبيق» في الإعدادات ← الأمان بعد إدخال رمز PIN؛ وتبقى شاشات القفل ورمز PIN وعبارة الاسترداد محمية، ويتوقف السماح من تلقاء نفسه عندما يُقفل Encly. + هل يمكنني التقاط لقطات شاشة لـ Encly؟ + الأساسيات + الأمان والاسترداد + الإعدادات + استكشاف الأخطاء وإصلاحها + تُخزَّن الملاحظات والوسوم والمهام فقط في قاعدة بيانات SQLCipher المحلية الخاصة بـ Encly. لا توجد مزامنة سحابية ولا نسخ احتياطي تلقائي: يمكنك بنفسك تصدير نسخة احتياطية مشفرة من الإعدادات ← النسخ الاحتياطي، أو نسخة Markdown مقروءة غير مشفرة. + أين تُخزَّن بياناتي؟ + تجمع الوسوم ملاحظاتك. يمكنك إنشاؤها وإعادة تسميتها وإخفاؤها وحذفها، وسحبها إلى أي ترتيب، وتصفية الشاشة الرئيسية حسب الوسم. + كيف تعمل الوسوم؟ + للمهام وصف وأولوية. يمكنك تصفيتها حسب الأولوية ووضع علامة «تم» عليها. لا يضع Encly نص المهام في إشعارات النظام أبدًا. + ماذا يمكن أن تفعل المهام؟ + في الإعدادات ← المظهر اختر إحدى السمات الخمس، والوضع الفاتح أو الداكن أو وضع النظام، وأحد أنماط الخطوط الثلاثة، وحجم النص. وعلى Android 12 فما فوق يمكنك أيضًا أخذ لون التمييز من خلفية الشاشة. + كيف أغيّر السمة؟ + يتعمد Encly ألا ينشئ قاعدة بيانات فارغة فوق خزنة مفقودة أو غير قابلة للقراءة. لا تؤكد المسح الكامل إن كنت لا تزال بحاجة إلى بياناتك المحلية. + يقول Encly إن الخزنة تالفة + + حجم النص + تباعد الأسطر + افتراضي + مضغوط + مريح + تحريري + عصري + تقني + + اختر اللغة + افتراضي النظام + + إضافة رابط + + عنصر القائمة + + العودة إلى رمز PIN + استعادة الوصول + أدخل عبارة الاسترداد المكونة من 12 كلمة + الاسترداد + أدخل رمز PIN للمتابعة + افتح قفل ملاحظاتك + جارٍ فتح القفل… + استعد الوصول بعبارة الاسترداد + رمز PIN خاطئ + عبارة الاسترداد خاطئة + + اضغط مطولًا لمسح كل شيء + يبدو أن البيانات المشفرة أو مفاتيح التشفير قد تغيّرت أو تلفت، لذا لا يمكن الاسترداد.\n\nلمواصلة استخدام التطبيق عليك إعادة تعيينه ومسح البيانات التالفة. سيؤدي ذلك إلى حذف كل الملاحظات والإعدادات المحفوظة نهائيًا. + عذرًا، بياناتك تالفة + هذه البيانات من إصدار أقدم + عثر Encly على ملاحظات حُفظت بإصدار سابق للإصدار 2.0. يستخدم Encly 2.0 تنسيقًا مشفرًا جديدًا ولا يستطيع فتحها، ولا توجد طريقة لتحويلها.\n\nلاستخدام التطبيق ابدأ من جديد. سيؤدي ذلك إلى حذف الملاحظات والإعدادات القديمة على هذا الهاتف نهائيًا. + اضغط مطولًا للبدء من جديد + + الأسئلة الشائعة + الإعدادات + ادعم المطوّر + الوسوم + المهام + سلة المحذوفات + + خيارات إضافية + + بلا وسم + + ملاحظة جديدة + تعذّر حذف الملاحظة + الوصف… + تجاهل كل التغييرات + تعذّر تجاهل التغييرات + تعذّر إنشاء نسخة + تعذّرت قراءة محتوى الملاحظة. عُطّل التحرير حتى لا تتضرر البيانات. + %1$s (نسخة) + تعذّرت استعادة الملاحظة + تعذّر حفظ التغييرات + تعذّر نقل الملاحظة إلى سلة المحذوفات + + تعذّرت تهيئة التطبيق + تعذّر إنشاء عبارة الاسترداد + تعذّر إنشاء الخزنة المشفرة + + فتح القائمة + + %1$d%% + + أدخل رمز PIN مرة أخرى للتأكيد + أكّد رمز PIN + أكّد رمز PIN الحالي قبل تغيير مفتاح الدخول + أدخل رمز PIN الحالي + من 6 إلى 12 رقمًا لحماية ملاحظاتك + تم تغيير رمز PIN + أنشئ رمز PIN + رمز PIN الحالي غير صحيح + حذف رقم + الرمزان غير متطابقين. حاول مرة أخرى. + تعذّر حفظ رمز PIN + تعذّر تحديث رمز PIN. حاول مرة أخرى. + + اضغط لبدء التحرير… + + عالية + منخفضة + متوسطة + اختر أولوية + + اقتباس + إعادة + + استعادة + + حفظ + + مسح البحث + + بصمة الإصبع + فتح القفل بالقياسات الحيوية + غير متاحة على هذا الجهاز + لا توجد بصمة مسجّلة. أضف واحدة من إعدادات النظام. + التشفير مفعّل + غير معدّة. من دونها يؤدي نسيان رمز PIN إلى فقدان كل شيء. اضغط لإنشائها. + مسح كل البيانات + احذف كل الملاحظات والمهام والوسوم ومفاتيح التشفير على هذا الجهاز + ستُحذف كل الملاحظات والمهام والوسوم ومفاتيح التشفير من هذا الجهاز، وسيبدأ Encly من جديد. لا يمكن التراجع عن ذلك: صدّر نسخة احتياطية أولًا إن أردت الاحتفاظ بأي شيء. + التشفير وحماية البيانات + الأمان + + المظهر + عام + اللغة + الخصوصية + إظهار المهام + إظهار بطاقة المهام في الشاشة الرئيسية + التحرير البسيط + أوقف الكتل الديناميكية وحرّر الملاحظات كنص عادي + + ترتيب حسب: + حسب تاريخ الإنشاء (الأقدم أولًا) + الإنشاء ↑ + حسب تاريخ الإنشاء (الأحدث أولًا) + الإنشاء ↓ + حسب العنوان (أ إلى ي) + العنوان أ–ي + حسب العنوان (ي إلى أ) + العنوان ي–أ + حسب آخر تعديل (الأقدم أولًا) + التعديل ↑ + حسب آخر تعديل (الأحدث أولًا) + التعديل ↓ + ادعم المطوّر + + تعذّر حفظ الوسم. حاول مرة أخرى. + تعذّر حذف الوسم. حاول مرة أخرى. + تعديل الوسم + وسم مخفي + يوجد وسم بهذا الاسم بالفعل + تعذّر حفظ ترتيب الوسوم. حاول مرة أخرى. + تعذّر تحديث الوسم. حاول مرة أخرى. + + اضغط مرة أخرى للحذف + + مهمة جديدة + تعذّر مسح المهام المكتملة. حاول مرة أخرى. + هل تريد حقًا حذف كل المهام المكتملة؟ لا يمكن التراجع عن ذلك. + مسح المهام المكتملة + اكتملت: %1$s + تعذّر حفظ المهمة. حاول مرة أخرى. + الوصف + تعديل المهمة + لا توجد مهام نشطة. + لا توجد مهام تطابق عوامل التصفية المحددة. + كل المهام + المكتملة + مهمة جديدة + التقدم + تعذّر تغيير حالة المهمة. حاول مرة أخرى. + تعذّر تحديث المهمة. حاول مرة أخرى. + + نص + + السمة + داكن + فاتح + النظام + + تراجع + + بلا عنوان + + التبديل إلى عرض الشبكة + التبديل إلى عرض القائمة + + + هل تريد حذف %d ملاحظة؟ + هل تريد حذف %d ملاحظة؟ + هل تريد حذف %d ملاحظتين؟ + هل تريد حذف %d ملاحظات؟ + هل تريد حذف %d ملاحظة؟ + هل تريد حذف %d ملاحظة؟ + + + محاولات كثيرة جدًا. أعد المحاولة بعد %d ثانية + محاولات كثيرة جدًا. أعد المحاولة بعد %d ثانية + محاولات كثيرة جدًا. أعد المحاولة بعد %d ثانيتين + محاولات كثيرة جدًا. أعد المحاولة بعد %d ثوانٍ + محاولات كثيرة جدًا. أعد المحاولة بعد %d ثانية + محاولات كثيرة جدًا. أعد المحاولة بعد %d ثانية + + + المهام المكتملة: %1$d من %2$d + المهام المكتملة: %1$d من %2$d + المهام المكتملة: %1$d من %2$d + المهام المكتملة: %1$d من %2$d + المهام المكتملة: %1$d من %2$d + المهام المكتملة: %1$d من %2$d + + النسخ الاحتياطي + نسخ احتياطي مشفر، واستيراد، وMarkdown + النسخ الاحتياطي + مشفرة بعبارة الاسترداد + تحتوي النسخة الاحتياطية على كل ملاحظاتك ومهامك ووسومك، مشفرة بعبارة الاسترداد المكونة من 12 كلمة. لا يرفعها Encly أبدًا: أنت من يختار مكان حفظ الملف. وعلى هاتف جديد تستعيدها الكلمات الـ12 نفسها. + آخر تصدير: %1$s + لم تُصدَّر أي نسخة احتياطية بعد + تصدير نسخة احتياطية + استيراد نسخة احتياطية + استعادة الملاحظات والمهام والوسوم من ملف نسخة احتياطية + تم حفظ النسخة الاحتياطية + أكّد أنك أنت + أدخل رمز PIN للمتابعة + عبارة الاسترداد + تُشفَّر النسخ الاحتياطية بعبارة استرداد، فلا يفتحها إلا كلماتها الـ12. لا تملك هذه الخزنة واحدة بعد. أنشئها الآن: فهي تتيح لك أيضًا فتح هذه الخزنة إن نسيت رمز PIN. + إنشاء عبارة الاسترداد + اكتب هذه الكلمات الـ12 على الورق بالترتيب واحتفظ بها دون اتصال. كل من يملكها يستطيع فتح نسخك الاحتياطية. + كتبتها + هذه الكلمات لا تتطابق. راجع ما كتبته. + تم حفظ عبارة الاسترداد + أكّد عبارة الاسترداد + أدخل عبارة الاسترداد المكونة من 12 كلمة لهذه الخزنة مرة واحدة، ليتمكن Encly من تشفير النسخ الاحتياطية بها. + أدخل عبارة الاسترداد + أدخل الكلمات الـ12 لعبارة الاسترداد التي أُنشئت بها هذه النسخة الاحتياطية. + فك التشفير + استيراد نسخة احتياطية + دمج + استبدال + استبدال كل البيانات؟ + ستُحذف كل ملاحظة ومهمة ووسم في هذه الخزنة وتُستبدل بالنسخة الاحتياطية. لا يمكن التراجع عن ذلك. + تم الاستيراد: ملاحظات %1$d، مهام %2$d، وسوم %3$d. تم التخطي (موجودة بالفعل): %4$d. + هذا الملف ليس نسخة احتياطية من Encly + أُنشئت هذه النسخة الاحتياطية بإصدار أحدث من Encly. حدّث التطبيق وحاول مرة أخرى. + ملف النسخة الاحتياطية تالف أو غير مكتمل + الملف أكبر من أن يكون نسخة احتياطية من Encly + خزنتك أكبر من أن تتسع في ملف نسخة احتياطية واحد (64 ميغابايت). لم يُحفظ شيء. + هذا الملف موجود بالفعل. اختر اسمًا جديدًا: لا تُستبدل نسخة احتياطية أقدم أبدًا. + لا يوجد تطبيق على هذا الجهاز يستطيع فتح الملفات أو حفظها. + هذه الكلمات لا تفتح هذه النسخة الاحتياطية، أو أن الملف عُدّل + أدخل عبارة استرداد صالحة من 12 كلمة + قُفلت الخزنة. افتح قفلها وحاول مرة أخرى. + تعذّرت قراءة النسخة الاحتياطية أو كتابتها. لم يتغير شيء. + هذه الكلمات ليست عبارة الاسترداد لهذه الخزنة + تعذّر حفظ عبارة الاسترداد + الاستعادة من نسخة احتياطية + اختر ملف نسخة احتياطية + تعذّرت استعادة النسخة الاحتياطية، لذا لم يكتمل الإعداد بعد. حاول مرة أخرى، أو تابع بخزنة فارغة واستورد الملف لاحقًا من الإعدادات ← النسخ الاحتياطي. + لم تُستعد النسخة الاحتياطية + حاول مرة أخرى + المتابعة بدونها + لا يستطيع هذا الإصدار من Encly قراءة هذه الملاحظة. + فتح الرابط + نسخ الرابط + تعديل الرابط + تم نسخ الرابط + لا يوجد تطبيق على هذا الجهاز يستطيع فتح هذا الرابط. + لا يمكن حفظ أو فتح إلا روابط الويب (http وhttps) والبريد الإلكتروني. + تعذّرت قراءة ملاحظاتك + تعذّرت قراءة وسومك + ما زالت ملاحظاتك مخزنة مشفرة على هذا الجهاز. أغلق Encly وافتحه مرة أخرى لإعادة المحاولة. + فتحت Encly بعبارة الاسترداد. عيّن رمز PIN جديدًا للمتابعة. + حذف المهمة + تم حذف المهمة + تعذّر حذف المهمة. حاول مرة أخرى. + الوضع + الخط + ورق + غابة + محيط + جرافيت + منتصف الليل + يتبع إعداد النظام. المعروض: فاتح. + يتبع إعداد النظام. المعروض: داكن. + فاتح دائمًا. + داكن دائمًا. + منتصف الليل داكن دائمًا. + خذ لون التمييز من خلفية الشاشة. Android 12 فما فوق. + اللون الديناميكي مفعّل: يأتي لون التمييز من خلفية الشاشة. + Playfair Display وSource Sans 3 + Poppins وSource Sans 3 + IBM Plex Sans في كل مكان + قائمة معاينة الشقة + قبل الزيارة: تحقق من ضغط المياه، واسأل كم كلّفت التدفئة الشتاء الماضي. + شخصي + معاينة + السمة والوضع والخط + صحيح + فتح القفل ببصمة الإصبع + الخطوة %1$d من %2$d + دفتر ملاحظات لا يفتحه غيرك. + ملاحظات ومهام مشفرة على هذا الهاتف. يستغرق الإعداد دقيقة تقريبًا. + بلا حساب + بلا تسجيل ولا بريد إلكتروني ولا إعادة تعيين كلمة مرور. + يعمل دون اتصال + لا يتصل Encly بالإنترنت أبدًا. + مشفر على الجهاز + خزنتك مقفلة بمفتاح لا يملكه سواك. + ابدأ + لديّ نسخة احتياطية + اختر رمز PIN + من 6 إلى 12 رقمًا. يفتح Encly ويحمي مفتاح خزنتك. لا يوجد بريد إلكتروني لإعادة تعيينه. + أدخل رمز PIN مرة أخرى + لنتأكد أنه الرمز الذي قصدته. + يبقى رمز PIN يعمل كبديل + اثنتا عشرة كلمة تعيد إليك كل شيء + سيعطيك Encly عبارة استرداد. اكتبها على الورق؛ فهي المفتاح الاحتياطي الوحيد. + نسيت رمز PIN؟ + تفتح العبارة خزنتك وتتيح لك تعيين رمز PIN جديد. + تفتح نسخك الاحتياطية + لا يمكن قراءة ملفات النسخ الاحتياطية المشفرة إلا بهذه الكلمات. + هاتف جديد + ملف النسخة الاحتياطية مع العبارة يستعيدان كل ملاحظة ومهمة ووسم. + لا أحد يستطيع استرجاعها لك، ولا حتى نحن. إن فقدت رمز PIN والعبارة معًا ضاعت الملاحظات. + أنشئ عبارتي + تخطَّ: رمز PIN فقط، بلا نسخ احتياطية + اكتب هذه الكلمات + على الورق، بهذا الترتيب. لا في لقطة شاشة ولا في السحابة. + إخفاء الكلمات + إظهار الكلمات + كتبتها + تحقق من ثلاث كلمات + اكتبها من نسختك الورقية، لا من الذاكرة. + الكلمة رقم %1$d + أدخل الكلمة + هذه ليست الكلمة رقم %1$d + تأكيد + أظهر الكلمات مرة أخرى + خزنتك جاهزة + كل ما تكتبه من الآن يُشفَّر قبل أن يُحفظ. + تم تعيين رمز PIN + يُطلب عند كل فتح لـ Encly + فتح القفل ببصمة الإصبع مفعّل + جرّب البصمة مرة أخرى + غيّره من الإعدادات، الأمان + تم التحقق من عبارة الاسترداد + احتفظ بالورقة في مكان آمن + لا توجد عبارة استرداد + النسخ الاحتياطية متوقفة. يمكنك إنشاء عبارة لاحقًا من الإعدادات، الأمان. + تمت استعادة النسخة الاحتياطية + عادت ملاحظاتك ومهامك ووسومك + نصيحة: صدّر نسخة احتياطية مشفرة بين حين وآخر من الإعدادات، النسخ الاحتياطي. عبارتك هي ما يفتحها. + افتح دفتر ملاحظاتي + استعد ثم عيّن رمز PIN + اختر ملف النسخة الاحتياطية من Encly، ثم أدخل الكلمات الـ12 التي أُنشئ بها. + 1 · ملف النسخة الاحتياطية + اضغط لاختيار ملف آخر + 2 · عبارة الاسترداد + تصبح هذه الكلمات أيضًا عبارة الاسترداد لهذا الهاتف. + الملاحظات + اقفل الآن + ابحث في العناوين والنصوص + خيارات العرض + لا شيء يطابق «%1$s» + اليوم + أمس + + المهام · المفتوحة: %d + المهام · المفتوحة: %d + المهام · المفتوحة: %d + المهام · المفتوحة: %d + المهام · المفتوحة: %d + المهام · المفتوحة: %d + + المهام · لا شيء مفتوح + لا توجد مهام بعد + اضغط «مهمة جديدة» لإضافة أولى مهامك. + تم كل شيء + المنجزة · %1$d + لا توجد ملاحظات بالوسم «%1$s» + تظهر هنا الملاحظات التي تمنحها هذا الوسم. + إظهار كل الملاحظات + سلة المحذوفات فارغة + اضغط على ملاحظة لقراءتها. اضغط مطولًا للتحديد. + حذف نهائيًا + تفريغ السلة + بحث في الملاحظات + الملاحظات والمهام + الإصدار وسياسة الخصوصية والتواصل + إجابات عن أسئلة شائعة + تراخيص المصادر المفتوحة + المكتبات والخطوط التي يستخدمها Encly + مبني على المصادر المفتوحة + صُنع Encly بهذه المكتبات والخطوط. اضغط على أحدها لقراءة ترخيصه. + المكتبات + الخطوط + لا توجد وسوم بعد + تجمع الوسوم ملاحظاتك. أنشئ وسمًا أعلاه، ثم اخترْه لملاحظة من قائمة الضغط المطوّل. + اضغط على وسم لإعادة تسميته أو إخفائه أو حذفه. اسحب المقبض لإعادة الترتيب. + إضافة وسم + الاسم + إظهار في قائمة الملاحظات + الوسم المخفي يحتفظ بملاحظاته لكن لا تظهر له شريحة تصفية + اسحب لإعادة الترتيب + نقل لأعلى + نقل لأسفل + + المحدد: %d + المحدد: %d + المحدد: %d + المحدد: %d + المحدد: %d + المحدد: %d + + تعديل الوسوم + العنوان + لم يُحفظ + إدارة الوسوم + تجاهل التغييرات؟ + ستضيع كل تغييراتك منذ فتحت هذه الملاحظة. لا يمكن التراجع عن ذلك. + كل شيء لك بالفعل + كل الميزات في التطبيق للجميع. لا شيء مقفل خلف الدفع. + بلا إعلانات ولا تتبّع + لا يرى Encly ملاحظاتك ولا يربح شيئًا من البيانات. التبرعات هي الشيء الوحيد الذي يموّله. + أين تذهب الأموال + أجهزة اختبار (التالي: Mac mini للبناء والاختبار) ووقت للإصدارات والإصلاحات. + بطاقة أو PayPal، مرة واحدة أو شهريًا + يفتح ko-fi.com في متصفحك. لا يتعامل Encly نفسه مع المدفوعات أبدًا. + ماذا يفعل الضغط المطوّل على ملاحظة؟ + يفتح إجراءات الملاحظة: تغيير وسمها، وإضافة وصف قصير يظهر على البطاقة، وتكرار الملاحظة، أو نقلها إلى سلة المحذوفات. + كيف أستعيد ملاحظة محذوفة؟ + تذهب الملاحظات المحذوفة إلى «سلة المحذوفات» في القائمة. ومنها يمكنك استعادة الملاحظة أو حذفها نهائيًا. لا يفرغ Encly السلة من تلقاء نفسه. + ماذا يفعل «قفل التحرير»؟ + يجعل الملاحظة للقراءة فقط حتى لا تتغير بالخطأ. ما زال بإمكانك تحديد النص ونسخه. شغّله أو أوقفه من قائمة المحرر. + كيف تعمل النسخ الاحتياطية؟ + في الإعدادات ← النسخ الاحتياطي تصدّر ملفًا واحدًا مشفرًا لا تفتحه إلا عبارة الاسترداد. وللاستعادة اختر الملف واكتب الكلمات الـ12 أو الصقها، ثم ادمجه في هذه الخزنة أو استبدل كل شيء. + إظهار لوحة المفاتيح + إخفاء لوحة المفاتيح + مسح كل البيانات؟ + تعذّر مسح البيانات. لم يتغير شيء. + مسح + المهام الفرعية + مهمة فرعية + إضافة مهمة فرعية + حذف المهمة الفرعية + كل المهام الفرعية منجزة + إكمال المهمة + إظهار المهام الفرعية + إخفاء المهام الفرعية + موسّعة + مطوية + تم حذف المهمة الفرعية + الخطوة التالية: %1$s + الخطوة الأولى + + المهام الفرعية المنجزة: %1$d من %2$d + المهام الفرعية المنجزة: %1$d من %2$d + المهام الفرعية المنجزة: %1$d من %2$d + المهام الفرعية المنجزة: %1$d من %2$d + المهام الفرعية المنجزة: %1$d من %2$d + المهام الفرعية المنجزة: %1$d من %2$d + + إدراج كتلة + بحث في الملاحظة + ابحث في هذه الملاحظة + %1$d من %2$d + لا توجد نتائج + النتيجة التالية + النتيجة السابقة + إغلاق البحث + تكرار الكتلة + ابدأ من قالب + فارغ + اجتماع + يوميات + ملاحظات مشروع + الحضور + جدول الأعمال + ملاحظات + بنود العمل + اليوم + الامتنان + الغد + الهدف + المهام + القرارات + أسئلة مفتوحة + المطلوب + تثبيت + إلغاء التثبيت + أرشفة + إلغاء الأرشفة + المثبّتة + أخرى + الأرشيف + الأرشيف فارغ + تغادر الملاحظات المؤرشفة قائمتك والبحث لكنها تبقى في الخزنة. أرشِف ملاحظة من قائمتها. + تعذّر نقل الملاحظة إلى الأرشيف + تعذّر إخراج الملاحظة من الأرشيف + اللون + بلا لون + أحمر + برتقالي + أصفر + أخضر + أزرق مخضر + أزرق + بنفسجي + وردي + تصدير بصيغة Markdown + نسخ بصيغة Markdown + تم النسخ بصيغة Markdown + تصدير مقروء + احفظ كل الملاحظات والمهام كملفات .md مقروءة داخل ملف .zip. الملف غير مشفر. + التصدير دون تشفير؟ + الملف المُصدَّر غير مشفر. كل من يحصل عليه يستطيع قراءة ملاحظاتك. وأي مكان تحفظه فيه، بما في ذلك مجلد سحابي، سيحتوي على نسخة مقروءة لا يستطيع Encly حمايتها ولا استردادها. ستؤكد برمز PIN بعد ذلك. + تم حفظ تصدير Markdown + هل يمكنني تصدير ملاحظاتي بصيغة Markdown؟ + نعم. في قائمة الملاحظة اختر «تصدير بصيغة Markdown» للحصول على ملف .md واحد، أو استخدم الإعدادات ← النسخ الاحتياطي ← «تصدير بصيغة Markdown» للحصول على ملف .zip لكل الملاحظات (المؤرشفة في مجلد أرشيف، وسلة المحذوفات مستثناة) وملف للمهام. هذه الملفات غير مشفرة: ينبّهك Encly أولًا ويطلب رمز PIN ويحفظ حيث تختار فقط. + إجراءات السحب + السحب على الملاحظات + اسحب بطاقة الملاحظة لتنفيذ إجراء عليها + السحب لليسار + السحب لليمين + تثبيت أو إلغاء التثبيت + اللون التالي + نقل إلى سلة المحذوفات + لا شيء + النقل إلى سلة المحذوفات؟ + يمكنك استعادة الملاحظة من سلة المحذوفات. + ماذا يفعل السحب على ملاحظة؟ + افتراضيًا، سحب الملاحظة لليسار يثبّتها أو يلغي تثبيتها، وسحبها لليمين يؤرشفها. في الإعدادات ← إجراءات السحب يمكنك اختيار التثبيت أو الأرشفة أو اللون التالي أو النقل إلى سلة المحذوفات (يسأل دائمًا أولًا) أو لا شيء لكل اتجاه، أو إيقاف السحب. يقدّم TalkBack الإجراءات نفسها في قائمة إجراءات الملاحظة. + النص في الملاحظات + الافتراضي لكل ملاحظة. يمكن لكل ملاحظة تحديد إعداداتها من قائمتها. + استخدام إعدادات النص الافتراضية + الشيفرة المصدرية + Encly مفتوح المصدر: اقرأ الشيفرة على GitHub + فتح + فتح في تبويب جديد + اختر ملاحظة أو ابدأ ملاحظة جديدة + ملف + عرض + نافذة + مساعدة + خروج + تصغير + نافذة جديدة + فتح في نافذة جديدة + تصدير بصيغة PDF + طباعة + التصدير إلى PDF دون تشفير؟ + ملف PDF غير مشفر. كل من يحصل عليه يستطيع قراءة ملاحظتك. وأي مكان تحفظه فيه، بما في ذلك مجلد سحابي، سيحتوي على نسخة مقروءة لا يستطيع Encly حمايتها ولا استردادها. ستؤكد برمز PIN بعد ذلك. + الطباعة دون تشفير؟ + ترسل الطباعة نص ملاحظتك إلى الطابعة دون تشفير. وقد يحتفظ مُجمِّع الطباعة في النظام بنسخ مؤقتة منه، وقد تخزنه طابعة الشبكة أو السحابة أيضًا. لا يستطيع Encly إزالتها. ستؤكد برمز PIN بعد ذلك. + تم حفظ PDF + أُرسل إلى الطابعة + هذه الملاحظة مفتوحة للتحرير في نافذة أخرى. هنا هي للقراءة فقط. + ما الجديد + التغييرات في هذا الإصدار + فهمت + تُحذف الملاحظات نهائيًا بعد 28 يومًا في سلة المحذوفات. + تحديد الكتلة + إعادة ترتيب العنصر + نقل العنصر لأعلى + نقل العنصر لأسفل + زيادة إزاحة العنصر + تقليل إزاحة العنصر + تحديد الكل + إلغاء التحديد + تم النسخ + نسخ + الكتل المحددة: %1$d + قص + حذف الكتل المحددة + ما طول رمز PIN المسموح به؟ + من 6 إلى 12 رقمًا بحسب اختيارك. لا تُعرض رموز أقصر: فالقفل المؤقت ومفتاح الجهاز يحدّان من عدد المحاولات، و6 أرقام هي أقصر طول يبقى آمنًا مع هذه الحدود. لا تُظهر شاشة القفل طول رمزك أبدًا: تضغط «فتح القفل» عند الانتهاء، والأمر نفسه ينطبق على رمز المسح ورمز الإكراه. تبقى الرموز التي عيّنتها سابقًا تعمل كما هي. + كود + جدول + إضافة صف + إضافة عمود + حذف الصف + حذف العمود + صف الرأس + خيارات الخلية + شريط أدوات المحرر + اختر الأدوات ورتّبها + اسحب المقبض لإعادة الترتيب. تخفي العين الأداة أو تعيدها. أدوات «المزيد» خلف السهم. + إعادة تعيين + الشريط + المزيد + المخفية + إخفاء الأداة + إظهار الأداة + أعلى + أسفل + + %1$d وسم آخر + %1$d وسم آخر + %1$d وسمان آخران + %1$d وسوم أخرى + %1$d وسمًا آخر + %1$d وسم آخر + + diff --git a/core/resources/src/commonMain/strings/values-ar/strings_handoff.xml b/core/resources/src/commonMain/strings/values-ar/strings_handoff.xml new file mode 100644 index 00000000..a1a75618 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-ar/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + الاستيراد من My Notes + جارٍ قراءة ملاحظاتك من My Notes… + انقل ملاحظاتك إلى Encly + يسلّم My Notes على هذا الهاتف %1$s و%2$s و%3$s. تُضاف إلى خزنتك المشفرة: لا شيء هنا يُستبدل، وأي شيء استُورد من قبل يُتخطى. + المرفقات التي تبقى في My Notes: %1$d. لا يستورد Encly الصور ولا الملفات. + الملاحظات المثبّتة التي لن تبقى مثبّتة: %1$d. لا يدعم Encly التثبيت. + استيراد الملاحظات + جارٍ الاستيراد… + تم الاستيراد + كل شيء ما زال في My Notes أيضًا. بعد أن تتحقق من ملاحظاتك هنا يمكنك مسحه هناك. + لم يُستورد شيء + أرسل My Notes ملاحظاته بتنسيق أحدث. حدّث Encly، ثم ابدأ النقل من جديد في My Notes. + تعذّرت قراءة البيانات القادمة من My Notes. + البيانات القادمة من My Notes أكبر مما يستطيع Encly استيراده دفعة واحدة. + حدث خطأ أثناء الاستيراد. حاول مرة أخرى من My Notes. + لا يستطيع Encly فتح خزنتك هنا. افتح Encly وتأكد من أنه معدّ ومن أن قفله يُفتح، ثم ابدأ النقل من جديد في My Notes. + diff --git a/core/resources/src/commonMain/strings/values-ar/strings_import.xml b/core/resources/src/commonMain/strings/values-ar/strings_import.xml new file mode 100644 index 00000000..e74bc237 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-ar/strings_import.xml @@ -0,0 +1,81 @@ + + + + الاستيراد من تطبيقات أخرى + استيراد الملاحظات + من Google Keep أو Standard Notes أو Joplin أو Notesnook أو ملفات Markdown + استورد ملاحظات من تطبيق آخر. اختر ملف التصدير الخاص به وسيتعرّف Encly على التنسيق بنفسه. كل شيء يبقى على هذا الهاتف؛ ولا يُرفع شيء. + اختر ملفات + اختر مجلدًا + التطبيقات المدعومة + طريقة التصدير + ما لا يُستورد + + Google Keep + ملف .zip من Google Takeout (اخترْه كما هو دون فك ضغطه) أو ملاحظات .json منفصلة + في Google Takeout حدّد Keep فقط، وأنشئ التصدير ونزّل ملف .zip. ثم اختر ملف .zip هذا هنا. + حالة التثبيت والأرشفة والألوان والمرفقات. لا يصبح وسمًا إلا التصنيف الأول. + Standard Notes + نسخة احتياطية غير مشفرة (.txt أو .json)، أو ملف .zip من تنزيل غير مشفر + في Standard Notes نزّل نسخة احتياطية غير مشفرة، لا مشفرة. ثم اختر الملف أو ملف .zip هنا. + تُرفض النسخ الاحتياطية المشفرة. حالة التثبيت والأرشفة والملفات وأنواع العناصر الأخرى. لا يُحتفظ إلا بالوسم الأول. + Joplin + أرشيف JEX (.jex) أو مجلد RAW أو تصدير Markdown + في Joplin اختر ملف ← تصدير الكل، ثم اختر JEX أو RAW. ثم اختر الملف أو المجلد هنا. + المرفقات، وحالة المهام (تصل المهام كملاحظات عادية). تأخذ الملاحظة وسمها الأول، وإلا اسم دفترها. + Notesnook + تصدير Markdown أو Markdown مع البيانات الأمامية أو TXT (.zip) + في Notesnook صدّر ملاحظاتك بصيغة Markdown مع البيانات الأمامية للحفاظ على التواريخ والوسوم. ثم اختر ملف .zip هنا. لا يمكن استيراد النسخ الاحتياطية المشفرة. + المرفقات وحالة التثبيت والأرشفة والألوان. يصبح الوسم الأول وسمًا، وإلا الدفتر. + Markdown والنصوص + ملفات .md و.txt أو مجلد كامل، مثلًا من Obsidian أو Markor + اختر الملفات أو المجلد الذي يحتويها. اسم الملف هو العنوان؛ وتُقرأ البيانات الأمامية البسيطة (العنوان والوسوم والتواريخ). + الصور والمرفقات والإضافات والتنسيق ما عدا العناوين والقوائم وقوائم التحقق والاقتباسات. يصبح المجلد الأول وسمًا. + + جارٍ قراءة ملفاتك والتعرّف عليها… + جاهز للاستيراد + تم التعرّف على: %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، وتم تخطي %2$d لأنها مكررة، وتعذّر استيراد %3$d. + لم تتغير ملفاتك الأصلية. احذفها بنفسك بعد أن تتحقق من ملاحظاتك هنا. + من أي تطبيق هذه الملفات؟ + تبدو الملفات قادمة من أكثر من تطبيق. اختر التطبيق المراد استيراده؛ وما عداه يُتخطى. + لم يُستورد شيء + نسخة Standard Notes الاحتياطية هذه مشفرة. نزّل نسخة احتياطية غير مشفرة من Standard Notes واخترها بدلًا منها. + هذه نسخة Notesnook احتياطية مشفرة. صدّر ملاحظاتك بصيغة Markdown من Notesnook بدلًا من ذلك واختر ملف .zip. + هذه الملفات ليست تصديرًا مدعومًا. راجع التطبيقات المدعومة، أو اختر ملفات Markdown أو نصية. + هذا الاختيار أكبر مما يستطيع Encly استيراده دفعة واحدة (64 ميغابايت من الملاحظات، أو 2 غيغابايت إجمالًا، أو 20,000 ملاحظة). استورده على أجزاء أصغر. + لم يُعثر على أي ملاحظات في هذا الاختيار. + تعذّرت قراءة الملفات المحددة. + لا يوجد على هذا الهاتف تطبيق لاختيار الملفات يستطيع Encly استخدامه. + أُغلقت الخزنة أثناء الاستيراد. افتح قفل Encly وحاول مرة أخرى. + حدث خطأ ولم يُستورد شيء. خزنتك لم تتغير. + تم تحديد %1$d من %2$d + تحديد الكل + إلغاء التحديد + الكل + الجديدة فقط + في سلة المحذوفات + بلا عنوان + قائمة تحقق + في سلة المحذوفات + موجودة في الخزنة + استيراد %1$d + اختر من جديد + diff --git a/core/resources/src/commonMain/strings/values-ar/strings_security.xml b/core/resources/src/commonMain/strings/values-ar/strings_security.xml new file mode 100644 index 00000000..b35877e1 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-ar/strings_security.xml @@ -0,0 +1,161 @@ + + + اكتب كلماتك الـ12 أو الصقها + الكلمة %1$d من %2$d + الكلمة %1$d ليست من كلمات عبارة الاسترداد. تحقق من الإملاء. + هذه الكلمات الـ12 لا تكوّن عبارة استرداد صالحة. تحقق من كل كلمة ومن ترتيبها. + لهذه الخزنة عبارة استرداد بالفعل + إضافتها إلى ملاحظاتك أم استبدالها؟ + تحتوي هذه النسخة الاحتياطية على %1$s و%2$s و%3$s. + احتفظ بكل ما هنا وأضف ما ينقص. العناصر الموجودة بالفعل تُتخطى. + استبدال كل شيء + احذف كل ملاحظة ومهمة ووسم على هذا الهاتف، ثم استعد النسخة الاحتياطية. + احتفظ بكل ملاحظاتك ومهامك ووسومك في ملف واحد مشفر، واستعدها على أي هاتف. + تصدير + استيراد + ملاحظاتك مشفرة. يفتحها رمز PIN (وبصمتك إن كانت مفعّلة). + ملاحظاتك مشفرة. يفتحها رمز PIN (وبصمتك إن كانت مفعّلة)، وتفتحها عبارة الاسترداد إن نسيت رمز PIN. + أُنشئت أثناء الإعداد. لا يخزّن Encly الكلمات ولا يستطيع عرضها مرة أخرى. لا تملكها؟ استبدل العبارة أدناه. + معدّة + بعد إعادة التعيين يمكنك استعادة ملف نسخة احتياطية بكلماته الـ12. + يتعذّر فتح خزنتك الآن + لم يستطع Encly قراءة بياناته الآن. قد يكون تطبيق آخر أو النظام يستخدمها، أو قد يكون الجهاز لا يزال مقفلًا. ملاحظاتك لم تضِع. حاول مرة أخرى بعد قليل. + حاول مرة أخرى + ما زال يتعذّر قراءتها. حاول مرة أخرى بعد قليل. + تغيير رمز PIN + + %d ملاحظة + %d ملاحظة + %d ملاحظتين + %d ملاحظات + %d ملاحظة + %d ملاحظة + + + %d مهمة + %d مهمة + %d مهمتين + %d مهام + %d مهمة + %d مهمة + + + %d وسم + %d وسم + %d وسمين + %d وسوم + %d وسمًا + %d وسم + + + لم يعد رمز PIN قادرًا على فتح Encly على هذا الهاتف: أُعيد تعيين مفتاحه العتادي. استخدم عبارة الاسترداد (أو بصمتك)، ثم عيّن رمز PIN جديدًا. + يُبلغ الجهاز أنه ما زال مقفلًا، لذلك بقي Encly مغلقًا. افتح قفل الجهاز ثم حاول مرة أخرى. + + محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقة + محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقة + محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقتين + محاولات كثيرة جدًا. أعد المحاولة بعد %d دقائق + محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقة + محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقة + + + محاولات كثيرة جدًا. أعد المحاولة بعد %d ساعة + محاولات كثيرة جدًا. أعد المحاولة بعد %d ساعة + محاولات كثيرة جدًا. أعد المحاولة بعد %d ساعتين + محاولات كثيرة جدًا. أعد المحاولة بعد %d ساعات + محاولات كثيرة جدًا. أعد المحاولة بعد %d ساعة + محاولات كثيرة جدًا. أعد المحاولة بعد %d ساعة + + استبدال عبارة الاسترداد + احصل على 12 كلمة جديدة. النسخ الاحتياطية التي أُنشئت قبلها تظل تحتاج إلى الكلمات القديمة. + استبدال عبارة الاسترداد؟ + ستحصل على 12 كلمة جديدة. ستتوقف العبارة القديمة عن فتح ملاحظاتك على هذا الهاتف. أما النسخ الاحتياطية التي أنشأتها بالفعل فلن تُفتح إلا بالعبارة القديمة، فاحتفظ بها ما دمت تحتفظ بتلك النسخ، أو أنشئ نسخة جديدة بعد ذلك. + احصل على كلمات جديدة + ستتوقف عبارتك القديمة عن العمل هنا + بمجرد أن تؤكد، لن يفتح ملاحظاتك إن نسيت رمز PIN إلا هذه الكلمات. أما النسخ الاحتياطية التي أُنشئت قبلها فتحتاج إلى الكلمات القديمة. + تم استبدال عبارة الاسترداد. أنشئ نسخة احتياطية جديدة لتستخدمها للنسخ الاحتياطية أيضًا. + الخصوصية + خصوصية صارمة للوحة المفاتيح + يطلب من لوحة المفاتيح ألا تقدم اقتراحات ولا تنبؤًا سحابيًا ولا قراءة لما حول المؤشر. يوقف التصحيح التلقائي، وقد تخفي بعض لوحات المفاتيح الرموز التعبيرية أو الإدخال الصوتي. + السماح بلقطات الشاشة حتى يُقفل التطبيق + تبقى لقطات الشاشة وتسجيلها محظورة ما لم تسمح بها هنا. يتوقف هذا من تلقاء نفسه عندما يُقفل Encly. + السماح بلقطات الشاشة؟ + قد تنتهي لقطات الشاشة في معرض الصور وفي النسخ الاحتياطية السحابية، وفيها ملاحظاتك. تبقى شاشة القفل وإدخال رمز PIN وعبارة الاسترداد محمية. ينتهي هذا عندما يُقفل Encly. + القفل بعد مغادرة التطبيق + المدة التي يبقى فيها Encly مفتوحًا عند التبديل إلى تطبيق آخر. إطفاء الشاشة أو قفل الهاتف يقفله دائمًا فورًا. + فورًا + بعد 15 ثانية + بعد 30 ثانية + بعد دقيقة + بعد دقيقتين + بعد 5 دقائق + بعد 10 دقائق + بعد 15 دقيقة + بعد 30 دقيقة + المهل الأطول تترك خزنتك مفتوحة في الخلفية. وقد يرى ملاحظاتك كل من يصل إلى هاتفك المفتوح. + كيف يحمي Encly ملاحظاتك + مفتاح عشوائي بطول 256 بت + قاعدة البيانات كلها مشفرة بمفتاح أُنشئ على هذا الهاتف. + رمز PIN مرتبط بهذا الهاتف + يُمزج رمز PIN بمفتاح في الشريحة الآمنة بالهاتف، فلا يمكن تخمينه في أي مكان آخر. + أقفال منفصلة لكل طريق دخول + يفتح رمز PIN وعبارة الاسترداد وبصمة الإصبع كلٌّ خانته المشفرة. وتحتاج البصمة إلى إصبعك في كل مرة. + منطقة الخطر + اضغط مطولًا لمدة 5 ثوانٍ للمسح + + رمز المسح + رمز PIN ثانٍ يمسح ملاحظاتك عند إدخاله في شاشة القفل + أُوقف عند إعادة تعيين مفتاح رمز PIN. عيّنه من جديد إن كنت تستخدم واحدًا. + إن أجبرك أحد على فتح Encly، فاكتب رمز المسح بدل رمز PIN. يمسح Encly ملاحظاتك ومهامك ووسومك دون أي إشارة، ويفتح كخزنة فارغة. يعمل فقط في شاشة القفل. + بعد المسح يفتح Encly برمز المسح: يصبح رمز PIN للخزنة الجديدة الفارغة. يتوقف رمز PIN القديم عن العمل، فمن رآك تكتب رمز المسح يستطيع فتح Encly به مرة أخرى ولا يرى شيئًا غير عادي. + ملفات النسخ الاحتياطية التي صدّرتها بالفعل لا تُمسّ. ما زالت تُفتح بعبارة الاسترداد وهي الطريق الوحيد للعودة. + يبقى فتح القفل بالقياسات الحيوية مفعّلًا: قد يضغط أحدهم بإصبعك على المستشعر قسرًا. أوقفه إن كان ذلك يمثل خطرًا عليك. + إيقاف فتح القفل بالقياسات الحيوية + يحميك عندما يجبرك أحد على فتح القفل، لا من فحص جنائي للهاتف. وأثناء الحظر المؤقت لا يمكن استخدام رمز المسح أيضًا. لا يعرض Encly أبدًا إن كان رمز المسح معيّنًا: الرمز الجديد يحل محل القديم، وتغيير رمز PIN إلى رمز المسح يوقفه. + تعيين رمز المسح + إيقاف رمز المسح + أكّد رمز PIN لإعداد رمز المسح + أنشئ رمز مسح + من 6 إلى 12 رقمًا، مختلفة عن رمز PIN + أكّد رمز المسح + أدخل رمز المسح مرة أخرى + يجب أن يختلف رمز المسح عن رمز PIN + تعذّر حفظ رمز المسح. حاول مرة أخرى. + تم تعيين رمز المسح + تم إيقاف رمز المسح + أُعيد تعيين مفتاح رمز PIN، فأُوقف أي رمز مسح كان لديك. عيّنه من جديد إن أردت واحدًا. + هذا الرمز مستخدم بالفعل. اختر رمزًا مختلفًا. + رمز الإكراه + رمز PIN ثانٍ يفتح خزنة منفصلة بملاحظاتها الخاصة + أُوقف عند إعادة تعيين مفتاح رمز PIN. عيّنه من جديد إن أردت واحدًا. + إن أجبرك أحد على فتح Encly، فاكتب رمز الإكراه بدل رمز PIN. يفتح Encly خزنة منفصلة فارغة يمكنك استخدامها كالمعتاد: الملاحظات التي تضيفها هناك تبقى فيها. + تبقى خزنتك الحقيقية مقفلة ولا تُمسّ. وعلى خلاف رمز المسح، لا يمسح رمز الإكراه شيئًا. وتفتح بصمتك وعبارة الاسترداد الخزنة الحقيقية دائمًا. + يحميك عندما يطلب منك أحد فتح التطبيق وإظهار ما فيه، لا من فحص جنائي للهاتف. وأثناء الحظر المؤقت لا يمكن استخدام رمز الإكراه أيضًا. + رمز الإكراه معيّن. + لا يوجد رمز إكراه معيّن. + تعيين رمز الإكراه + تغيير رمز الإكراه + إيقاف رمز الإكراه + أكّد رمز PIN لإعداد رمز الإكراه + أنشئ رمز إكراه + من 6 إلى 12 رقمًا، مختلفة عن رمز PIN + أكّد رمز الإكراه + أدخل رمز الإكراه مرة أخرى + يجب أن يختلف رمز الإكراه عن رمز PIN + تعذّر حفظ رمز الإكراه. حاول مرة أخرى. + تم تعيين رمز الإكراه. + تم تغيير رمز الإكراه. تحتفظ الخزنة الوهمية بملاحظاتها. + تم إيقاف رمز الإكراه + أُعيد تعيين مفتاح رمز PIN، فتوقف رمز الإكراه عن العمل. عيّنه من جديد؛ فالخزنة الوهمية تحتفظ بملاحظاتها. + إيقاف رمز الإكراه؟ + تُحذف الخزنة الوهمية والملاحظات التي فيها. أما خزنتك الحقيقية فلا تتأثر. + إيقاف + إظهار معاينات الملاحظات + تعرض قائمة الملاحظات أول أسطر كل ملاحظة. أوقفه لعرض العنوان والوسم والتاريخ فقط؛ ويظل البحث يجد النص الكامل. + الأرقام المُدخلة: %1$d + فتح القفل + تم قبول عبارة الاسترداد + رمز PIN لا يزال يعمل. احتفظ به أو عيّن رمزًا جديدًا. + الاحتفاظ برمز PIN الخاص بي + تعيين رمز PIN جديد + diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml new file mode 100644 index 00000000..4f3ab38f --- /dev/null +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -0,0 +1,722 @@ + + + Über die App + Ein lokaler, verschlüsselter Ort für Notizen, Ideen und Aufgaben – ohne Cloud-Synchronisierung. + Datenschutz + Die Tresordaten werden lokal in einer verschlüsselten SQLCipher-Datenbank gespeichert + Häufige Fragen + Feedback + Einen Fehler melden oder eine Verbesserung auf GitHub vorschlagen + Warum es wichtig ist und wie du über Ko-fi hilfst + Kein Browser gefunden. Öffne den Link manuell: %1$s + Keine E-Mail-App gefunden + Datenschutzerklärung + Wie wir deine Daten speichern und schützen + App bewerten + Gib eine Bewertung im Google Play Store ab + Support und Kontakt + Entwickler kontaktieren + Wenn du Fragen oder Vorschläge hast, kannst du mir eine E-Mail schreiben. + + Weiter + + Hinzufügen + + Alle + + 6 bis 12 Ziffern. Tippe, um deine PIN zu ändern. + PIN + Anmeldung + + Zurück + + Die Änderung des biometrischen Schutzes wurde nicht bestätigt + Der biometrische Schlüssel konnte nicht erstellt werden + Bestätige deine Identität + Authentifizierung + Bestätige deine Biometrie + Schlüsselzugriff + Bestätige deine Biometrie, um den Schutz zu ändern + Sicherheitsbestätigung + Bestätige deine Biometrie, um auf den verschlüsselten Schlüssel zuzugreifen + Encly entsperren + PIN verwenden + Biometrische Authentifizierung ist nicht verfügbar + + Weitere Block-Werkzeuge + Aufzählung + Checkliste + Überschrift + Überschrift %1$d + Link + Liste + Checkliste sperren + Checkliste entsperren + Block auswählen + Eintrag verschieben + Eintrag nach oben verschieben + Eintrag nach unten verschieben + Eintrag einrücken + Einrückung des Eintrags verringern + Ausgewählte Blöcke: %1$d + Auswahl aufheben + Kopieren + Ausschneiden + Ausgewählte Blöcke löschen + Alles auswählen + Kopiert + Block nach unten verschieben + Block nach oben verschieben + Nummerierte Liste + Trennlinie + + Abbrechen + + + Möchtest du diese Notizen wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden. + Möchtest du den Papierkorb wirklich endgültig leeren? Das kann nicht rückgängig gemacht werden. + Papierkorb leeren? + + Leeren + + Schließen + + Einklappen + + Neues Label erstellen + + Heute %1$s + Gestern %1$s + + Löschen + Block löschen + Löschen + Label löschen + Dieses Label wird aus allen deinen Notizen entfernt. Die Notizen selbst werden nicht gelöscht. + + Löschen + Abbrechen + Möchtest du diese Notiz wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden. + Notiz löschen? + + Encly ist kostenlos und Open Source, ohne Werbung, Tracking und Konten. Spenden sind die einzige Einnahmequelle. + Entwicklung unterstützen + + Fertig + + Duplikat + + Dynamische Farben + + Bearbeiten + + Bearbeitung sperren + + Noch keine Notizen + Tippe auf „Neue Notiz“, um die erste zu schreiben. + Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. + + Link eingeben + + Die Datenbank konnte nicht geöffnet werden + + Ausklappen + + Inhalt konnte nicht geladen werden + + Encly speichert deine Änderungen und schließt nach der unter Einstellungen → Sicherheit gewählten Verzögerung (standardmäßig 15 Sekunden) die verschlüsselte Datenbank und löscht den Sitzungsschlüssel. Bildschirm aus oder Telefon sperren tut das sofort. Danach entsperrst du erneut. + Was passiert, wenn ich die App verlasse? + Nutze den Biometrie-Schalter unter Einstellungen → Sicherheit. Das Anlegen oder Entfernen des biometrischen Slots erfordert eine starke biometrische Authentifizierung. + Wie aktiviere oder deaktiviere ich die Biometrie? + Öffne Einstellungen → Sicherheit, bestätige deine aktuelle PIN und gib dann zweimal die neue ein. + Wie ändere ich meine PIN? + Encly erzeugt einen zufälligen 256-Bit-Datenbankschlüssel. Deine PIN schützt einen separaten AES-GCM-Entsperrslot, und die Biometrie nutzt einen Android-Keystore-Schlüssel, der bei jeder Verwendung eine Authentifizierung erfordert. + Wie ist der Datenbankschlüssel geschützt? + Tippe auf dem Sperrbildschirm auf „Zugang wiederherstellen“ und gib deine 12 Wörter ein. Funktioniert deine PIN noch, kannst du sie behalten oder eine neue PIN festlegen; sonst legst du sofort eine neue PIN fest. Ohne Wiederherstellungsphrase gibt es keinen anderen Weg hinein: Niemand, auch nicht der Entwickler, kann sie zurücksetzen. + Was passiert, wenn ich meine PIN vergesse? + Öffne Einstellungen → Sprache und wähle eine der verfügbaren Sprachen oder „Systemstandard“, um der Sprache deines Geräts zu folgen. Die Änderung gilt sofort. + Wie ändere ich die Sprache? + Tippe auf dem Startbildschirm auf „Neue Notiz“. Die Leiste über der Tastatur fügt Überschriften, Checklisten, Aufzählungen, nummerierte Listen, Zitate, Links und Trennlinien ein. + Wie erstelle ich eine neue Notiz? + Sie ist ein Ersatzschlüssel aus 12 Wörtern. Sie entsperrt den Tresor, wenn du deine PIN vergisst, und ist der einzige Schlüssel zu deinen Backup-Dateien. Du erstellst sie bei der Einrichtung oder später unter Einstellungen → Sicherheit, falls du sie übersprungen hast. Bewahre sie auf Papier auf, offline. + Wofür ist die Wiederherstellungsphrase? + Öffne „Über die App“ und nutze die Feedback-Option oder schreibe dem Entwickler. Füge einem Fehlerbericht niemals deine Wiederherstellungsphrase, deine PIN oder private Notiztexte hinzu. + Wie melde ich einen Fehler? + Automatisch während du schreibst, und beim Verlassen des Editors wird die letzte Speicherung abgeschlossen. Schlägt eine Speicherung einmal fehl, zeigt der Editor „Nicht gespeichert“ in Rot. + Wie werden Änderungen gespeichert? + Notizen, Aufgaben, lokale Verschlüsselung und Zugangswiederherstellung – ohne Cloud-Synchronisierung. + Antworten auf die häufigsten Fragen + Standardmäßig nicht. Der Bildschirmschutz ist aktiv: Encly blockiert Screenshots, Bildschirmaufnahmen und ungeschützte Vorschauen in der Liste der zuletzt verwendeten Apps. Brauchst du einen, hebt Einstellungen → Sicherheit → „Screenshots erlauben, bis die App sperrt“ ihn nach deiner PIN auf; Sperr-, PIN- und Wiederherstellungsphrasen-Bildschirme bleiben geschützt, und die Freigabe endet, sobald Encly sperrt. + Kann ich Screenshots von Encly machen? + Grundlagen + Sicherheit und Wiederherstellung + Einstellungen + Fehlerbehebung + Notizen, Labels und Aufgaben werden ausschließlich in der lokalen SQLCipher-Datenbank von Encly gespeichert. Es gibt keine Cloud-Synchronisierung und keine automatischen Backups: Ein verschlüsseltes Backup kannst du selbst unter Einstellungen → Backup exportieren, außerdem eine lesbare Markdown-Kopie, die nicht verschlüsselt ist. + Wo werden meine Daten gespeichert? + Labels gruppieren deine Notizen. Du kannst sie erstellen, umbenennen, ausblenden und löschen, per Ziehen neu ordnen und den Startbildschirm danach filtern. + Wie funktionieren Labels? + Aufgaben haben eine Beschreibung und eine Priorität. Du kannst sie nach Priorität filtern und als erledigt markieren. Encly zeigt Aufgabentexte nie in Systembenachrichtigungen an. + Was können Aufgaben? + Unter Einstellungen → Darstellung wählst du eines von fünf Designs, hellen, dunklen oder System-Modus, einen von drei Schriftstilen und die Textgröße. Ab Android 12 kann die Akzentfarbe auch vom Hintergrundbild kommen. + Wie ändere ich das Design? + Encly legt absichtlich nie eine leere Datenbank über einen verlorenen oder unlesbaren Tresor an. Bestätige das vollständige Löschen nicht, wenn du deine lokalen Daten noch brauchst. + Encly meldet einen beschädigten Tresor + + Textgröße + Zeilenabstand + Standard + Kompakt + Großzügig + Redaktionell + Modern + Technisch + + + Sprache wählen + Systemstandard + + Link hinzufügen + + Listeneintrag + + + Zurück zur PIN + Zugang wiederherstellen + Gib deine 12-Wörter-Wiederherstellungsphrase ein + Wiederherstellung + Gib deine PIN ein, um fortzufahren + Notizen entsperren + Wird entsperrt… + Zugang mit der Wiederherstellungsphrase wiederherstellen + Falsche PIN + Falsche Wiederherstellungsphrase + + Gedrückt halten, um alles zu löschen + Die verschlüsselten Daten oder die Verschlüsselungsschlüssel wurden offenbar verändert oder beschädigt, daher ist keine Wiederherstellung möglich.\n\nUm die App weiter zu nutzen, musst du sie zurücksetzen und die beschädigten Daten löschen. Dadurch werden alle gespeicherten Notizen und Einstellungen endgültig gelöscht. + Deine Daten sind leider beschädigt + Diese Daten stammen aus einer älteren Version + Encly hat Notizen gefunden, die mit einer Version vor 2.0 gespeichert wurden. Encly 2.0 nutzt ein neues verschlüsseltes Format und kann sie nicht öffnen; eine Umwandlung ist nicht möglich.\n\nUm die App zu nutzen, fang neu an. Dabei werden die alten Notizen und Einstellungen auf diesem Handy endgültig gelöscht. + Gedrückt halten, um neu anzufangen + + FAQ + Einstellungen + Entwickler unterstützen + Labels + Aufgaben + Papierkorb + + Weitere Optionen + + + Kein Label + + Neue Notiz + Die Notiz konnte nicht gelöscht werden + Beschreibung… + Alle Änderungen verwerfen + Die Änderungen konnten nicht verworfen werden + Die Kopie konnte nicht erstellt werden + Der Inhalt der Notiz konnte nicht gelesen werden. Die Bearbeitung ist deaktiviert, damit die Daten nicht beschädigt werden. + + %1$s (Kopie) + Die Notiz konnte nicht wiederhergestellt werden + Die Änderungen konnten nicht gespeichert werden + Die Notiz konnte nicht in den Papierkorb verschoben werden + + Die App konnte nicht initialisiert werden + Die Wiederherstellungsphrase konnte nicht erstellt werden + Der verschlüsselte Tresor konnte nicht erstellt werden + + Menü öffnen + + %1$d %% + + Gib die PIN zur Bestätigung erneut ein + PIN bestätigen + Bestätige deine aktuelle PIN, bevor du deinen Zugangsschlüssel änderst + Aktuelle PIN eingeben + 6 bis 12 Ziffern zum Schutz deiner Notizen + PIN geändert + PIN erstellen + Die aktuelle PIN ist falsch + Ziffer löschen + Die PINs stimmen nicht überein. Bitte versuche es erneut. + Die PIN konnte nicht gespeichert werden + Die PIN konnte nicht aktualisiert werden. Bitte versuche es erneut. + + + Tippe, um mit dem Bearbeiten zu beginnen… + + Hoch + Niedrig + Mittel + Priorität wählen + + Zitat + Wiederholen + + Wiederherstellen + + Speichern + + + + + Suche löschen + + Fingerabdruck + Biometrisches Entsperren + Auf diesem Gerät nicht verfügbar + Kein Fingerabdruck eingerichtet. Füge einen in den Systemeinstellungen hinzu. + Verschlüsselung ist aktiv + Nicht eingerichtet. Ohne sie bedeutet eine vergessene PIN, dass alles verloren ist. Tippe, um eine zu erstellen. + Alle Daten löschen + Alle Notizen, Aufgaben und Labels sowie die Schlüssel auf diesem Gerät löschen + Alle Notizen, Aufgaben und Labels sowie die Schlüssel werden von diesem Gerät gelöscht und Encly beginnt von vorn. Das lässt sich nicht rückgängig machen: Exportiere zuerst ein Backup, wenn du etwas behalten möchtest. + Verschlüsselung und Datenschutz + Sicherheit + + + Darstellung + Allgemein + Sprache + Privatsphäre + Aufgaben anzeigen + Die Aufgabenkarte auf dem Startbildschirm anzeigen + Einfaches Bearbeiten + Dynamische Blöcke deaktivieren und Notizen als reinen Text bearbeiten + + Sortieren nach: + Nach Erstellungsdatum (älteste zuerst) + Erstellt ↑ + Nach Erstellungsdatum (neueste zuerst) + Erstellt ↓ + Nach Titel (A bis Z) + Titel A–Z + Nach Titel (Z bis A) + Titel Z–A + Nach letzter Bearbeitung (älteste zuerst) + Bearbeitet ↑ + Nach letzter Bearbeitung (neueste zuerst) + Bearbeitet ↓ + Entwickler unterstützen + + Das Label konnte nicht gespeichert werden. Bitte versuche es erneut. + Das Label konnte nicht gelöscht werden. Bitte versuche es erneut. + Label bearbeiten + Ausgeblendetes Label + Ein Label mit diesem Namen gibt es bereits + Die Reihenfolge der Labels konnte nicht gespeichert werden. Bitte versuche es erneut. + Das Label konnte nicht aktualisiert werden. Bitte versuche es erneut. + + Zum Löschen erneut tippen + + Neue Aufgabe + Erledigte Aufgaben konnten nicht gelöscht werden. Bitte versuche es erneut. + Möchtest du wirklich alle erledigten Aufgaben löschen? Das kann nicht rückgängig gemacht werden. + Erledigte Aufgaben löschen + Erledigt: %1$s + Die Aufgabe konnte nicht gespeichert werden. Bitte versuche es erneut. + Beschreibung + Aufgabe bearbeiten + Keine offenen Aufgaben. + Keine Aufgaben passen zu den gewählten Filtern. + Alle Aufgaben + Erledigt + Neue Aufgabe + Fortschritt + Der Status der Aufgabe konnte nicht geändert werden. Bitte versuche es erneut. + Die Aufgabe konnte nicht aktualisiert werden. Bitte versuche es erneut. + + Text + + Design + Dunkel + Hell + System + + + Rückgängig + + Ohne Titel + + Zur Rasteransicht wechseln + Zur Listenansicht wechseln + + + %d Notiz löschen? + %d Notizen löschen? + + + Zu viele Versuche. Versuche es in %d Sekunde erneut + Zu viele Versuche. Versuche es in %d Sekunden erneut + + + %1$d von %2$d Aufgabe erledigt + %1$d von %2$d Aufgaben erledigt + + Backup + Verschlüsselte Sicherung, Import und Markdown + Backup + Mit deiner Wiederherstellungsphrase verschlüsselt + Ein Backup enthält alle deine Notizen, Aufgaben und Labels, verschlüsselt mit deiner 12-Wörter-Wiederherstellungsphrase. Encly lädt es nirgendwo hoch: Du entscheidest, wo die Datei gespeichert wird. Auf einem neuen Handy stellen dieselben 12 Wörter es wieder her. + Letzter Export: %1$s + Noch kein Backup exportiert + Backup exportieren + Backup importieren + Notizen, Aufgaben und Labels aus einer Backup-Datei wiederherstellen + Backup gespeichert + Bestätige, dass du es bist + Gib deine PIN ein, um fortzufahren + Wiederherstellungsphrase + Backups werden mit einer Wiederherstellungsphrase verschlüsselt, nur ihre 12 Wörter können sie öffnen. Dieser Tresor hat noch keine. Erstelle jetzt eine: Mit ihr kannst du den Tresor auch entsperren, wenn du deine PIN vergisst. + Wiederherstellungsphrase erstellen + Schreib diese 12 Wörter der Reihe nach auf Papier und bewahre sie offline auf. Wer sie hat, kann deine Backups öffnen. + Ich habe sie notiert + Die Wörter stimmen nicht. Prüfe deine Notiz. + Wiederherstellungsphrase gespeichert + Wiederherstellungsphrase bestätigen + Gib die 12-Wörter-Wiederherstellungsphrase dieses Tresors einmal ein, damit Encly Backups damit verschlüsseln kann. + Wiederherstellungsphrase eingeben + Gib die 12 Wörter der Wiederherstellungsphrase ein, mit der dieses Backup erstellt wurde. + Entschlüsseln + Backup importieren + Zusammenführen + Ersetzen + Alle Daten ersetzen? + Alle Notizen, Aufgaben und Labels in diesem Tresor werden gelöscht und durch das Backup ersetzt. Das kann nicht rückgängig gemacht werden. + Importiert: Notizen %1$d, Aufgaben %2$d, Labels %3$d. Übersprungen (schon vorhanden): %4$d. + Diese Datei ist kein Encly-Backup + Dieses Backup stammt von einer neueren Encly-Version. Aktualisiere die App und versuche es erneut. + Die Backup-Datei ist beschädigt oder unvollständig + Die Datei ist zu groß für ein Encly-Backup + Dein Tresor ist zu groß für eine Backup-Datei (64 MB). Es wurde nichts gespeichert. + Diese Datei existiert bereits. Wähle einen neuen Namen: Ein älteres Backup wird nie überschrieben. + Auf diesem Gerät gibt es keine App zum Öffnen oder Speichern von Dateien. + Diese Wörter öffnen dieses Backup nicht, oder die Datei wurde verändert + Gib eine gültige 12-Wörter-Wiederherstellungsphrase ein + Der Tresor wurde gesperrt. Entsperre ihn und versuche es erneut. + Das Backup konnte nicht gelesen oder geschrieben werden. Es wurde nichts geändert. + Diese Wörter sind nicht die Wiederherstellungsphrase dieses Tresors + Die Wiederherstellungsphrase konnte nicht gespeichert werden + Aus einem Backup wiederherstellen + Backup-Datei wählen + Das Backup konnte nicht wiederhergestellt werden, daher ist die Einrichtung noch nicht abgeschlossen. Versuche es erneut oder fahre mit einem leeren Tresor fort und importiere die Datei später unter Einstellungen → Backup. + Backup nicht wiederhergestellt + Erneut versuchen + Ohne Backup fortfahren + Diese Notiz kann von dieser Encly-Version nicht gelesen werden. + Link öffnen + Link kopieren + Link bearbeiten + Link kopiert + Keine App auf diesem Gerät kann diesen Link öffnen. + Nur Web-Links (http, https) und E-Mail-Links können gespeichert oder geöffnet werden. + Notizen konnten nicht gelesen werden + Labels konnten nicht gelesen werden + Deine Notizen sind weiterhin verschlüsselt auf diesem Gerät gespeichert. Schließe Encly und öffne es erneut, um es noch einmal zu versuchen. + Du hast Encly mit deiner Wiederherstellungsphrase entsperrt. Lege eine neue PIN fest, um fortzufahren. + Aufgabe löschen + Aufgabe gelöscht + Die Aufgabe konnte nicht gelöscht werden. Bitte versuche es erneut. + Modus + Schrift + Papier + Wald + Ozean + Graphit + Mitternacht + Folgt der Systemeinstellung. Gerade hell. + Folgt der Systemeinstellung. Gerade dunkel. + Immer hell. + Immer dunkel. + Mitternacht ist immer dunkel. + Akzentfarbe aus deinem Hintergrundbild. Ab Android 12. + Dynamische Farben sind an: Die Akzentfarbe kommt aus deinem Hintergrundbild. + Playfair Display und Source Sans 3 + Poppins und Source Sans 3 + Durchgehend IBM Plex Sans + Checkliste Wohnungsbesichtigung + Vor dem Termin: Wasserdruck prüfen, fragen, was die Heizung letzten Winter gekostet hat. + Privat + Vorschau + Design, Modus und Schrift + Richtig + Mit Fingerabdruck entsperren + Schritt %1$d von %2$d + Ein Notizbuch, das nur du öffnen kannst. + Notizen und Aufgaben, verschlüsselt auf diesem Handy. Die Einrichtung dauert etwa eine Minute. + Kein Konto + Keine Anmeldung, keine E-Mail, kein Passwort-Reset. + Funktioniert offline + Encly verbindet sich nie mit dem Internet. + Auf dem Gerät verschlüsselt + Dein Tresor ist mit einem Schlüssel verschlossen, den nur du hast. + Los geht\'s + Ich habe ein Backup + Wähle eine PIN + 6 bis 12 Ziffern. Sie entsperrt Encly und schützt den Schlüssel zu deinem Tresor. Es gibt keine E-Mail, um sie zurückzusetzen. + Gib die PIN noch einmal ein + Damit wir wissen, dass es die richtige ist. + Die PIN funktioniert weiterhin als Ersatz + Zwölf Wörter bringen alles zurück + Encly gibt dir eine Wiederherstellungsphrase. Schreib sie auf Papier; sie ist der einzige Ersatzschlüssel. + PIN vergessen? + Die Phrase entsperrt deinen Tresor und lässt dich eine neue PIN festlegen. + Öffnet deine Backups + Verschlüsselte Backup-Dateien lassen sich nur mit diesen Wörtern lesen. + Neues Handy + Backup-Datei plus Phrase stellt jede Notiz, Aufgabe und jedes Label wieder her. + Niemand kann sie für dich wiederherstellen, auch wir nicht. Verlierst du PIN und Phrase, sind die Notizen weg. + Meine Phrase erstellen + Überspringen: nur PIN, keine Backups + Schreib diese Wörter auf + Auf Papier, in dieser Reihenfolge. Nicht als Screenshot, nicht in der Cloud. + Wörter verbergen + Wörter zeigen + Ich habe sie aufgeschrieben + Drei Wörter prüfen + Tippe sie von deiner Papierkopie ab, nicht aus dem Gedächtnis. + Wort Nr. %1$d + Wort eingeben + Das ist nicht Wort Nr. %1$d + Bestätigen + Wörter noch einmal zeigen + Dein Tresor ist bereit + Alles, was du ab jetzt schreibst, wird vor dem Speichern verschlüsselt. + PIN festgelegt + Wird bei jedem Öffnen von Encly abgefragt + Entsperren per Fingerabdruck an + Fingerabdruck erneut versuchen + Änderbar unter Einstellungen, Sicherheit + Wiederherstellungsphrase geprüft + Bewahre das Papier sicher auf + Keine Wiederherstellungsphrase + Backups sind aus. Du kannst später unter Einstellungen, Sicherheit eine Phrase erstellen. + Backup wiederhergestellt + Deine Notizen, Aufgaben und Labels sind zurück + Tipp: Exportiere ab und zu ein verschlüsseltes Backup unter Einstellungen, Backup. Deine Phrase öffnet es. + Mein Notizbuch öffnen + Wiederherstellen, dann PIN festlegen + Wähle deine Encly-Backup-Datei und gib dann die 12 Wörter ein, mit denen sie erstellt wurde. + 1 · Backup-Datei + Tippen, um eine andere Datei zu wählen + 2 · Wiederherstellungsphrase + Diese Wörter werden auch die Wiederherstellungsphrase dieses Handys. + Notizen + Jetzt sperren + Titel und Text durchsuchen + Ansichtsoptionen + Keine Treffer für „%1$s“ + Heute + Gestern + + Aufgaben · %d offen + Aufgaben · %d offen + + Aufgaben · keine offen + Noch keine Aufgaben + Tippe auf „Neue Aufgabe“, um die erste anzulegen. + Alles erledigt + Erledigt · %1$d + Keine Notizen mit dem Tag „%1$s“ + Notizen mit diesem Tag erscheinen hier. + Alle Notizen zeigen + Der Papierkorb ist leer + Tippe auf eine Notiz, um sie zu lesen. Lange drücken zum Auswählen. + Endgültig löschen + Papierkorb leeren + Notizen durchsuchen + Notizen und Aufgaben + Version, Datenschutz und Kontakt + Antworten auf häufige Fragen + Open-Source-Lizenzen + Bibliotheken und Schriften, die Encly nutzt + Auf Open Source gebaut + Encly nutzt diese Bibliotheken und Schriften. Tippe darauf, um die Lizenz zu lesen. + Bibliotheken + Schriften + Noch keine Tags + Tags ordnen deine Notizen. Lege oben einen an und wähle ihn dann per langem Druck auf eine Notiz aus. + Tippe auf einen Tag, um ihn umzubenennen, auszublenden oder zu löschen. Zum Sortieren am Griff ziehen. + Tag hinzufügen + Name + In der Notizliste zeigen + Ein ausgeblendeter Tag behält seine Notizen, aber keinen Filter-Chip + Ziehen zum Sortieren + Nach oben + Nach unten + + %d ausgewählt + %d ausgewählt + + Labels bearbeiten + Titel + Nicht gespeichert + Labels verwalten + Änderungen verwerfen? + Alles, was du seit dem Öffnen dieser Notiz geändert hast, geht verloren. Das kann nicht rückgängig gemacht werden. + Alles gehört schon dir + Jede Funktion steht allen zur Verfügung. Nichts ist hinter einer Zahlung versteckt. + Keine Werbung, kein Tracking + Encly sieht deine Notizen nie und verdient nichts an Daten. Nur Spenden finanzieren die App. + Wohin das Geld fließt + Testgeräte (als Nächstes: ein Mac mini zum Bauen und Testen) und Zeit für Releases und Fehlerbehebungen. + Karte oder PayPal, einmalig oder monatlich + Öffnet ko-fi.com im Browser. Encly selbst wickelt nie Zahlungen ab. + Was bewirkt langes Drücken auf eine Notiz? + Es öffnet die Aktionen der Notiz: Label ändern, eine kurze Beschreibung für die Karte hinzufügen, die Notiz duplizieren oder in den Papierkorb verschieben. + Wie bekomme ich eine gelöschte Notiz zurück? + Gelöschte Notizen landen im Papierkorb im Menü. Dort kannst du eine Notiz wiederherstellen oder endgültig löschen. Encly leert den Papierkorb nie von selbst. + Was macht „Bearbeitung sperren“? + Die Notiz wird schreibgeschützt, damit sie nicht versehentlich geändert wird. Text lässt sich weiterhin markieren und kopieren. Ein- und ausschalten kannst du es im Menü des Editors. + Wie funktionieren Backups? + Unter Einstellungen → Backup exportierst du eine verschlüsselte Datei, die nur deine Wiederherstellungsphrase öffnen kann. Zum Wiederherstellen wählst du die Datei, tippst oder fügst die 12 Wörter ein und führst sie dann mit diesem Tresor zusammen oder ersetzt alles. + Tastatur einblenden + Tastatur ausblenden + Alle Daten löschen? + Die Daten konnten nicht gelöscht werden. Nichts wurde geändert. + Löschen + Unteraufgaben + Unteraufgabe + Unteraufgabe hinzufügen + Unteraufgabe löschen + Alle Unteraufgaben sind erledigt + Aufgabe erledigen + Unteraufgaben anzeigen + Unteraufgaben ausblenden + Ausgeklappt + Eingeklappt + Unteraufgabe gelöscht + Nächster Schritt: %1$s + Erster Schritt + + %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 + Anheften + Anheften aufheben + Archivieren + Aus dem Archiv holen + Angeheftet + Weitere + Archiv + Das Archiv ist leer + Archivierte Notizen verschwinden aus Liste und Suche, bleiben aber im Tresor. Archiviere eine Notiz über ihr Menü. + Die Notiz konnte nicht archiviert werden + Die Notiz konnte nicht aus dem Archiv geholt werden + Farbe + Keine Farbe + Rot + Orange + Gelb + Grün + Petrol + Blau + Violett + Rosa + Als Markdown exportieren + Als Markdown kopieren + Als Markdown kopiert + Lesbarer Export + Alle Notizen und Aufgaben als lesbare .md-Dateien in einer .zip speichern. Die Datei ist nicht verschlüsselt. + Ohne Verschlüsselung exportieren? + Die exportierte Datei ist nicht verschlüsselt. Jeder, der sie bekommt, kann deine Notizen lesen. Überall, wo du sie speicherst, auch in einem Cloud-Ordner, liegt eine lesbare Kopie, die Encly weder schützen noch zurückholen kann. Als Nächstes bestätigst du mit deiner PIN. + Markdown-Export gespeichert + Kann ich meine Notizen als Markdown exportieren? + Ja. Wähle im Menü einer Notiz „Als Markdown exportieren“ für eine .md-Datei, oder nutze Einstellungen → Backup → „Als Markdown exportieren“ für eine .zip mit allen Notizen (archivierte im Ordner archive, ohne Papierkorb) und einer Aufgabendatei. Diese Dateien sind nicht verschlüsselt: Encly warnt dich vorher, fragt nach deiner PIN und speichert nur dort, wo du es festlegst. + Wischaktionen + Wischen auf Notizen + Wische eine Notizkarte, um eine Aktion auszuführen + Nach rechts wischen + Nach links wischen + Anheften oder lösen + Nächste Farbe + In den Papierkorb + Nichts + In den Papierkorb legen? + Du kannst die Notiz aus dem Papierkorb wiederherstellen. + Was bewirken Wischgesten auf einer Notiz? + Standardmäßig heftet ein Wisch nach rechts eine Notiz an oder löst sie, ein Wisch nach links archiviert sie. Unter Einstellungen → Wischaktionen kannst du für jede Richtung Anheften, Archivieren, nächste Farbe, In den Papierkorb (fragt immer nach) oder nichts wählen oder das Wischen ausschalten. TalkBack bietet dieselben Aktionen im Aktionsmenü der Notiz an. + Text in Notizen + Der Standard für jede Notiz. Eine Notiz kann in ihrem Menü eigene Werte festlegen. + Standard-Texteinstellungen verwenden + Quellcode + Encly ist Open Source: Lies den Code auf GitHub + Öffnen + In neuem Tab öffnen + Wähle eine Notiz oder beginne eine neue + Datei + Ansicht + Fenster + Hilfe + Beenden + Minimieren + Neues Fenster + In neuem Fenster öffnen + Als PDF exportieren + Drucken + Ohne Verschlüsselung als PDF exportieren? + Das PDF ist nicht verschlüsselt. Jeder, der es bekommt, kann deine Notiz lesen. Überall, wo du es speicherst, auch in einem Cloud-Ordner, liegt eine lesbare Kopie, die Encly weder schützen noch zurückholen kann. Als Nächstes bestätigst du mit deiner PIN. + Ohne Verschlüsselung drucken? + Beim Drucken wird der Text deiner Notiz unverschlüsselt an den Drucker gesendet. Die Druckwarteschlange des Systems kann temporäre Kopien davon behalten, und ein Netzwerk- oder Cloud-Drucker speichert ihn möglicherweise ebenfalls. Encly kann sie nicht entfernen. Als Nächstes bestätigst du mit deiner PIN. + PDF gespeichert + An den Drucker gesendet + Diese Notiz ist in einem anderen Fenster zum Bearbeiten geöffnet. Hier ist sie schreibgeschützt. + Neuigkeiten + Die Änderungen dieser Version + Verstanden + Notizen werden nach 28 Tagen im Papierkorb endgültig gelöscht. + Wie lang darf meine PIN sein? + Zwischen 6 und 12 Ziffern, du entscheidest. Kürzere PINs werden nicht angeboten: Die Sperre und der Gerätschlüssel begrenzen die Zahl der Versuche, und 6 Ziffern sind die kürzeste PIN, die damit sicher bleibt. Der Sperrbildschirm zeigt nie, wie lang deine PIN ist: Du tippst auf „Entsperren“, wenn du fertig bist; das gilt auch für Lösch-PIN und Zwangs-PIN. PINs, die du vorher festgelegt hast, funktionieren unverändert. + Code + Tabelle + Zeile hinzufügen + Spalte hinzufügen + Zeile löschen + Spalte löschen + Kopfzeile + Zellenoptionen + Editor-Werkzeugleiste + Werkzeuge auswählen und anordnen + Zum Umordnen am Griff ziehen. Das Auge blendet ein Werkzeug aus oder wieder ein. Werkzeuge unter „Weiteres Menü“ liegen hinter dem Pfeil. + Zurücksetzen + Werkzeugleiste + Weiteres Menü + Ausgeblendet + Werkzeug ausblenden + Werkzeug einblenden + Nach oben + Nach unten + + %1$d weiteres Label + %1$d weitere Labels + + diff --git a/app/src/main/res/values-de/strings_handoff.xml b/core/resources/src/commonMain/strings/values-de/strings_handoff.xml similarity index 100% rename from app/src/main/res/values-de/strings_handoff.xml rename to core/resources/src/commonMain/strings/values-de/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values-de/strings_import.xml b/core/resources/src/commonMain/strings/values-de/strings_import.xml new file mode 100644 index 00000000..d45d68a5 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-de/strings_import.xml @@ -0,0 +1,79 @@ + + + + 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 + Aufgaben-Notizen, die als normale Notizen importiert werden: %1$d + Übersprungene verschlüsselte oder nicht unterstützte Einträge: %1$d + Nicht verwendbare Dateien: %1$d + Archive in Archiven, nicht geöffnet: %1$d + Wird importiert … + Importiert + %1$d hinzugefügt, %2$d als Duplikate übersprungen, %3$d nicht importiert. + Deine Originaldateien wurden nicht verändert. Lösche sie selbst, sobald du deine Notizen hier geprüft hast. + Aus welcher App stammen diese? + Die Dateien scheinen aus mehr als einer App zu stammen. Wähle die zu importierende; der Rest wird übersprungen. + Nichts wurde importiert + Dieses Standard-Notes-Backup ist verschlüsselt. Lade in Standard Notes ein entschlüsseltes Backup herunter und wähle dieses. + Das ist ein verschlüsseltes Notesnook-Backup. Exportiere deine Notizen in Notesnook stattdessen als Markdown und wähle die .zip. + Diese Dateien sind kein unterstützter Export. Sieh dir die unterstützten Apps an oder wähle Markdown- oder Textdateien. + Diese Auswahl ist größer, als Encly auf einmal importieren kann (64 MB Notizen, 2 GB insgesamt oder 20.000 Notizen). Importiere sie in kleineren Teilen. + In dieser Auswahl wurden keine Notizen gefunden. + Die gewählten Dateien konnten nicht gelesen werden. + Auf diesem Telefon gibt es keine Datei-Auswahl-App, die Encly nutzen kann. + Der Tresor wurde während des Imports geschlossen. Entsperre Encly und versuche es erneut. + Etwas ist schiefgelaufen, es wurde nichts importiert. Dein Tresor ist unverändert. + %1$d von %2$d ausgewählt + Alle auswählen + Keine auswählen + Alle + Nur neue + Im Papierkorb + Ohne Titel + Checkliste + Im Papierkorb + Schon im Tresor + %1$d importieren + Erneut wählen + diff --git a/core/resources/src/commonMain/strings/values-de/strings_security.xml b/core/resources/src/commonMain/strings/values-de/strings_security.xml new file mode 100644 index 00000000..bce41ba4 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-de/strings_security.xml @@ -0,0 +1,141 @@ + + + Gib deine 12 Wörter ein oder füge sie ein + Wort %1$d von %2$d + Wort %1$d ist kein Wort einer Wiederherstellungsphrase. Prüfe die Schreibweise. + Diese 12 Wörter ergeben keine gültige Wiederherstellungsphrase. Prüfe jedes Wort und die Reihenfolge. + Dieser Tresor hat bereits eine Wiederherstellungsphrase + Zu deinen Notizen hinzufügen oder sie ersetzen? + Dieses Backup enthält %1$s, %2$s und %3$s. + Alles hier bleibt, Fehlendes kommt dazu. Was schon da ist, wird übersprungen. + Alles ersetzen + Alle Notizen, Aufgaben und Tags auf diesem Handy löschen, dann das Backup wiederherstellen. + Sichere alle Notizen, Aufgaben und Tags in einer verschlüsselten Datei und stelle sie auf jedem Handy wieder her. + Exportieren + Importieren + Deine Notizen sind verschlüsselt. Deine PIN (und dein Fingerabdruck, falls aktiviert) entsperrt sie. + Deine Notizen sind verschlüsselt. Deine PIN (und dein Fingerabdruck, falls aktiviert) entsperrt sie, und wenn du die PIN vergisst, deine Wiederherstellungsphrase. + Bei der Einrichtung erstellt. Encly speichert die Wörter nie und kann sie nicht erneut zeigen. Hast du sie nicht? Ersetze die Phrase unten. + Eingerichtet + Nach dem Zurücksetzen kannst du eine Backup-Datei mit ihren 12 Wörtern wiederherstellen. + Dein Tresor lässt sich gerade nicht öffnen + Encly konnte seine Daten eben nicht lesen. Eine andere App oder das System nutzt sie womöglich, oder das Gerät ist noch gesperrt. Deine Notizen sind nicht verloren. Versuche es gleich noch einmal. + Erneut versuchen + Immer noch nicht lesbar. Versuche es gleich noch einmal. + PIN ändern + + %d Notiz + %d Notizen + + + %d Aufgabe + %d Aufgaben + + + %d Tag + %d Tags + + + Deine PIN kann Encly auf diesem Telefon nicht mehr entsperren: Ihr Hardware-Schlüssel wurde zurückgesetzt. Nutze deine Wiederherstellungsphrase (oder deinen Fingerabdruck) und lege dann eine neue PIN fest. + Das Gerät meldet, dass es noch gesperrt ist, deshalb ist Encly geschlossen geblieben. Entsperre das Gerät und versuche es erneut. + + Zu viele Versuche. Versuche es in %d Minute erneut + Zu viele Versuche. Versuche es in %d Minuten erneut + + + Zu viele Versuche. Versuche es in %d Stunde erneut + Zu viele Versuche. Versuche es in %d Stunden erneut + + Wiederherstellungsphrase ersetzen + Erhalte 12 neue Wörter. Frühere Backups brauchen weiterhin die alten. + Wiederherstellungsphrase ersetzen? + Du erhältst 12 neue Wörter. Die alte Phrase entsperrt deine Notizen auf diesem Telefon nicht mehr. Bereits erstellte Backups öffnen sich weiterhin nur mit der alten Phrase – bewahre sie also auf, solange du diese Backups behältst, oder erstelle danach ein neues Backup. + Neue Wörter erhalten + Deine alte Phrase funktioniert hier nicht mehr + Sobald du bestätigst, entsperren nur noch diese Wörter deine Notizen, wenn du die PIN vergisst. Frühere Backups brauchen weiterhin die alten Wörter. + Wiederherstellungsphrase ersetzt. Erstelle ein neues Backup, um sie auch für Backups zu nutzen. + Datenschutz + Strenger Tastatur-Datenschutz + Bittet die Tastatur um keine Vorschläge, keine Cloud-Vorhersage und kein Mitlesen rund um den Cursor. Schaltet die Autokorrektur aus, und manche Tastaturen blenden Emojis oder Spracheingabe aus. + Screenshots erlauben, bis die App sperrt + Screenshots und Bildschirmaufnahmen bleiben gesperrt, solange du sie hier nicht erlaubst. Die Option schaltet sich aus, sobald Encly sperrt. + Screenshots erlauben? + Screenshots können mitsamt deinen Notizen in der Galerie und in Cloud-Backups landen. Sperrbildschirm, PIN-Eingabe und Wiederherstellungsphrase bleiben geschützt. Die Erlaubnis endet, sobald Encly sperrt. + Sperren nach dem Verlassen + Wie lange Encly offen bleibt, wenn du zu einer anderen App wechselst. Bildschirm aus oder Telefon sperren sperrt es immer sofort. + Sofort + Nach 15 Sekunden + Nach 30 Sekunden + Nach 1 Minute + Nach 2 Minuten + Nach 5 Minuten + Nach 10 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. + Eine PIN, an dieses Telefon gebunden + Deine PIN wird mit einem Schlüssel im Sicherheitschip des Telefons verknüpft und kann nirgendwo sonst erraten werden. + Ein eigenes Schloss für jeden Zugang + PIN, Wiederherstellungsphrase und Fingerabdruck öffnen je einen eigenen verschlüsselten Slot. Der Fingerabdruck wird jedes Mal verlangt. + Gefahrenbereich + 5 Sekunden halten zum Löschen + + Lösch-PIN + Eine zweite PIN, die deine Notizen löscht, wenn du sie auf dem Sperrbildschirm eingibst + Ausgeschaltet, als dein PIN-Schlüssel zurückgesetzt wurde. Lege sie neu fest, wenn du eine hattest. + Wenn dich jemand zwingt, Encly zu öffnen, gib statt deiner PIN die Lösch-PIN ein. Encly löscht deine Notizen, Aufgaben und Labels ohne sichtbares Zeichen und öffnet sich als leerer Tresor. Das funktioniert nur auf dem Sperrbildschirm. + Nach dem Löschen öffnet sich Encly mit der Lösch-PIN: Sie wird zur PIN des neuen, leeren Tresors. Deine alte PIN funktioniert nicht mehr. Wer gesehen hat, wie du die Lösch-PIN eingibst, kann Encly damit wieder öffnen und bemerkt nichts Ungewöhnliches. + Bereits exportierte Backup-Dateien bleiben unberührt. Sie lassen sich weiterhin mit deiner Wiederherstellungsphrase öffnen und sind der einzige Weg zurück. + Biometrisches Entsperren bleibt an: Jemand kann deinen Finger auf den Sensor zwingen. Schalte es aus, wenn das für dich ein Risiko ist. + Biometrisches Entsperren ausschalten + Das schützt dich, wenn dich jemand zum Entsperren zwingt, nicht vor einer forensischen Untersuchung des Telefons. Während einer Sperre nach Fehlversuchen funktioniert auch die Lösch-PIN nicht. Encly zeigt nie an, ob eine Lösch-PIN festgelegt ist: Eine neue ersetzt die alte, und wenn du deine PIN auf die Lösch-PIN änderst, wird sie ausgeschaltet. + Lösch-PIN festlegen + Lösch-PIN ausschalten + Bestätige deine PIN, um die Lösch-PIN einzurichten + Lösch-PIN erstellen + 6 bis 12 Ziffern, anders als deine PIN + Lösch-PIN bestätigen + Gib die Lösch-PIN noch einmal ein + Die Lösch-PIN muss sich von deiner PIN unterscheiden + Die Lösch-PIN konnte nicht gespeichert werden. Bitte versuche es erneut. + 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 bis 12 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 + Notizvorschau anzeigen + Die Notizliste zeigt die ersten Zeilen jeder Notiz. Ausgeschaltet erscheinen nur Titel, Tag und Datum; die Suche findet weiterhin den ganzen Text. + Eingegebene Ziffern: %1$d + Entsperren + Wiederherstellungsphrase akzeptiert + Deine PIN funktioniert noch. Behalte sie oder lege eine neue fest. + Meine PIN behalten + Neue PIN festlegen + diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml new file mode 100644 index 00000000..33acb9ee --- /dev/null +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -0,0 +1,729 @@ + + + Acerca de + Un espacio local y cifrado para notas, ideas y tareas, sin sincronización en la nube. + Protección de datos + Los datos de la caja fuerte se guardan localmente en una base de datos SQLCipher cifrada + Preguntas frecuentes + Comentarios + Informa de un error o sugiere una mejora en GitHub + Por qué importa y cómo ayudar en Ko-fi + No se encontró ningún navegador. Abre el enlace manualmente: %1$s + No se encontró ninguna aplicación de correo + Política de privacidad + Cómo guardamos y protegemos tus datos + Valorar la aplicación + Deja una valoración en Google Play Store + Soporte y contacto + Contactar con el desarrollador + Si tienes preguntas o sugerencias, puedes escribirme por correo. + + Continuar + + Añadir + + Todas + + De 6 a 12 dígitos. Toca para cambiar el PIN. + PIN + Autenticación + + Atrás + + No se confirmó el cambio de la protección biométrica + No se pudo crear la clave biométrica + Confirma tu identidad + Autenticación + Confirma tu biometría + Acceso a la clave + Confirma tu biometría para cambiar la protección + Confirmación de seguridad + Confirma tu biometría para acceder a la clave cifrada + Desbloquear Encly + Usar PIN + La autenticación biométrica no está disponible + + Más herramientas de bloque + Lista con viñetas + Lista de verificación + Encabezado + Encabezado %1$d + Enlace + Lista + Bloquear lista de tareas + Desbloquear lista de tareas + Seleccionar bloque + Reordenar elemento + Subir elemento + Bajar elemento + Aumentar sangría del elemento + Reducir sangría del elemento + Bloques seleccionados: %1$d + Quitar selección + Copiar + Cortar + Eliminar bloques seleccionados + Seleccionar todo + Copiado + Mover bloque hacia abajo + Mover bloque hacia arriba + Lista numerada + Separador + + Cancelar + + + ¿Seguro que quieres eliminar estas notas para siempre? Esta acción no se puede deshacer. + ¿Seguro que quieres vaciar la papelera para siempre? Esta acción no se puede deshacer. + ¿Vaciar la papelera? + + Borrar + + Cerrar + + Contraer + + Crear una etiqueta + + Hoy %1$s + Ayer %1$s + + Eliminar + Eliminar bloque + Eliminar + Eliminar etiqueta + Esta etiqueta se quitará de todas tus notas. Las notas en sí no se eliminarán. + + Eliminar + Cancelar + ¿Seguro que quieres eliminar esta nota para siempre? Esta acción no se puede deshacer. + ¿Eliminar la nota? + + Encly es gratuito y de código abierto, sin anuncios, sin rastreo y sin cuentas. Las donaciones son su único ingreso. + Apoyar el desarrollo + + Listo + + Duplicar + + Colores dinámicos + + Editar + + Bloquear la edición + + Aún no hay notas + Toca Nueva nota para escribir la primera. + Las notas eliminadas esperan aquí hasta que las restaures o las borres para siempre. + + Introduce un enlace + + No se pudo abrir la base de datos + + Expandir + + No se pudo cargar el contenido + + Encly guarda tus cambios y, tras el retraso elegido en Ajustes → Seguridad (15 segundos por defecto), cierra la base de datos cifrada y borra la clave de sesión. Apagar la pantalla o bloquear el teléfono lo hace al instante. Después, desbloqueas de nuevo. + ¿Qué pasa cuando salgo de la aplicación? + Usa el interruptor de biometría en Ajustes → Seguridad. Crear o eliminar la ranura biométrica requiere autenticación biométrica fuerte. + ¿Cómo activo o desactivo la biometría? + Abre Ajustes → Seguridad, confirma tu PIN actual y luego introduce el nuevo dos veces. + ¿Cómo cambio mi PIN? + Encly genera una clave de base de datos aleatoria de 256 bits. Tu PIN protege una ranura de desbloqueo AES-GCM independiente, y la biometría usa una clave de Android Keystore que exige autenticación en cada uso. + ¿Cómo se protege la clave de la base de datos? + En la pantalla de bloqueo, toca «Recuperar el acceso» e introduce tus 12 palabras. Si tu PIN sigue funcionando, puedes conservarlo o elegir un PIN nuevo; si no, eliges un PIN nuevo en ese momento. Sin frase de recuperación no hay otra forma de entrar: nadie, ni siquiera el desarrollador, puede restablecerla. + ¿Y si olvido mi PIN? + Abre Ajustes → Idioma y elige uno de los idiomas disponibles, o «Predeterminado del sistema» para usar el idioma del dispositivo. El cambio se aplica al instante. + ¿Cómo cambio el idioma? + Toca «Nota nueva» en la pantalla principal. La barra sobre el teclado añade encabezados, listas de verificación, listas con viñetas y numeradas, citas, enlaces y separadores. + ¿Cómo creo una nota nueva? + Es una llave de repuesto de 12 palabras. Desbloquea la bóveda si olvidas tu PIN y es la única llave de tus copias de seguridad. Se crea durante la configuración, o más tarde en Ajustes → Seguridad si la omitiste. Guárdala en papel, sin conexión. + ¿Para qué sirve la frase de recuperación? + Abre «Acerca de» y usa la opción de comentarios, o escribe al desarrollador. Nunca incluyas tu frase de recuperación, tu PIN ni el texto privado de tus notas en un informe de error. + ¿Cómo informo de un error? + Automáticamente mientras escribes, y al salir del editor se completa el último guardado. Si alguna vez falla un guardado, el editor muestra «Sin guardar» en rojo. + ¿Cómo se guardan los cambios? + Notas, tareas, cifrado local y recuperación del acceso, sin sincronización en la nube. + Respuestas a las preguntas más frecuentes + No por defecto. La protección de pantalla está activa: Encly bloquea las capturas de pantalla, la grabación de pantalla y las vistas previas sin proteger en la lista de aplicaciones recientes. Si necesitas una, Ajustes → Seguridad → «Permitir capturas de pantalla hasta que la app se bloquee» la levanta tras tu PIN; las pantallas de bloqueo, PIN y frase de recuperación siguen protegidas, y el permiso se desactiva solo cuando Encly se bloquea. + ¿Puedo hacer capturas de pantalla de Encly? + Lo básico + Seguridad y recuperación + Ajustes + Solución de problemas + Las notas, etiquetas y tareas se guardan únicamente en la base de datos SQLCipher local de Encly. No hay sincronización en la nube ni copias automáticas: puedes exportar tú mismo una copia cifrada en Ajustes → Copia de seguridad, o una copia legible en Markdown, que no está cifrada. + ¿Dónde se guardan mis datos? + Las etiquetas agrupan tus notas. Puedes crearlas, renombrarlas, ocultarlas y eliminarlas, arrastrarlas en el orden que quieras y filtrar la pantalla principal por etiqueta. + ¿Cómo funcionan las etiquetas? + Las tareas tienen una descripción y una prioridad. Puedes filtrarlas por prioridad y marcarlas como completadas. Encly nunca muestra el texto de las tareas en las notificaciones del sistema. + ¿Qué puedo hacer con las tareas? + En Ajustes → Apariencia elige uno de cinco temas, modo claro, oscuro o del sistema, uno de tres estilos de fuente y el tamaño del texto. En Android 12 y posteriores también puedes tomar el color de acento del fondo de pantalla. + ¿Cómo cambio el tema? + Encly nunca crea a propósito una base de datos vacía sobre una caja fuerte perdida o ilegible. No confirmes el borrado completo si aún necesitas tus datos locales. + Encly indica que la caja fuerte está dañada + + Tamaño del texto + Interlineado + Predeterminado + Compacto + Amplio + Editorial + Moderno + Técnico + + + Elegir idioma + Predeterminado del sistema + + Añadir enlace + + Elemento de la lista + + + Volver al PIN + Recuperar el acceso + Introduce tu frase de recuperación de 12 palabras + Recuperación + Introduce tu PIN para continuar + Desbloquea tus notas + Desbloqueando… + Recuperar el acceso con tu frase de recuperación + PIN incorrecto + Frase de recuperación incorrecta + + Mantén pulsado para borrarlo todo + Parece que los datos cifrados o las claves de cifrado se han modificado o dañado, así que no es posible recuperarlos.\n\nPara seguir usando la aplicación, tienes que restablecerla y borrar los datos dañados. Esto elimina para siempre todas las notas y ajustes guardados. + Lo sentimos, tus datos están dañados + Estos datos son de una versión anterior + Encly encontró notas guardadas por una versión anterior a la 2.0. Encly 2.0 usa un nuevo formato cifrado y no puede abrirlas, y no hay forma de convertirlas.\n\nPara usar la app, empieza de cero. Esto borra para siempre las notas y los ajustes antiguos de este teléfono. + Mantén pulsado para empezar de cero + + Preguntas frecuentes + Ajustes + Apoyar al desarrollador + Etiquetas + Tareas + Papelera + + Más opciones + + + Sin etiqueta + + Nota nueva + No se pudo eliminar la nota + Descripción… + Descartar todos los cambios + No se pudieron descartar los cambios + No se pudo crear una copia + No se pudo leer el contenido de la nota. La edición está desactivada para no dañar los datos. + + %1$s (copia) + No se pudo restaurar la nota + No se pudieron guardar los cambios + No se pudo mover la nota a la papelera + + No se pudo inicializar la aplicación + No se pudo crear la frase de recuperación + No se pudo crear la caja fuerte cifrada + + Abrir menú + + %1$d %% + + Vuelve a introducir el PIN para confirmarlo + Confirma tu PIN + Confirma tu PIN actual antes de cambiar tu clave de acceso + Introduce tu PIN actual + De 6 a 12 dígitos para proteger tus notas + PIN cambiado + Crea un PIN + El PIN actual no es correcto + Borrar dígito + Los PIN no coinciden. Inténtalo de nuevo. + No se pudo guardar el PIN + No se pudo actualizar el PIN. Inténtalo de nuevo. + + + Toca para empezar a editar… + + Alta + Baja + Media + Elige una prioridad + + Cita + Rehacer + + Restaurar + + Guardar + + + + + Borrar búsqueda + + Huella digital + Desbloqueo biométrico + No disponible en este dispositivo + No hay ninguna huella registrada. Añade una en los ajustes del sistema. + El cifrado está activo + Sin configurar. Sin ella, olvidar el PIN significa perderlo todo. Toca para crear una. + Borrar todos los datos + Eliminar todas las notas, tareas y etiquetas y las claves de cifrado de este dispositivo + Todas las notas, tareas y etiquetas y las claves de cifrado se eliminarán de este dispositivo, y Encly empezará de cero. No se puede deshacer: exporta antes una copia si quieres conservar algo. + Cifrado y protección de datos + Seguridad + + + Apariencia + General + Idioma + Privacidad + Mostrar tareas + Mostrar la tarjeta de tareas en la pantalla de inicio + Edición sencilla + Desactivar los bloques dinámicos y editar las notas como texto sin formato + + Ordenar por: + Por fecha de creación (más antiguas primero) + Creada ↑ + Por fecha de creación (más recientes primero) + Creada ↓ + Por título (A a Z) + Título A–Z + Por título (Z a A) + Título Z–A + Por última edición (más antiguas primero) + Editada ↑ + Por última edición (más recientes primero) + Editada ↓ + Apoyar al desarrollador + + No se pudo guardar la etiqueta. Inténtalo de nuevo. + No se pudo eliminar la etiqueta. Inténtalo de nuevo. + Editar etiqueta + Etiqueta oculta + Ya existe una etiqueta con este nombre + No se pudo guardar el orden de las etiquetas. Inténtalo de nuevo. + No se pudo actualizar la etiqueta. Inténtalo de nuevo. + + Toca de nuevo para eliminar + + Nueva tarea + No se pudieron borrar las tareas completadas. Inténtalo de nuevo. + ¿Seguro que quieres eliminar todas las tareas completadas? Esta acción no se puede deshacer. + Borrar tareas completadas + Completada: %1$s + No se pudo guardar la tarea. Inténtalo de nuevo. + Descripción + Editar tarea + No hay tareas activas. + Ninguna tarea coincide con los filtros seleccionados. + Todas las tareas + Completadas + Tarea nueva + Progreso + No se pudo cambiar el estado de la tarea. Inténtalo de nuevo. + No se pudo actualizar la tarea. Inténtalo de nuevo. + + Texto + + Tema + Oscuro + Claro + Sistema + + + Deshacer + + Sin título + + Cambiar a vista de cuadrícula + Cambiar a vista de lista + + + ¿Eliminar %d nota? + ¿Eliminar %d notas? + ¿Eliminar %d notas? + + + Demasiados intentos. Vuelve a intentarlo en %d segundo + Demasiados intentos. Vuelve a intentarlo en %d segundos + Demasiados intentos. Vuelve a intentarlo en %d segundos + + + %1$d de %2$d tarea completada + %1$d de %2$d tareas completadas + %1$d de %2$d tareas completadas + + Copia de seguridad + Copia cifrada, importación y Markdown + Copia de seguridad + Cifrada con tu frase de recuperación + Una copia de seguridad contiene todas tus notas, tareas y etiquetas, cifradas con tu frase de recuperación de 12 palabras. Encly nunca la sube a ningún sitio: tú eliges dónde se guarda el archivo. En un teléfono nuevo, las mismas 12 palabras la restauran. + Última exportación: %1$s + Aún no se ha exportado ninguna copia + Exportar copia + Importar copia + Restaurar notas, tareas y etiquetas desde un archivo de copia + Copia de seguridad guardada + Confirma que eres tú + Introduce tu PIN para continuar + Frase de recuperación + Las copias se cifran con una frase de recuperación, así que solo sus 12 palabras pueden abrirlas. Esta caja fuerte aún no tiene ninguna. Créala ahora: también te permite desbloquear esta caja fuerte si olvidas tu PIN. + Crear frase de recuperación + Anota estas 12 palabras en papel, en orden, y guárdalas sin conexión. Quien las tenga puede abrir tus copias. + Ya las he anotado + Las palabras no coinciden. Revisa lo que anotaste. + Frase de recuperación guardada + Confirma tu frase de recuperación + Introduce una vez la frase de recuperación de 12 palabras de esta caja fuerte para que Encly pueda cifrar las copias con ella. + Introduce la frase de recuperación + Introduce las 12 palabras de la frase de recuperación con la que se hizo esta copia. + Descifrar + Importar copia + Combinar + Reemplazar + ¿Reemplazar todos los datos? + Todas las notas, tareas y etiquetas de esta caja fuerte se eliminarán y se sustituirán por la copia. No se puede deshacer. + Importado: notas %1$d, tareas %2$d, etiquetas %3$d. Omitido (ya existía): %4$d. + Este archivo no es una copia de seguridad de Encly + Esta copia se creó con una versión más reciente de Encly. Actualiza la app y vuelve a intentarlo. + El archivo de copia está dañado o incompleto + El archivo es demasiado grande para ser una copia de Encly + Tu caja fuerte es demasiado grande para un solo archivo de copia (64 MB). No se ha guardado nada. + Ese archivo ya existe. Elige otro nombre: nunca se sobrescribe una copia anterior. + Ninguna app de este dispositivo puede abrir ni guardar archivos. + Estas palabras no abren esta copia, o el archivo ha sido modificado + Introduce una frase de recuperación válida de 12 palabras + La caja fuerte se ha bloqueado. Desbloquéala y vuelve a intentarlo. + No se pudo leer ni escribir la copia. No se ha cambiado nada. + Estas palabras no son la frase de recuperación de esta caja fuerte + No se pudo guardar la frase de recuperación + Restaurar desde una copia + Elegir un archivo de copia + No se pudo restaurar la copia, así que la configuración aún no ha terminado. Inténtalo de nuevo o continúa con una caja fuerte vacía e importa el archivo más tarde en Ajustes → Copia de seguridad. + Copia no restaurada + Reintentar + Continuar sin ella + Esta versión de Encly no puede leer esta nota. + Abrir enlace + Copiar enlace + Editar enlace + Enlace copiado + Ninguna app de este dispositivo puede abrir este enlace. + Solo se pueden guardar o abrir enlaces web (http, https) y de correo. + No se pudieron leer tus notas + No se pudieron leer tus etiquetas + Tus notas siguen guardadas cifradas en este dispositivo. Cierra Encly y vuelve a abrirlo para intentarlo de nuevo. + Has desbloqueado Encly con tu frase de recuperación. Crea un PIN nuevo para continuar. + Eliminar tarea + Tarea eliminada + No se pudo eliminar la tarea. Inténtalo de nuevo. + Modo + Fuente + Papel + Bosque + Océano + Grafito + Medianoche + Sigue el ajuste del sistema. Ahora claro. + Sigue el ajuste del sistema. Ahora oscuro. + Siempre claro. + Siempre oscuro. + Medianoche siempre es oscuro. + Toma el color de acento de tu fondo de pantalla. Android 12 y posteriores. + Los colores dinámicos están activados: el acento viene de tu fondo de pantalla. + Playfair Display y Source Sans 3 + Poppins y Source Sans 3 + IBM Plex Sans en todo + Lista para ver el piso + Antes de la visita: comprobar la presión del agua, preguntar cuánto costó la calefacción el invierno pasado. + Personal + Vista previa + Tema, modo y fuente + Correcto + Desbloquear con huella + Paso %1$d de %2$d + Una libreta que solo tú puedes abrir. + Notas y tareas, cifradas en este teléfono. La configuración lleva un minuto. + Sin cuenta + Sin registro, sin correo, sin restablecer contraseñas. + Funciona sin conexión + Encly nunca se conecta a internet. + Cifrado en el dispositivo + Tu caja fuerte está cerrada con una llave que solo tienes tú. + Empezar + Tengo una copia de seguridad + Elige un PIN + De 6 a 12 dígitos. Desbloquea Encly y protege la llave de tu caja fuerte. No hay correo para restablecerlo. + Vuelve a introducir el PIN + Para saber que es el que querías. + El PIN sigue funcionando como alternativa + Doce palabras lo recuperan todo + Encly te dará una frase de recuperación. Escríbela en papel; es la única llave de repuesto. + ¿Olvidaste tu PIN? + La frase desbloquea tu caja fuerte y te permite poner un PIN nuevo. + Abre tus copias + Los archivos de copia cifrados solo se pueden leer con estas palabras. + Teléfono nuevo + El archivo de copia y la frase restauran cada nota, tarea y etiqueta. + Nadie puede recuperarla por ti, ni siquiera nosotros. Si pierdes el PIN y la frase, las notas se pierden. + Crear mi frase + Omitir: solo PIN, sin copias + Escribe estas palabras + En papel, en este orden. Ni en una captura ni en la nube. + Ocultar palabras + Mostrar palabras + Ya las escribí + Comprueba tres palabras + Escríbelas desde tu papel, no de memoria. + Palabra n.º %1$d + Introduce la palabra + Esa no es la palabra n.º %1$d + Confirmar + Ver las palabras otra vez + Tu caja fuerte está lista + Todo lo que escribas a partir de ahora se cifra antes de guardarse. + PIN configurado + Se pide cada vez que abres Encly + Desbloqueo con huella activado + Probar la huella otra vez + Cámbialo en Ajustes, Seguridad + Frase de recuperación comprobada + Guarda el papel en un lugar seguro + Sin frase de recuperación + Las copias están desactivadas. Puedes crear una frase más tarde en Ajustes, Seguridad. + Copia restaurada + Tus notas, tareas y etiquetas han vuelto + Consejo: exporta de vez en cuando una copia cifrada desde Ajustes, Copia de seguridad. Tu frase es lo que la abre. + Abrir mi libreta + Restaurar y después crear un PIN + Elige tu archivo de copia de Encly y escribe las 12 palabras con las que se creó. + 1 · Archivo de copia + Toca para elegir otro archivo + 2 · Frase de recuperación + Estas palabras también serán la frase de recuperación de este teléfono. + Notas + Bloquear ahora + Buscar en títulos y texto + Opciones de vista + Nada coincide con «%1$s» + Hoy + Ayer + + Tareas · %d pendiente + Tareas · %d pendientes + Tareas · %d pendientes + + Tareas · ninguna pendiente + Aún no hay tareas + Toca Nueva tarea para añadir la primera. + Todo hecho + Hechas · %1$d + No hay notas con la etiqueta «%1$s» + Aquí aparecerán las notas con esta etiqueta. + Mostrar todas las notas + La papelera está vacía + Toca una nota para leerla. Mantén pulsado para seleccionar. + Eliminar para siempre + Vaciar papelera + Buscar notas + Notas y tareas + Versión, privacidad y contacto + Respuestas a preguntas frecuentes + Licencias de código abierto + Las bibliotecas y fuentes que usa Encly + Hecho con código abierto + Encly se hace con estas bibliotecas y fuentes. Toca una para leer su licencia. + Bibliotecas + Fuentes + Aún no hay etiquetas + Las etiquetas agrupan tus notas. Crea una arriba y elígela para una nota con una pulsación larga. + Toca una etiqueta para renombrarla, ocultarla o borrarla. Arrastra el asa para reordenar. + Añadir etiqueta + Nombre + Mostrar en la lista de notas + Una etiqueta oculta conserva sus notas pero no tiene filtro + Arrastra para reordenar + Subir + Bajar + + %d seleccionada + %d seleccionadas + %d seleccionadas + + Editar etiquetas + Título + Sin guardar + Gestionar etiquetas + ¿Descartar los cambios? + Se perderá todo lo que has cambiado desde que abriste esta nota. Esta acción no se puede deshacer. + Todo ya es tuyo + Todas las funciones están disponibles para todos. Nada está bloqueado tras un pago. + Sin anuncios ni rastreo + Encly nunca ve tus notas y no gana nada con tus datos. Solo las donaciones lo financian. + En qué se usa el dinero + Dispositivos de prueba (el próximo objetivo: un Mac mini para compilar y probar) y tiempo para versiones y correcciones. + Tarjeta o PayPal, una vez o cada mes + Abre ko-fi.com en tu navegador. La propia app Encly nunca gestiona pagos. + ¿Qué hace una pulsación larga sobre una nota? + Abre las acciones de la nota: cambiar su etiqueta, añadir una breve descripción que se ve en la tarjeta, duplicarla o moverla a la papelera. + ¿Cómo recupero una nota eliminada? + Las notas eliminadas van a la Papelera del menú. Allí puedes restaurar una nota o eliminarla para siempre. Encly nunca vacía la papelera por sí solo. + ¿Qué hace «Bloquear la edición»? + Deja la nota en solo lectura para que no se cambie por accidente. Aún puedes seleccionar y copiar el texto. Actívalo o desactívalo desde el menú del editor. + ¿Cómo funcionan las copias de seguridad? + En Ajustes → Copia de seguridad exportas un archivo cifrado que solo tu frase de recuperación puede abrir. Para restaurar, elige el archivo, escribe o pega las 12 palabras y luego combínalo con esta bóveda o reemplázalo todo. + Mostrar teclado + Ocultar teclado + ¿Borrar todos los datos? + No se pudieron borrar los datos. No se ha cambiado nada. + Borrar + Subtareas + Subtarea + Añadir subtarea + Eliminar subtarea + Todas las subtareas están hechas + Completar tarea + Mostrar subtareas + Ocultar subtareas + Expandido + Contraído + Subtarea eliminada + Siguiente paso: %1$s + Primer paso + + %1$d de %2$d subtarea completada + %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 + Fijar + Desfijar + Archivar + Desarchivar + Fijadas + Otras + Archivo + El archivo está vacío + Las notas archivadas salen de la lista y de la búsqueda, pero siguen en la bóveda. Archiva una nota desde su menú. + No se pudo archivar la nota + No se pudo sacar la nota del archivo + Color + Sin color + Rojo + Naranja + Amarillo + Verde + Turquesa + Azul + Morado + Rosa + Exportar como Markdown + Copiar como Markdown + Copiado como Markdown + Exportación legible + Guarda todas las notas y tareas como archivos .md legibles en un .zip. El archivo no está cifrado. + ¿Exportar sin cifrado? + El archivo exportado no está cifrado. Cualquiera que lo obtenga puede leer tus notas. Dondequiera que lo guardes, incluida una carpeta en la nube, habrá una copia legible que Encly no puede proteger ni recuperar. A continuación confirmarás con tu PIN. + Exportación Markdown guardada + ¿Puedo exportar mis notas como Markdown? + Sí. En el menú de una nota elige «Exportar como Markdown» para obtener un archivo .md, o usa Ajustes → Copia de seguridad → «Exportar como Markdown» para un .zip con todas las notas (las archivadas en una carpeta archive, sin la papelera) y un archivo de tareas. Estos archivos no están cifrados: Encly te avisa antes, te pide el PIN y solo guarda donde tú elijas. + Acciones al deslizar + Deslizar notas + Desliza una nota para actuar sobre ella + Deslizar a la derecha + Deslizar a la izquierda + Fijar o desfijar + Siguiente color + Mover a la papelera + Nada + ¿Mover a la papelera? + Podrás restaurar la nota desde la papelera. + ¿Qué hacen los deslizamientos en una nota? + Por defecto, deslizar una nota a la derecha la fija o la desfija, y a la izquierda la archiva. En Ajustes → Acciones al deslizar puedes elegir para cada dirección: fijar, archivar, siguiente color, mover a la papelera (siempre pregunta) o nada, o desactivar los deslizamientos. TalkBack ofrece las mismas acciones en el menú de acciones de la nota. + Texto de las notas + El valor predeterminado de cada nota. Una nota puede fijar el suyo desde su menú. + Usar el texto predeterminado + Código fuente + Encly es de código abierto: lee el código en GitHub + Abrir + Abrir en una pestaña nueva + Elige una nota o empieza una nueva + Archivo + Ver + Ventana + Ayuda + Salir + Minimizar + Nueva ventana + Abrir en una ventana nueva + Exportar como PDF + Imprimir + ¿Exportar a PDF sin cifrado? + El PDF no está cifrado. Cualquiera que lo obtenga puede leer tu nota. Dondequiera que lo guardes, incluida una carpeta en la nube, habrá una copia legible que Encly no puede proteger ni recuperar. A continuación confirmarás con tu PIN. + ¿Imprimir sin cifrado? + Al imprimir, el texto de tu nota se envía a la impresora sin cifrar. La cola de impresión del sistema puede conservar copias temporales, y una impresora de red o en la nube también puede guardarlo. Encly no puede eliminarlas. A continuación confirmarás con tu PIN. + PDF guardado + Enviado a la impresora + Esta nota está abierta para editar en otra ventana. Aquí es de solo lectura. + Novedades + Los cambios de esta versión + Entendido + Las notas se eliminan definitivamente tras 28 días en la papelera. + ¿Qué longitud puede tener mi PIN? + Entre 6 y 12 dígitos, a tu elección. No se ofrecen PIN más cortos: el bloqueo y la clave del dispositivo limitan los intentos, y 6 dígitos es lo más corto que sigue siendo seguro con esos límites. La pantalla de bloqueo nunca muestra la longitud de tu PIN: pulsas «Desbloquear» cuando terminas, y lo mismo vale para el PIN de borrado y el PIN de coacción. Los PIN que ya tenías siguen funcionando igual. + Código + Tabla + Añadir fila + Añadir columna + Eliminar fila + Eliminar columna + Fila de encabezado + Opciones de celda + Barra del editor + Elige y ordena las herramientas + Arrastra el asa para reordenar. El ojo oculta una herramienta o la devuelve. Las herramientas de «Más opciones» quedan tras la flecha. + Restablecer + Barra + Más opciones + Ocultas + Ocultar herramienta + Mostrar herramienta + Subir + Bajar + + %1$d etiqueta más + %1$d etiquetas más + %1$d etiquetas más + + diff --git a/app/src/main/res/values-es/strings_handoff.xml b/core/resources/src/commonMain/strings/values-es/strings_handoff.xml similarity index 100% rename from app/src/main/res/values-es/strings_handoff.xml rename to core/resources/src/commonMain/strings/values-es/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values-es/strings_import.xml b/core/resources/src/commonMain/strings/values-es/strings_import.xml new file mode 100644 index 00000000..b27f95d4 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-es/strings_import.xml @@ -0,0 +1,79 @@ + + + + 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 + Notas de tareas importadas como notas normales: %1$d + Elementos cifrados o no compatibles omitidos: %1$d + Archivos que no se pudieron usar: %1$d + Archivos dentro de archivos, sin abrir: %1$d + Importando… + Importado + %1$d añadidas, %2$d omitidas por duplicadas, %3$d sin importar. + Tus archivos originales no se han modificado. Bórralos tú cuando hayas revisado las notas aquí. + ¿De qué app son? + Los archivos parecen venir de más de una app. Elige la que quieres importar; el resto se omite. + No se importó nada + Esta copia de Standard Notes está cifrada. Descarga una copia descifrada en Standard Notes y elígela. + Es una copia cifrada de Notesnook. En Notesnook exporta las notas como Markdown y elige el .zip. + Estos archivos no son una exportación compatible. Mira las apps compatibles o elige archivos Markdown o de texto. + Esta selección es mayor de lo que Encly puede importar de una vez (64 MB de notas, 2 GB en total o 20 000 notas). Impórtala por partes. + No se encontraron notas en esta selección. + No se pudieron leer los archivos elegidos. + Este teléfono no tiene ninguna app selectora de archivos que Encly pueda usar. + La caja fuerte se cerró durante la importación. Desbloquea Encly e inténtalo de nuevo. + Algo salió mal y no se importó nada. Tu caja fuerte no ha cambiado. + %1$d de %2$d seleccionadas + Seleccionar todo + Quitar selección + Todas + Solo nuevas + En la papelera + Sin título + Lista de tareas + En la papelera + Ya en la caja fuerte + Importar %1$d + Elegir de nuevo + diff --git a/core/resources/src/commonMain/strings/values-es/strings_security.xml b/core/resources/src/commonMain/strings/values-es/strings_security.xml new file mode 100644 index 00000000..c5bd89cd --- /dev/null +++ b/core/resources/src/commonMain/strings/values-es/strings_security.xml @@ -0,0 +1,146 @@ + + + Escribe o pega tus 12 palabras + Palabra %1$d de %2$d + La palabra %1$d no es de una frase de recuperación. Revisa cómo está escrita. + Estas 12 palabras no forman una frase de recuperación válida. Revisa cada palabra y su orden. + Esta caja fuerte ya tiene una frase de recuperación + ¿Añadir a tus notas o reemplazarlas? + Esta copia contiene %1$s, %2$s y %3$s. + Todo lo de aquí se queda y se añade lo que falta. Lo que ya está se omite. + Reemplazar todo + Borra todas las notas, tareas y etiquetas de este teléfono y luego restaura la copia. + Guarda todas tus notas, tareas y etiquetas en un archivo cifrado y restáuralas en cualquier teléfono. + Exportar + Importar + Tus notas están cifradas. Tu PIN (y tu huella, si está activada) las desbloquea. + Tus notas están cifradas. Tu PIN (y tu huella, si está activada) las desbloquea, y tu frase de recuperación si olvidas el PIN. + Creada durante la configuración. Encly nunca guarda las palabras y no puede volver a mostrarlas. ¿No las tienes? Sustituye la frase abajo. + Configurada + Después del restablecimiento puedes restaurar un archivo de copia con sus 12 palabras. + No se puede abrir tu bóveda ahora + Encly no pudo leer sus datos hace un momento. Puede que otra app o el sistema los esté usando, o que el dispositivo siga bloqueado. Tus notas no se han perdido. Inténtalo de nuevo en un momento. + Reintentar + Sigue sin poder leerse. Inténtalo de nuevo en un momento. + Cambiar PIN + + %d nota + %d de notas + %d notas + + + %d tarea + %d de tareas + %d tareas + + + %d etiqueta + %d de etiquetas + %d etiquetas + + + Tu PIN ya no puede desbloquear Encly en este teléfono: su clave de hardware se ha restablecido. Usa tu frase de recuperación (o tu huella) y luego establece un PIN nuevo. + El dispositivo indica que sigue bloqueado, así que Encly se quedó cerrado. Desbloquea el dispositivo e inténtalo de nuevo. + + Demasiados intentos. Vuelve a intentarlo en %d minuto + Demasiados intentos. Vuelve a intentarlo en %d minutos + Demasiados intentos. Vuelve a intentarlo en %d minutos + + + Demasiados intentos. Vuelve a intentarlo en %d hora + Demasiados intentos. Vuelve a intentarlo en %d horas + Demasiados intentos. Vuelve a intentarlo en %d horas + + Reemplazar la frase de recuperación + Obtén 12 palabras nuevas. Las copias hechas antes siguen necesitando las antiguas. + ¿Reemplazar la frase de recuperación? + Obtendrás 12 palabras nuevas. La frase antigua deja de desbloquear tus notas en este teléfono. Las copias de seguridad que ya hiciste solo se abren con la frase antigua, así que guárdala mientras conserves esas copias, o haz una copia nueva después. + Obtener palabras nuevas + Tu frase antigua deja de funcionar aquí + Cuando confirmes, solo estas palabras desbloquearán tus notas si olvidas el PIN. Las copias hechas antes siguen necesitando las palabras antiguas. + Frase de recuperación reemplazada. Haz una copia de seguridad nueva para usarla también en las copias. + Privacidad + Privacidad estricta del teclado + Pide al teclado que no muestre sugerencias, no use predicción en la nube y no lea el texto junto al cursor. Desactiva la autocorrección, y algunos teclados ocultan los emojis o la entrada por voz. + Permitir capturas de pantalla hasta que la app se bloquee + Las capturas y la grabación de pantalla siguen bloqueadas salvo que las permitas aquí. Se desactiva solo cuando Encly se bloquea. + ¿Permitir capturas de pantalla? + Las capturas pueden acabar en tu galería y en copias de seguridad en la nube, con tus notas. La pantalla de bloqueo, la introducción del PIN y la frase de recuperación siguen protegidas. El permiso termina cuando Encly se bloquea. + Bloquear al salir + Cuánto tiempo sigue abierto Encly cuando cambias a otra app. Apagar la pantalla o bloquear el teléfono siempre lo bloquea al momento. + Inmediatamente + Tras 15 segundos + Tras 30 segundos + Tras 1 minuto + Tras 2 minutos + Tras 5 minutos + Tras 10 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. + Un PIN ligado a este teléfono + Tu PIN se combina con una clave del chip seguro del teléfono, así que no se puede adivinar en ningún otro sitio. + Una cerradura para cada acceso + El PIN, la frase de recuperación y la huella abren cada uno su propio espacio cifrado. La huella se pide siempre. + Zona de peligro + Mantén 5 segundos para borrar + + PIN de borrado + Un segundo PIN que borra tus notas si lo escribes en la pantalla de bloqueo + Se desactivó al restablecerse la clave de tu PIN. Vuelve a configurarlo si lo usabas. + Si alguien te obliga a abrir Encly, escribe el PIN de borrado en lugar de tu PIN. Encly borra tus notas, tareas y etiquetas sin dejar señal y se abre como una bóveda vacía. Solo funciona en la pantalla de bloqueo. + Tras el borrado, Encly se abre con el PIN de borrado: pasa a ser el PIN de la nueva bóveda vacía. Tu PIN anterior deja de funcionar, así que quien te vio escribir el PIN de borrado puede volver a abrir Encly con él sin notar nada raro. + Los archivos de copia que ya exportaste no se tocan. Se siguen abriendo con tu frase de recuperación y son la única forma de volver atrás. + El desbloqueo biométrico sigue activado: alguien puede obligarte a poner el dedo en el sensor. Desactívalo si eso es un riesgo para ti. + Desactivar el desbloqueo biométrico + Te protege cuando alguien te obliga a desbloquear, no frente a un análisis forense del teléfono. Durante un bloqueo por intentos fallidos el PIN de borrado tampoco funciona. Encly nunca muestra si hay un PIN de borrado: uno nuevo sustituye al anterior, y si cambias tu PIN al PIN de borrado, este se desactiva. + Configurar PIN de borrado + Desactivar PIN de borrado + Confirma tu PIN para configurar el PIN de borrado + Crea un PIN de borrado + De 6 a 12 dígitos, distintos de tu PIN + Confirma el PIN de borrado + Vuelve a escribir el PIN de borrado + El PIN de borrado debe ser distinto de tu PIN + No se pudo guardar el PIN de borrado. Inténtalo de nuevo. + 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 + De 6 a 12 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 + Mostrar vista previa de las notas + La lista de notas muestra las primeras líneas de cada nota. Desactívalo para mostrar solo el título, la etiqueta y la fecha; la búsqueda sigue encontrando todo el texto. + Dígitos introducidos: %1$d + Desbloquear + Frase de recuperación aceptada + Tu PIN sigue funcionando. Consérvalo o elige uno nuevo. + Conservar mi PIN + Establecer un PIN nuevo + diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml new file mode 100644 index 00000000..edab7131 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -0,0 +1,729 @@ + + + À propos + Un espace local et chiffré pour vos notes, idées et tâches, sans synchronisation cloud. + Protection des données + Les données du coffre-fort sont stockées localement dans une base de données SQLCipher chiffrée + Questions fréquentes + Commentaires + Signaler un bug ou proposer une amélioration sur GitHub + Pourquoi c\'est important et comment aider sur Ko-fi + Aucun navigateur trouvé. Ouvrez le lien manuellement : %1$s + Aucune application de messagerie trouvée + Politique de confidentialité + Comment nous stockons et protégeons vos données + Noter l\'application + Laissez une note sur le Google Play Store + Assistance et contact + Contacter le développeur + Si vous avez des questions ou des suggestions, vous pouvez m\'écrire par e-mail. + + Continuer + + Ajouter + + Toutes + + 6 à 12 chiffres. Appuyez pour modifier votre code PIN. + Code PIN + Authentification + + Retour + + La modification de la protection biométrique n\'a pas été confirmée + Impossible de créer la clé biométrique + Confirmez votre identité + Authentification + Confirmez votre biométrie + Accès à la clé + Confirmez votre biométrie pour modifier la protection + Confirmation de sécurité + Confirmez votre biométrie pour accéder à la clé chiffrée + Déverrouiller Encly + Utiliser le code PIN + L\'authentification biométrique n\'est pas disponible + + Plus d\'outils de bloc + Liste à puces + Liste de contrôle + Titre + Titre %1$d + Lien + Liste + Verrouiller la liste de tâches + Déverrouiller la liste de tâches + Sélectionner le bloc + Réorganiser l\'élément + Monter l\'élément + Descendre l\'élément + Mettre l\'élément en retrait + Réduire le retrait de l\'élément + Blocs sélectionnés : %1$d + Effacer la sélection + Copier + Couper + Supprimer les blocs sélectionnés + Tout sélectionner + Copié + Déplacer le bloc vers le bas + Déplacer le bloc vers le haut + Liste numérotée + Séparateur + + Annuler + + + Voulez-vous vraiment supprimer définitivement ces notes ? Cette action est irréversible. + Voulez-vous vraiment vider définitivement la corbeille ? Cette action est irréversible. + Vider la corbeille ? + + Effacer + + Fermer + + Réduire + + Créer un libellé + + Aujourd\'hui %1$s + Hier %1$s + + Supprimer + Supprimer le bloc + Supprimer + Supprimer le libellé + Ce libellé sera retiré de toutes vos notes. Les notes elles-mêmes ne seront pas supprimées. + + Supprimer + Annuler + Voulez-vous vraiment supprimer définitivement cette note ? Cette action est irréversible. + Supprimer la note ? + + Encly est gratuit et open source, sans publicité, sans pistage et sans compte. Les dons sont son seul revenu. + Soutenir le développement + + Terminé + + Dupliquer + + Couleurs dynamiques + + Modifier + + Verrouiller la modification + + Pas encore de notes + Touchez Nouvelle note pour écrire la première. + Les notes supprimées restent ici jusqu’à ce que vous les restauriez ou les supprimiez définitivement. + + Saisissez un lien + + Impossible d\'ouvrir la base de données + + Développer + + Impossible de charger le contenu + + Encly enregistre vos modifications puis, après le délai choisi dans Paramètres → Sécurité (15 secondes par défaut), ferme la base chiffrée et efface la clé de session. Éteindre l’écran ou verrouiller le téléphone le fait immédiatement. Vous déverrouillez ensuite à nouveau. + Que se passe-t-il quand je quitte l\'application ? + Utilisez l\'interrupteur de biométrie dans Paramètres → Sécurité. La création ou la suppression de l\'emplacement biométrique nécessite une authentification biométrique forte. + Comment activer ou désactiver la biométrie ? + Ouvrez Paramètres → Sécurité, confirmez votre code PIN actuel, puis saisissez deux fois le nouveau. + Comment modifier mon code PIN ? + Encly génère une clé de base de données aléatoire de 256 bits. Votre code PIN protège un emplacement de déverrouillage AES-GCM distinct, et la biométrie utilise une clé Android Keystore qui exige une authentification à chaque utilisation. + Comment la clé de la base de données est-elle protégée ? + Sur l’écran de verrouillage, touchez « Récupérer l’accès » et saisissez vos 12 mots. Si votre code PIN fonctionne encore, vous pouvez le garder ou définir un nouveau PIN ; sinon, vous définissez tout de suite un nouveau PIN. Sans phrase de récupération, il n’y a pas d’autre moyen d’entrer : personne, pas même le développeur, ne peut la réinitialiser. + Et si j\'oublie mon code PIN ? + Ouvrez Paramètres → Langue et choisissez l\'une des langues disponibles, ou « Langue du système » pour suivre votre appareil. Le changement s\'applique immédiatement. + Comment changer la langue ? + Touchez « Nouvelle note » sur l’écran d’accueil. La barre au-dessus du clavier ajoute des titres, des listes de contrôle, des listes à puces et numérotées, des citations, des liens et des séparateurs. + Comment créer une nouvelle note ? + C’est une clé de secours de 12 mots. Elle déverrouille le coffre si vous oubliez votre code PIN et c’est la seule clé de vos fichiers de sauvegarde. Vous la créez lors de la configuration, ou plus tard dans Paramètres → Sécurité si vous l’avez ignorée. Gardez-la sur papier, hors ligne. + À quoi sert la phrase de récupération ? + Ouvrez « À propos » et utilisez l\'option de commentaires, ou écrivez au développeur. N\'incluez jamais votre phrase de récupération, votre code PIN ni le texte privé de vos notes dans un rapport de bug. + Comment signaler un bug ? + Automatiquement pendant la saisie, et quitter l’éditeur termine le dernier enregistrement. Si un enregistrement échoue, l’éditeur affiche « Non enregistré » en rouge. + Comment les modifications sont-elles enregistrées ? + Notes, tâches, chiffrement local et récupération de l\'accès, sans synchronisation cloud. + Réponses aux questions les plus courantes + Pas par défaut. La protection de l\'écran est active : Encly bloque les captures d\'écran, l\'enregistrement de l\'écran et les aperçus non protégés dans la liste des applications récentes. Si vous en avez besoin, Paramètres → Sécurité → « Autoriser les captures d’écran jusqu’au verrouillage » la lève après votre code PIN ; les écrans de verrouillage, de PIN et de phrase de récupération restent protégés, et l\'autorisation prend fin au verrouillage d\'Encly. + Puis-je faire des captures d\'écran d\'Encly ? + Les bases + Sécurité et récupération + Paramètres + Dépannage + Les notes, libellés et tâches sont stockés uniquement dans la base de données SQLCipher locale d\'Encly. Il n\'y a ni synchronisation cloud ni sauvegarde automatique : vous pouvez exporter vous-même une sauvegarde chiffrée dans Paramètres → Sauvegarde, ou une copie Markdown lisible, qui n\'est pas chiffrée. + Où sont stockées mes données ? + Les libellés regroupent vos notes. Vous pouvez les créer, les renommer, les masquer et les supprimer, les réordonner par glisser-déposer et filtrer l’écran d’accueil par libellé. + Comment fonctionnent les libellés ? + Les tâches ont une description et une priorité. Vous pouvez les filtrer par priorité et les marquer comme terminées. Encly n\'affiche jamais le texte des tâches dans les notifications système. + Que permettent les tâches ? + Dans Paramètres → Apparence, choisissez l’un des cinq thèmes, le mode clair, sombre ou système, l’un des trois styles de police et la taille du texte. Sur Android 12 et plus, la couleur d’accent peut aussi venir du fond d’écran. + Comment changer de thème ? + Encly ne crée volontairement jamais une base de données vide à la place d\'un coffre-fort perdu ou illisible. Ne confirmez pas l\'effacement complet si vous avez encore besoin de vos données locales. + Encly indique que le coffre-fort est endommagé + + Taille du texte + Espacement des lignes + Par défaut + Compact + Aéré + Éditorial + Moderne + Technique + + + Choisir la langue + Langue du système + + Ajouter un lien + + Élément de liste + + + Retour au code PIN + Récupérer l\'accès + Saisissez votre phrase de récupération de 12 mots + Récupération + Saisissez votre code PIN pour continuer + Déverrouillez vos notes + Déverrouillage… + Récupérer l\'accès avec votre phrase de récupération + Code PIN incorrect + Phrase de récupération incorrecte + + Maintenez pour tout effacer + Les données chiffrées ou les clés de chiffrement semblent avoir été modifiées ou endommagées ; la récupération est donc impossible.\n\nPour continuer à utiliser l\'application, vous devez la réinitialiser et effacer les données endommagées. Toutes les notes et tous les paramètres enregistrés seront définitivement supprimés. + Désolé, vos données sont endommagées + Ces données viennent d\'une version antérieure + Encly a trouvé des notes enregistrées par une version antérieure à la 2.0. Encly 2.0 utilise un nouveau format chiffré et ne peut pas les ouvrir, et il est impossible de les convertir.\n\nPour utiliser l\'application, repartez de zéro. Les anciennes notes et les anciens réglages de ce téléphone seront définitivement supprimés. + Maintenez pour repartir de zéro + + FAQ + Paramètres + Soutenir le développeur + Libellés + Tâches + Corbeille + + Plus d\'options + + + Sans libellé + + Nouvelle note + Impossible de supprimer la note + Description… + Annuler toutes les modifications + Impossible d\'annuler les modifications + Impossible de créer une copie + Impossible de lire le contenu de la note. La modification est désactivée pour ne pas endommager les données. + + %1$s (copie) + Impossible de restaurer la note + Impossible d\'enregistrer les modifications + Impossible de déplacer la note dans la corbeille + + Impossible d\'initialiser l\'application + Impossible de créer la phrase de récupération + Impossible de créer le coffre-fort chiffré + + Ouvrir le menu + + %1$d %% + + Saisissez à nouveau le code PIN pour confirmer + Confirmez votre code PIN + Confirmez votre code PIN actuel avant de modifier votre clé d\'accès + Saisissez votre code PIN actuel + 6 à 12 chiffres pour protéger vos notes + Code PIN modifié + Créez un code PIN + Le code PIN actuel est incorrect + Effacer le chiffre + Les codes PIN ne correspondent pas. Veuillez réessayer. + Impossible d\'enregistrer le code PIN + Impossible de mettre à jour le code PIN. Veuillez réessayer. + + + Appuyez pour commencer à écrire… + + Haute + Basse + Moyenne + Choisissez une priorité + + Citation + Rétablir + + Restaurer + + Enregistrer + + + + + Effacer la recherche + + Empreinte digitale + Déverrouillage biométrique + Non disponible sur cet appareil + Aucune empreinte enregistrée. Ajoutez-en une dans les paramètres du système. + Le chiffrement est actif + Non configurée. Sans elle, un PIN oublié signifie tout perdre. Touchez pour en créer une. + Effacer toutes les données + Supprimer toutes les notes, tâches et libellés ainsi que les clés de chiffrement de cet appareil + Toutes les notes, tâches et libellés ainsi que les clés de chiffrement seront supprimés de cet appareil, et Encly repartira de zéro. Action irréversible : exportez d\'abord une sauvegarde si vous voulez conserver quelque chose. + Chiffrement et protection des données + Sécurité + + + Apparence + Général + Langue + Confidentialité + Afficher les tâches + Afficher la carte des tâches sur l\'écran d\'accueil + Édition simple + Désactiver les blocs dynamiques et modifier les notes en texte brut + + Trier par : + Par date de création (plus anciennes d\'abord) + Créée ↑ + Par date de création (plus récentes d\'abord) + Créée ↓ + Par titre (A à Z) + Titre A–Z + Par titre (Z à A) + Titre Z–A + Par dernière modification (plus anciennes d\'abord) + Modifiée ↑ + Par dernière modification (plus récentes d\'abord) + Modifiée ↓ + Soutenir le développeur + + Impossible d\'enregistrer le libellé. Veuillez réessayer. + Impossible de supprimer le libellé. Veuillez réessayer. + Modifier le libellé + Libellé masqué + Un libellé portant ce nom existe déjà + Impossible d\'enregistrer l\'ordre des libellés. Veuillez réessayer. + Impossible de mettre à jour le libellé. Veuillez réessayer. + + Appuyez à nouveau pour supprimer + + Nouvelle tâche + Impossible d\'effacer les tâches terminées. Veuillez réessayer. + Voulez-vous vraiment supprimer toutes les tâches terminées ? Cette action est irréversible. + Effacer les tâches terminées + Terminée : %1$s + Impossible d\'enregistrer la tâche. Veuillez réessayer. + Description + Modifier la tâche + Aucune tâche active. + Aucune tâche ne correspond aux filtres sélectionnés. + Toutes les tâches + Terminées + Nouvelle tâche + Progression + Impossible de modifier l\'état de la tâche. Veuillez réessayer. + Impossible de mettre à jour la tâche. Veuillez réessayer. + + Texte + + Thème + Sombre + Clair + Système + + + Annuler + + Sans titre + + Passer à l\'affichage en grille + Passer à l\'affichage en liste + + + Supprimer %d note ? + Supprimer %d notes ? + Supprimer %d notes ? + + + Trop de tentatives. Réessayez dans %d seconde + Trop de tentatives. Réessayez dans %d secondes + Trop de tentatives. Réessayez dans %d secondes + + + %1$d sur %2$d tâche terminée + %1$d sur %2$d tâches terminées + %1$d sur %2$d tâches terminées + + Sauvegarde + Sauvegarde chiffrée, import et Markdown + Sauvegarde + Chiffrée avec votre phrase de récupération + Une sauvegarde contient toutes vos notes, tâches et libellés, chiffrés avec votre phrase de récupération de 12 mots. Encly ne l\'envoie nulle part : vous choisissez où le fichier est enregistré. Sur un nouveau téléphone, les mêmes 12 mots la restaurent. + Dernier export : %1$s + Aucune sauvegarde exportée pour l\'instant + Exporter une sauvegarde + Importer une sauvegarde + Restaurer notes, tâches et libellés depuis un fichier de sauvegarde + Sauvegarde enregistrée + Confirmez que c\'est vous + Saisissez votre PIN pour continuer + Phrase de récupération + Les sauvegardes sont chiffrées avec une phrase de récupération : seuls ses 12 mots peuvent les ouvrir. Ce coffre n\'en a pas encore. Créez-la maintenant : elle permet aussi de déverrouiller ce coffre-fort si vous oubliez votre PIN. + Créer une phrase de récupération + Notez ces 12 mots sur papier, dans l\'ordre, et gardez-les hors ligne. Quiconque les possède peut ouvrir vos sauvegardes. + Je les ai notés + Les mots ne correspondent pas. Vérifiez ce que vous avez noté. + Phrase de récupération enregistrée + Confirmez votre phrase de récupération + Saisissez une fois la phrase de récupération de 12 mots de ce coffre-fort pour qu\'Encly puisse chiffrer les sauvegardes avec elle. + Saisir la phrase de récupération + Saisissez les 12 mots de la phrase de récupération avec laquelle cette sauvegarde a été faite. + Déchiffrer + Importer une sauvegarde + Fusionner + Remplacer + Remplacer toutes les données ? + Toutes les notes, tâches et libellés de ce coffre-fort seront supprimés et remplacés par la sauvegarde. Cette action est irréversible. + Importé : notes %1$d, tâches %2$d, libellés %3$d. Ignoré (déjà présent) : %4$d. + Ce fichier n\'est pas une sauvegarde Encly + Cette sauvegarde a été créée par une version plus récente d\'Encly. Mettez l\'app à jour et réessayez. + Le fichier de sauvegarde est endommagé ou incomplet + Le fichier est trop volumineux pour être une sauvegarde Encly + Votre coffre-fort est trop volumineux pour un seul fichier de sauvegarde (64 Mo). Rien n\'a été enregistré. + Ce fichier existe déjà. Choisissez un autre nom : une ancienne sauvegarde n\'est jamais écrasée. + Aucune application de cet appareil ne peut ouvrir ou enregistrer des fichiers. + Ces mots n\'ouvrent pas cette sauvegarde, ou le fichier a été modifié + Saisissez une phrase de récupération valide de 12 mots + Le coffre-fort a été verrouillé. Déverrouillez-le et réessayez. + Impossible de lire ou d\'écrire la sauvegarde. Rien n\'a été modifié. + Ces mots ne sont pas la phrase de récupération de ce coffre-fort + Impossible d\'enregistrer la phrase de récupération + Restaurer une sauvegarde + Choisir un fichier de sauvegarde + La sauvegarde n\'a pas pu être restaurée, la configuration n\'est donc pas terminée. Réessayez, ou continuez avec un coffre-fort vide et importez le fichier plus tard dans Paramètres → Sauvegarde. + Sauvegarde non restaurée + Réessayer + Continuer sans + Cette version d\'Encly ne peut pas lire cette note. + Ouvrir le lien + Copier le lien + Modifier le lien + Lien copié + Aucune application de cet appareil ne peut ouvrir ce lien. + Seuls les liens web (http, https) et e-mail peuvent être enregistrés ou ouverts. + Impossible de lire vos notes + Impossible de lire vos libellés + Vos notes sont toujours stockées chiffrées sur cet appareil. Fermez Encly puis rouvrez-le pour réessayer. + Vous avez déverrouillé Encly avec votre phrase de récupération. Définissez un nouveau code PIN pour continuer. + Supprimer la tâche + Tâche supprimée + Impossible de supprimer la tâche. Veuillez réessayer. + Mode + Police + Papier + Forêt + Océan + Graphite + Minuit + Suit le réglage du système. Affichage clair. + Suit le réglage du système. Affichage sombre. + Toujours clair. + Toujours sombre. + Minuit est toujours sombre. + Prend la couleur d\'accent de votre fond d\'écran. Android 12 et versions ultérieures. + Les couleurs dynamiques sont activées : l\'accent vient de votre fond d\'écran. + Playfair Display et Source Sans 3 + Poppins et Source Sans 3 + IBM Plex Sans partout + Liste pour la visite de l\'appartement + Avant la visite : vérifier la pression de l\'eau, demander le coût du chauffage l\'hiver dernier. + Personnel + Aperçu + Thème, mode et police + Correct + Déverrouiller avec l\'empreinte + Étape %1$d sur %2$d + Un carnet que vous seul pouvez ouvrir. + Notes et tâches, chiffrées sur ce téléphone. La configuration prend environ une minute. + Aucun compte + Pas d\'inscription, pas d\'e-mail, pas de réinitialisation de mot de passe. + Fonctionne hors ligne + Encly ne se connecte jamais à Internet. + Chiffré sur l\'appareil + Votre coffre est verrouillé par une clé que vous seul détenez. + Commencer + J\'ai une sauvegarde + Choisissez un code PIN + 6 à 12 chiffres. Il déverrouille Encly et protège la clé de votre coffre. Aucun e-mail ne permet de le réinitialiser. + Saisissez à nouveau le code PIN + Pour être sûr que c\'est bien celui que vous vouliez. + Le code PIN reste disponible en secours + Douze mots pour tout récupérer + Encly va vous donner une phrase de récupération. Notez-la sur papier : c\'est la seule clé de secours. + Code PIN oublié ? + La phrase déverrouille votre coffre et permet de définir un nouveau code PIN. + Ouvre vos sauvegardes + Les fichiers de sauvegarde chiffrés ne peuvent être lus qu\'avec ces mots. + Nouveau téléphone + Le fichier de sauvegarde et la phrase restaurent chaque note, tâche et libellé. + Personne ne peut la récupérer à votre place, pas même nous. Si vous perdez le code PIN et la phrase, les notes sont perdues. + Créer ma phrase + Passer : code PIN seul, sans sauvegardes + Notez ces mots + Sur papier, dans cet ordre. Pas en capture d\'écran, pas dans le cloud. + Masquer les mots + Afficher les mots + Je les ai notés + Vérifiez trois mots + Recopiez-les depuis votre papier, pas de mémoire. + Mot n° %1$d + Saisissez le mot + Ce n\'est pas le mot n° %1$d + Confirmer + Revoir les mots + Votre coffre est prêt + Tout ce que vous écrivez désormais est chiffré avant d\'être enregistré. + Code PIN défini + Demandé à chaque ouverture d\'Encly + Déverrouillage par empreinte activé + Réessayer l\'empreinte + Modifiable dans Paramètres, Sécurité + Phrase de récupération vérifiée + Gardez le papier en lieu sûr + Aucune phrase de récupération + Les sauvegardes sont désactivées. Vous pourrez créer une phrase plus tard dans Paramètres, Sécurité. + Sauvegarde restaurée + Vos notes, tâches et libellés sont de retour + Astuce : exportez de temps en temps une sauvegarde chiffrée depuis Paramètres, Sauvegarde. C\'est votre phrase qui l\'ouvre. + Ouvrir mon carnet + Restaurer, puis définir un PIN + Choisissez votre fichier de sauvegarde Encly, puis saisissez les 12 mots avec lesquels il a été créé. + 1 · Fichier de sauvegarde + Touchez pour choisir un autre fichier + 2 · Phrase de récupération + Ces mots deviennent aussi la phrase de récupération de ce téléphone. + Notes + Verrouiller maintenant + Rechercher dans les titres et le texte + Options d\'affichage + Aucun résultat pour « %1$s » + Aujourd\'hui + Hier + + Tâches · %d en cours + Tâches · %d en cours + Tâches · %d en cours + + Tâches · aucune en cours + Pas encore de tâches + Touchez Nouvelle tâche pour ajouter la première. + Tout est fait + Terminées · %1$d + Aucune note avec l’étiquette « %1$s » + Les notes portant cette étiquette apparaîtront ici. + Afficher toutes les notes + La corbeille est vide + Touchez une note pour la lire. Appui long pour sélectionner. + Supprimer définitivement + Vider la corbeille + Rechercher des notes + Notes et tâches + Version, confidentialité et contact + Réponses aux questions fréquentes + Licences open source + Les bibliothèques et polices utilisées par Encly + Construit sur l’open source + Encly repose sur ces bibliothèques et polices. Touchez-en une pour lire sa licence. + Bibliothèques + Polices + Pas encore d’étiquettes + Les étiquettes regroupent vos notes. Créez-en une ci-dessus, puis choisissez-la pour une note par un appui long. + Touchez une étiquette pour la renommer, la masquer ou la supprimer. Faites glisser la poignée pour réordonner. + Ajouter l’étiquette + Nom + Afficher dans la liste des notes + Une étiquette masquée garde ses notes mais n’a pas de filtre + Faire glisser pour réordonner + Monter + Descendre + + %d sélectionnée + %d sélectionnées + %d sélectionnées + + Modifier les libellés + Titre + Non enregistré + Gérer les libellés + Annuler les modifications ? + Tout ce que vous avez modifié depuis l\'ouverture de cette note sera perdu. Cette action est irréversible. + Tout est déjà à vous + Chaque fonctionnalité est accessible à tous. Rien n’est bloqué derrière un paiement. + Sans pub ni pistage + Encly ne voit jamais vos notes et ne gagne rien avec vos données. Seuls les dons le financent. + À quoi sert l\'argent + Des appareils de test (prochain objectif : un Mac mini pour compiler et tester) et du temps pour les versions et les corrections. + Carte ou PayPal, ponctuel ou mensuel + Ouvre ko-fi.com dans votre navigateur. Encly lui-même ne gère jamais de paiement. + Que fait un appui long sur une note ? + Il ouvre les actions de la note : changer son libellé, ajouter une courte description affichée sur la carte, dupliquer la note ou la mettre à la corbeille. + Comment récupérer une note supprimée ? + Les notes supprimées vont dans la Corbeille du menu. Vous pouvez y restaurer une note ou la supprimer définitivement. Encly ne vide jamais la corbeille de lui-même. + Que fait « Verrouiller la modification » ? + La note passe en lecture seule pour éviter toute modification accidentelle. Vous pouvez toujours sélectionner et copier le texte. Activez-le ou désactivez-le depuis le menu de l’éditeur. + Comment fonctionnent les sauvegardes ? + Dans Paramètres → Sauvegarde, vous exportez un fichier chiffré que seule votre phrase de récupération peut ouvrir. Pour restaurer, choisissez le fichier, saisissez ou collez les 12 mots, puis fusionnez-le avec ce coffre ou remplacez tout. + 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 + Ajouter une sous-tâche + Supprimer la sous-tâche + Toutes les sous-tâches sont terminées + Terminer la tâche + Afficher les sous-tâches + Masquer les sous-tâches + Développé + Réduit + Sous-tâche supprimée + Prochaine étape : %1$s + Première étape + + %1$d sur %2$d sous-tâche terminée + %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 + Épingler + Désépingler + Archiver + Désarchiver + Épinglées + Autres + Archives + Les archives sont vides + Les notes archivées quittent la liste et la recherche mais restent dans le coffre-fort. Archivez une note depuis son menu. + Impossible d\'archiver la note + Impossible de désarchiver la note + Couleur + Aucune couleur + Rouge + Orange + Jaune + Vert + Sarcelle + Bleu + Violet + Rose + Exporter en Markdown + Copier en Markdown + Copié en Markdown + Export lisible + Enregistrer toutes les notes et tâches en fichiers .md lisibles dans un .zip. Le fichier n\'est pas chiffré. + Exporter sans chiffrement ? + Le fichier exporté n\'est pas chiffré. Quiconque l\'obtient peut lire vos notes. Là où vous l\'enregistrez, y compris dans un dossier cloud, se trouvera une copie lisible qu\'Encly ne peut ni protéger ni reprendre. Vous confirmerez ensuite avec votre PIN. + Export Markdown enregistré + Puis-je exporter mes notes en Markdown ? + Oui. Dans le menu d\'une note, choisissez « Exporter en Markdown » pour un fichier .md, ou allez dans Paramètres → Sauvegarde → « Exporter en Markdown » pour un .zip de toutes les notes (celles archivées dans un dossier archive, sans la corbeille) et un fichier de tâches. Ces fichiers ne sont pas chiffrés : Encly vous prévient d\'abord, demande votre PIN et n\'enregistre qu\'à l\'endroit que vous choisissez. + Actions par balayage + Balayer les notes + Balayez une carte de note pour agir dessus + Balayer vers la droite + Balayer vers la gauche + Épingler ou désépingler + Couleur suivante + Mettre à la corbeille + Rien + Mettre à la corbeille ? + Vous pourrez restaurer la note depuis la corbeille. + À quoi servent les balayages sur une note ? + Par défaut, balayer une note vers la droite l\'épingle ou la désépingle, et vers la gauche l\'archive. Dans Paramètres → Actions par balayage, vous choisissez pour chaque direction : épingler, archiver, couleur suivante, mettre à la corbeille (avec confirmation) ou rien, ou vous désactivez les balayages. TalkBack propose les mêmes actions dans le menu d\'actions de la note. + Texte des notes + La valeur par défaut de chaque note. Une note peut définir la sienne depuis son menu. + Utiliser les réglages de texte par défaut + Code source + Encly est open source : lisez le code sur GitHub + Ouvrir + Ouvrir dans un nouvel onglet + Choisissez une note ou commencez-en une nouvelle + Fichier + Affichage + Fenêtre + Aide + Quitter + Réduire + Nouvelle fenêtre + Ouvrir dans une nouvelle fenêtre + Exporter en PDF + Imprimer + Exporter en PDF sans chiffrement ? + Le PDF n\'est pas chiffré. Quiconque l\'obtient peut lire votre note. Là où vous l\'enregistrez, y compris un dossier dans le cloud, se trouvera une copie lisible qu\'Encly ne peut ni protéger ni reprendre. Vous confirmerez ensuite avec votre code PIN. + Imprimer sans chiffrement ? + L\'impression envoie le texte de votre note à l\'imprimante sans chiffrement. Le spouleur d\'impression du système peut en conserver des copies temporaires, et une imprimante réseau ou cloud peut aussi le stocker. Encly ne peut pas les supprimer. Vous confirmerez ensuite avec votre code PIN. + PDF enregistré + Envoyé à l\'imprimante + Cette note est ouverte en modification dans une autre fenêtre. Ici, elle est en lecture seule. + Nouveautés + Les changements de cette version + Compris + Les notes sont supprimées définitivement après 28 jours dans la corbeille. + Quelle longueur peut avoir mon code PIN ? + Entre 6 et 12 chiffres, à votre choix. Les codes plus courts ne sont pas proposés : le blocage et la clé de l’appareil limitent le nombre d’essais, et 6 chiffres est le minimum qui reste sûr avec ces limites. L’écran de verrouillage n’indique jamais la longueur de votre code : vous touchez « Déverrouiller » quand vous avez fini, de même pour le PIN d’effacement et le PIN de contrainte. Les codes définis auparavant continuent de fonctionner tels quels. + Code + Tableau + Ajouter une ligne + Ajouter une colonne + Supprimer la ligne + Supprimer la colonne + Ligne d\'en-tête + Options de la cellule + Barre d\'outils de l\'éditeur + Choisir et ordonner les outils + Faites glisser la poignée pour réordonner. L\'œil masque un outil ou le rétablit. Les outils de « Menu supplémentaire » se cachent derrière la flèche. + Réinitialiser + Barre d\'outils + Menu supplémentaire + Masqués + Masquer l\'outil + Afficher l\'outil + Monter + Descendre + + %1$d autre libellé + %1$d autres libellés + %1$d autres libellés + + diff --git a/app/src/main/res/values-fr/strings_handoff.xml b/core/resources/src/commonMain/strings/values-fr/strings_handoff.xml similarity index 100% rename from app/src/main/res/values-fr/strings_handoff.xml rename to core/resources/src/commonMain/strings/values-fr/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values-fr/strings_import.xml b/core/resources/src/commonMain/strings/values-fr/strings_import.xml new file mode 100644 index 00000000..cc036a07 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-fr/strings_import.xml @@ -0,0 +1,79 @@ + + + + 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 + Notes de tâche importées comme notes ordinaires : %1$d + Éléments chiffrés ou non pris en charge ignorés : %1$d + Fichiers inutilisables : %1$d + Archives dans des archives, non ouvertes : %1$d + Importation… + Importé + %1$d ajoutées, %2$d ignorées comme doublons, %3$d non importées. + Vos fichiers d\'origine n\'ont pas été modifiés. Supprimez-les vous-même une fois vos notes vérifiées ici. + De quelle application viennent-ils ? + Les fichiers semblent venir de plusieurs applications. Choisissez celle à importer ; le reste est ignoré. + Rien n\'a été importé + Cette sauvegarde Standard Notes est chiffrée. Téléchargez une sauvegarde déchiffrée dans Standard Notes et choisissez-la. + C\'est une sauvegarde Notesnook chiffrée. Dans Notesnook, exportez plutôt vos notes en Markdown et choisissez le .zip. + Ces fichiers ne sont pas un export pris en charge. Consultez les applications prises en charge ou choisissez des fichiers Markdown ou texte. + Cette sélection dépasse ce qu\'Encly peut importer d\'un coup (64 Mo de notes, 2 Go au total ou 20 000 notes). Importez-la en plusieurs parties. + Aucune note n\'a été trouvée dans cette sélection. + Les fichiers choisis n\'ont pas pu être lus. + Ce téléphone n\'a pas d\'application de sélection de fichiers qu\'Encly puisse utiliser. + Le coffre-fort s\'est fermé pendant l\'importation. Déverrouillez Encly et réessayez. + Une erreur s\'est produite et rien n\'a été importé. Votre coffre-fort est inchangé. + %1$d sur %2$d sélectionnées + Tout sélectionner + Tout désélectionner + Toutes + Nouvelles seulement + Dans la corbeille + Sans titre + Liste de tâches + Dans la corbeille + Déjà dans le coffre-fort + Importer %1$d + Choisir à nouveau + diff --git a/core/resources/src/commonMain/strings/values-fr/strings_security.xml b/core/resources/src/commonMain/strings/values-fr/strings_security.xml new file mode 100644 index 00000000..8bc23d8c --- /dev/null +++ b/core/resources/src/commonMain/strings/values-fr/strings_security.xml @@ -0,0 +1,146 @@ + + + Saisissez ou collez vos 12 mots + Mot %1$d sur %2$d + Le mot %1$d ne fait pas partie des mots de récupération. Vérifiez l\'orthographe. + Ces 12 mots ne forment pas une phrase de récupération valide. Vérifiez chaque mot et leur ordre. + Ce coffre-fort a déjà une phrase de récupération + Ajouter à vos notes ou les remplacer ? + Cette sauvegarde contient %1$s, %2$s et %3$s. + Tout ce qui est ici est conservé et ce qui manque est ajouté. Les éléments déjà présents sont ignorés. + Tout remplacer + Supprimer toutes les notes, tâches et étiquettes de ce téléphone, puis restaurer la sauvegarde. + Conservez toutes vos notes, tâches et étiquettes dans un fichier chiffré et restaurez-les sur n\'importe quel téléphone. + Exporter + Importer + Vos notes sont chiffrées. Votre code PIN (et votre empreinte, si elle est activée) les déverrouille. + Vos notes sont chiffrées. Votre code PIN (et votre empreinte, si elle est activée) les déverrouille, et votre phrase de récupération si vous oubliez le PIN. + Créée lors de la configuration. Encly ne conserve jamais les mots et ne peut pas les réafficher. Vous ne les avez plus ? Remplacez la phrase ci-dessous. + Configurée + Après la réinitialisation, vous pourrez restaurer un fichier de sauvegarde avec ses 12 mots. + Impossible d\'ouvrir votre coffre pour le moment + Encly n\'a pas pu lire ses données à l\'instant. Une autre appli ou le système les utilise peut-être, ou l\'appareil est encore verrouillé. Vos notes ne sont pas perdues. Réessayez dans un instant. + Réessayer + Toujours illisible. Réessayez dans un instant. + Changer le code PIN + + %d note + %d de notes + %d notes + + + %d tâche + %d de tâches + %d tâches + + + %d étiquette + %d d\'étiquettes + %d étiquettes + + + Votre PIN ne peut plus déverrouiller Encly sur ce téléphone : sa clé matérielle a été réinitialisée. Utilisez votre phrase de récupération (ou votre empreinte), puis définissez un nouveau PIN. + L’appareil indique qu’il est encore verrouillé, Encly est donc resté fermé. Déverrouillez l’appareil, puis réessayez. + + Trop de tentatives. Réessayez dans %d minute + Trop de tentatives. Réessayez dans %d minutes + Trop de tentatives. Réessayez dans %d minutes + + + Trop de tentatives. Réessayez dans %d heure + Trop de tentatives. Réessayez dans %d heures + Trop de tentatives. Réessayez dans %d heures + + Remplacer la phrase de récupération + Obtenez 12 nouveaux mots. Les sauvegardes faites avant nécessitent toujours les anciens. + Remplacer la phrase de récupération ? + Vous obtiendrez 12 nouveaux mots. L\'ancienne phrase ne déverrouille plus vos notes sur ce téléphone. Les sauvegardes déjà faites ne s\'ouvrent qu\'avec l\'ancienne phrase : gardez-la tant que vous conservez ces sauvegardes, ou faites une nouvelle sauvegarde ensuite. + Obtenir de nouveaux mots + Votre ancienne phrase ne fonctionne plus ici + Une fois confirmé, seuls ces mots déverrouillent vos notes si vous oubliez le PIN. Les sauvegardes faites avant nécessitent toujours les anciens mots. + Phrase de récupération remplacée. Faites une nouvelle sauvegarde pour l\'utiliser aussi pour les sauvegardes. + Confidentialité + Confidentialité stricte du clavier + Demande au clavier de ne pas faire de suggestions, pas de prédiction dans le cloud et pas de lecture autour du curseur. Désactive la correction automatique, et certains claviers masquent les emoji ou la saisie vocale. + Autoriser les captures d’écran jusqu’au verrouillage + Les captures et l’enregistrement d’écran restent bloqués tant que vous ne les autorisez pas ici. L’option se désactive d’elle-même quand Encly se verrouille. + Autoriser les captures d’écran ? + Les captures d’écran peuvent finir dans votre galerie et vos sauvegardes cloud, avec vos notes. L’écran de verrouillage, la saisie du PIN et la phrase de récupération restent protégés. L’autorisation prend fin au verrouillage d’Encly. + Verrouiller après la sortie + Durée pendant laquelle Encly reste ouvert quand vous passez à une autre app. Éteindre l’écran ou verrouiller le téléphone le verrouille toujours aussitôt. + Immédiatement + Après 15 secondes + Après 30 secondes + Après 1 minute + Après 2 minutes + Après 5 minutes + Après 10 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. + Un code PIN lié à ce téléphone + Votre code PIN est combiné à une clé de la puce sécurisée du téléphone : impossible de le deviner ailleurs. + Une serrure par moyen d’accès + Le code PIN, la phrase de récupération et l’empreinte ouvrent chacun leur propre emplacement chiffré. L’empreinte est demandée à chaque fois. + Zone de danger + Maintenez 5 secondes pour effacer + + PIN d\'effacement + Un second PIN qui efface vos notes s\'il est saisi sur l\'écran de verrouillage + Désactivé lors de la réinitialisation de la clé de votre PIN. Définissez-le à nouveau si vous en utilisiez un. + Si quelqu\'un vous force à ouvrir Encly, saisissez le PIN d\'effacement au lieu de votre PIN. Encly efface vos notes, tâches et libellés sans aucun signe et s\'ouvre comme un coffre vide. Cela ne fonctionne que sur l\'écran de verrouillage. + Après l\'effacement, Encly s\'ouvre avec le PIN d\'effacement : il devient le PIN du nouveau coffre vide. Votre ancien PIN ne fonctionne plus. La personne qui vous a vu saisir le PIN d\'effacement peut rouvrir Encly avec et ne remarque rien d\'inhabituel. + Les fichiers de sauvegarde déjà exportés ne sont pas touchés. Ils s\'ouvrent toujours avec votre phrase de récupération et sont le seul moyen de revenir en arrière. + Le déverrouillage biométrique reste actif : quelqu\'un peut vous forcer à poser le doigt sur le capteur. Désactivez-le si c\'est un risque pour vous. + Désactiver le déverrouillage biométrique + Cela vous protège quand quelqu\'un vous force à déverrouiller, pas contre une analyse forensique du téléphone. Pendant un blocage après des essais ratés, le PIN d\'effacement ne fonctionne pas non plus. Encly n\'indique jamais si un PIN d\'effacement est défini : un nouveau remplace l\'ancien, et si vous changez votre PIN pour le PIN d\'effacement, celui-ci est désactivé. + Définir le PIN d\'effacement + Désactiver le PIN d\'effacement + Confirmez votre PIN pour configurer le PIN d\'effacement + Créez un PIN d\'effacement + 6 à 12 chiffres, différents de votre PIN + Confirmez le PIN d\'effacement + Saisissez à nouveau le PIN d\'effacement + Le PIN d\'effacement doit être différent de votre PIN + Impossible d\'enregistrer le PIN d\'effacement. Veuillez réessayer. + 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 à 12 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 + Afficher l\'aperçu des notes + La liste des notes affiche les premières lignes de chaque note. Désactivez-le pour n\'afficher que le titre, le libellé et la date ; la recherche trouve toujours le texte entier. + Chiffres saisis : %1$d + Déverrouiller + Phrase de récupération acceptée + Votre code PIN fonctionne toujours. Gardez-le ou définissez-en un nouveau. + Garder mon code PIN + Définir un nouveau code PIN + diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml new file mode 100644 index 00000000..2d1cf9c5 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -0,0 +1,729 @@ + + + Informazioni + Uno spazio locale e crittografato per note, idee e attività, senza sincronizzazione nel cloud. + Protezione dei dati + I dati della cassaforte sono salvati localmente in un database SQLCipher crittografato + Domande frequenti + Feedback + Segnala un bug o suggerisci un miglioramento su GitHub + Perché conta e come contribuire su Ko-fi + Nessun browser trovato. Apri il link manualmente: %1$s + Nessuna app di posta trovata + Informativa sulla privacy + Come salviamo e proteggiamo i tuoi dati + Valuta l\'app + Lascia una valutazione sul Google Play Store + Assistenza e contatti + Contatta lo sviluppatore + Se hai domande o suggerimenti, puoi scrivermi un\'email. + + Continua + + Aggiungi + + Tutte + + Da 6 a 12 cifre. Tocca per cambiare il PIN. + PIN + Autenticazione + + Indietro + + La modifica della protezione biometrica non è stata confermata + Impossibile creare la chiave biometrica + Conferma la tua identità + Autenticazione + Conferma la biometria + Accesso alla chiave + Conferma la biometria per modificare la protezione + Conferma di sicurezza + Conferma la biometria per accedere alla chiave crittografata + Sblocca Encly + Usa il PIN + L\'autenticazione biometrica non è disponibile + + Altri strumenti di blocco + Elenco puntato + Checklist + Titolo + Titolo %1$d + Link + Elenco + Blocca la lista di controllo + Sblocca la lista di controllo + Seleziona blocco + Riordina elemento + Sposta elemento su + Sposta elemento giù + Aumenta rientro dell\'elemento + Riduci rientro dell\'elemento + Blocchi selezionati: %1$d + Annulla selezione + Copia + Taglia + Elimina i blocchi selezionati + Seleziona tutto + Copiato + Sposta blocco giù + Sposta blocco su + Elenco numerato + Separatore + + Annulla + + + Vuoi davvero eliminare definitivamente queste note? L\'operazione non può essere annullata. + Vuoi davvero svuotare definitivamente il cestino? L\'operazione non può essere annullata. + Svuotare il cestino? + + Cancella + + Chiudi + + Comprimi + + Crea una nuova etichetta + + Oggi %1$s + Ieri %1$s + + Elimina + Elimina blocco + Elimina + Elimina etichetta + Questa etichetta verrà rimossa da tutte le tue note. Le note non verranno eliminate. + + Elimina + Annulla + Vuoi davvero eliminare definitivamente questa nota? L\'operazione non può essere annullata. + Eliminare la nota? + + Encly è gratuito e open source, senza pubblicità, tracciamento né account. Le donazioni sono il suo unico introito. + Sostieni lo sviluppo + + Fine + + Duplica + + Colori dinamici + + Modifica + + Blocca la modifica + + Ancora nessuna nota + Tocca Nuova nota per scrivere la prima. + Le note eliminate restano qui finché non le ripristini o le elimini per sempre. + + Inserisci un link + + Impossibile aprire il database + + Espandi + + Impossibile caricare il contenuto + + Encly salva le modifiche e, dopo il ritardo scelto in Impostazioni → Sicurezza (15 secondi per impostazione predefinita), chiude il database crittografato e cancella la chiave di sessione. Spegnere lo schermo o bloccare il telefono lo fa subito. Poi sblocchi di nuovo. + Cosa succede quando esco dall\'app? + Usa l\'interruttore della biometria in Impostazioni → Sicurezza. Creare o rimuovere lo slot biometrico richiede un\'autenticazione biometrica forte. + Come attivo o disattivo la biometria? + Apri Impostazioni → Sicurezza, conferma il PIN attuale e poi inserisci due volte quello nuovo. + Come cambio il PIN? + Encly genera una chiave del database casuale a 256 bit. Il PIN protegge uno slot di sblocco AES-GCM separato e la biometria usa una chiave Android Keystore che richiede l\'autenticazione a ogni utilizzo. + Come è protetta la chiave del database? + Nella schermata di blocco tocca «Recupera l’accesso» e inserisci le 12 parole. Se il PIN funziona ancora, puoi tenerlo o impostare un nuovo PIN; altrimenti imposti subito un nuovo PIN. Senza frase di recupero non c’è altro modo di entrare: nessuno, nemmeno lo sviluppatore, può reimpostarla. + Cosa succede se dimentico il PIN? + Apri Impostazioni → Lingua e scegli una delle lingue disponibili, oppure «Predefinita di sistema» per usare la lingua del dispositivo. La modifica si applica subito. + Come cambio la lingua? + Tocca «Nuova nota» nella schermata principale. La barra sopra la tastiera aggiunge titoli, checklist, elenchi puntati e numerati, citazioni, link e separatori. + Come creo una nuova nota? + È una chiave di riserva di 12 parole. Sblocca la cassaforte se dimentichi il PIN ed è l’unica chiave dei tuoi file di backup. La crei durante la configurazione, o più tardi in Impostazioni → Sicurezza se l’hai saltata. Conservala su carta, offline. + A cosa serve la frase di recupero? + Apri «Informazioni» e usa l\'opzione di feedback, oppure scrivi allo sviluppatore. Non includere mai la frase di recupero, il PIN o il testo privato delle note in una segnalazione di bug. + Come segnalo un bug? + Automaticamente mentre scrivi, e uscire dall’editor completa l’ultimo salvataggio. Se un salvataggio non riesce, l’editor mostra «Non salvato» in rosso. + Come vengono salvate le modifiche? + Note, attività, crittografia locale e recupero dell\'accesso, senza sincronizzazione nel cloud. + Risposte alle domande più comuni + Non per impostazione predefinita. La protezione dello schermo è attiva: Encly blocca screenshot, registrazione dello schermo e anteprime non protette nell\'elenco delle app recenti. Se ti serve, Impostazioni → Sicurezza → «Consenti screenshot finché l’app non si blocca» la toglie dopo il PIN; le schermate di blocco, PIN e frase di recupero restano protette, e il permesso si spegne da solo quando Encly si blocca. + Posso fare screenshot di Encly? + Nozioni di base + Sicurezza e recupero + Impostazioni + Risoluzione dei problemi + Note, etichette e attività sono salvate solo nel database SQLCipher locale di Encly. Non c\'è sincronizzazione nel cloud né backup automatico: puoi esportare tu stesso un backup crittografato in Impostazioni → Backup, oppure una copia Markdown leggibile, che non è crittografata. + Dove sono salvati i miei dati? + Le etichette raggruppano le note. Puoi crearle, rinominarle, nasconderle ed eliminarle, trascinarle nell’ordine che vuoi e filtrare la schermata principale per etichetta. + Come funzionano le etichette? + Le attività hanno una descrizione e una priorità. Puoi filtrarle per priorità e segnarle come completate. Encly non mostra mai il testo delle attività nelle notifiche di sistema. + Cosa possono fare le attività? + In Impostazioni → Aspetto scegli uno dei cinque temi, la modalità chiara, scura o di sistema, uno dei tre stili di carattere e la dimensione del testo. Da Android 12 in poi puoi anche prendere il colore d’accento dallo sfondo. + Come cambio il tema? + Encly di proposito non crea mai un database vuoto al posto di una cassaforte persa o illeggibile. Non confermare la cancellazione completa se ti servono ancora i dati locali. + Encly segnala che la cassaforte è danneggiata + + Dimensione del testo + Interlinea + Predefinita + Compatta + Ampia + Editoriale + Moderno + Tecnico + + + Scegli la lingua + Predefinita di sistema + + Aggiungi link + + Elemento dell\'elenco + + + Torna al PIN + Recupera l\'accesso + Inserisci la tua frase di recupero di 12 parole + Recupero + Inserisci il PIN per continuare + Sblocca le tue note + Sblocco in corso… + Recupera l\'accesso con la frase di recupero + PIN errato + Frase di recupero errata + + Tieni premuto per cancellare tutto + Sembra che i dati crittografati o le chiavi di crittografia siano stati modificati o danneggiati, quindi il recupero non è possibile.\n\nPer continuare a usare l\'app devi reimpostarla e cancellare i dati danneggiati. Tutte le note e le impostazioni salvate verranno eliminate definitivamente. + Purtroppo i tuoi dati sono danneggiati + Questi dati provengono da una versione precedente + Encly ha trovato note salvate da una versione precedente alla 2.0. Encly 2.0 usa un nuovo formato crittografato e non può aprirle, né è possibile convertirle.\n\nPer usare l\'app, ricomincia da capo. Le vecchie note e impostazioni su questo telefono verranno eliminate definitivamente. + Tieni premuto per ricominciare da capo + + FAQ + Impostazioni + Sostieni lo sviluppatore + Etichette + Attività + Cestino + + Altre opzioni + + + Nessuna etichetta + + Nuova nota + Impossibile eliminare la nota + Descrizione… + Annulla tutte le modifiche + Impossibile annullare le modifiche + Impossibile creare una copia + Impossibile leggere il contenuto della nota. La modifica è disattivata per non danneggiare i dati. + + %1$s (copia) + Impossibile ripristinare la nota + Impossibile salvare le modifiche + Impossibile spostare la nota nel cestino + + Impossibile inizializzare l\'app + Impossibile creare la frase di recupero + Impossibile creare la cassaforte crittografata + + Apri il menu + + %1$d%% + + Inserisci di nuovo il PIN per confermare + Conferma il PIN + Conferma il PIN attuale prima di cambiare la chiave di accesso + Inserisci il PIN attuale + Da 6 a 12 cifre per proteggere le tue note + PIN cambiato + Crea un PIN + Il PIN attuale non è corretto + Cancella cifra + I PIN non corrispondono. Riprova. + Impossibile salvare il PIN + Impossibile aggiornare il PIN. Riprova. + + + Tocca per iniziare a scrivere… + + Alta + Bassa + Media + Scegli una priorità + + Citazione + Ripeti + + Ripristina + + Salva + + + + + Cancella ricerca + + Impronta digitale + Sblocco biometrico + Non disponibile su questo dispositivo + Nessuna impronta registrata. Aggiungine una nelle impostazioni di sistema. + La crittografia è attiva + Non configurata. Senza di essa, un PIN dimenticato significa perdere tutto. Tocca per crearne una. + Cancella tutti i dati + Elimina tutte le note, le attività, le etichette e le chiavi di cifratura su questo dispositivo + Tutte le note, le attività, le etichette e le chiavi di cifratura verranno eliminate da questo dispositivo ed Encly ripartirà da zero. L\'operazione non si può annullare: esporta prima un backup se vuoi conservare qualcosa. + Crittografia e protezione dei dati + Sicurezza + + + Aspetto + Generali + Lingua + Privacy + Mostra attività + Mostra la scheda delle attività nella schermata principale + Modifica semplice + Disattiva i blocchi dinamici e modifica le note come testo semplice + + Ordina per: + Per data di creazione (prima le meno recenti) + Creata ↑ + Per data di creazione (prima le più recenti) + Creata ↓ + Per titolo (da A a Z) + Titolo A–Z + Per titolo (da Z a A) + Titolo Z–A + Per ultima modifica (prima le meno recenti) + Modificata ↑ + Per ultima modifica (prima le più recenti) + Modificata ↓ + Sostieni lo sviluppatore + + Impossibile salvare l\'etichetta. Riprova. + Impossibile eliminare l\'etichetta. Riprova. + Modifica etichetta + Etichetta nascosta + Esiste già un\'etichetta con questo nome + Impossibile salvare l\'ordine delle etichette. Riprova. + Impossibile aggiornare l\'etichetta. Riprova. + + Tocca di nuovo per eliminare + + Nuova attività + Impossibile cancellare le attività completate. Riprova. + Vuoi davvero eliminare tutte le attività completate? L\'operazione non può essere annullata. + Cancella le attività completate + Completata: %1$s + Impossibile salvare l\'attività. Riprova. + Descrizione + Modifica attività + Nessuna attività in corso. + Nessuna attività corrisponde ai filtri selezionati. + Tutte le attività + Completate + Nuova attività + Avanzamento + Impossibile cambiare lo stato dell\'attività. Riprova. + Impossibile aggiornare l\'attività. Riprova. + + Testo + + Tema + Scuro + Chiaro + Sistema + + + Annulla + + Senza titolo + + Passa alla vista a griglia + Passa alla vista a elenco + + + Eliminare %d nota? + Eliminare %d note? + Eliminare %d note? + + + Troppi tentativi. Riprova tra %d secondo + Troppi tentativi. Riprova tra %d secondi + Troppi tentativi. Riprova tra %d secondi + + + %1$d di %2$d attività completata + %1$d di %2$d attività completate + %1$d di %2$d attività completate + + Backup + Backup crittografato, importazione e Markdown + Backup + Crittografato con la tua frase di recupero + Un backup contiene tutte le tue note, attività ed etichette, crittografate con la tua frase di recupero di 12 parole. Encly non lo carica da nessuna parte: scegli tu dove salvare il file. Su un nuovo telefono, le stesse 12 parole lo ripristinano. + Ultima esportazione: %1$s + Nessun backup ancora esportato + Esporta backup + Importa backup + Ripristina note, attività ed etichette da un file di backup + Backup salvato + Conferma che sei tu + Inserisci il PIN per continuare + Frase di recupero + I backup sono crittografati con una frase di recupero, quindi solo le sue 12 parole possono aprirli. Questa cassaforte non ne ha ancora una. Creala ora: ti permette anche di sbloccare la cassaforte se dimentichi il PIN. + Crea frase di recupero + Scrivi queste 12 parole su carta, in ordine, e conservale offline. Chiunque le abbia può aprire i tuoi backup. + Le ho scritte + Le parole non corrispondono. Controlla quello che hai scritto. + Frase di recupero salvata + Conferma la frase di recupero + Inserisci una volta la frase di recupero di 12 parole di questa cassaforte, così Encly può crittografare i backup con essa. + Inserisci la frase di recupero + Inserisci le 12 parole della frase di recupero con cui è stato creato questo backup. + Decrittografa + Importa backup + Unisci + Sostituisci + Sostituire tutti i dati? + Tutte le note, attività ed etichette di questa cassaforte verranno eliminate e sostituite dal backup. Non si può annullare. + Importato: note %1$d, attività %2$d, etichette %3$d. Saltato (già presente): %4$d. + Questo file non è un backup di Encly + Questo backup è stato creato da una versione più recente di Encly. Aggiorna l\'app e riprova. + Il file di backup è danneggiato o incompleto + Il file è troppo grande per essere un backup di Encly + La cassaforte è troppo grande per un solo file di backup (64 MB). Non è stato salvato nulla. + Questo file esiste già. Scegli un nuovo nome: un backup precedente non viene mai sovrascritto. + Nessuna app su questo dispositivo può aprire o salvare file. + Queste parole non aprono questo backup, oppure il file è stato modificato + Inserisci una frase di recupero valida di 12 parole + La cassaforte è stata bloccata. Sbloccala e riprova. + Impossibile leggere o scrivere il backup. Non è stato modificato nulla. + Queste parole non sono la frase di recupero di questa cassaforte + Impossibile salvare la frase di recupero + Ripristina da un backup + Scegli un file di backup + Impossibile ripristinare il backup, quindi la configurazione non è ancora completa. Riprova oppure continua con una cassaforte vuota e importa il file più tardi in Impostazioni → Backup. + Backup non ripristinato + Riprova + Continua senza + Questa versione di Encly non riesce a leggere questa nota. + Apri link + Copia link + Modifica link + Link copiato + Nessuna app su questo dispositivo può aprire questo link. + Si possono salvare o aprire solo link web (http, https) ed email. + Impossibile leggere le note + Impossibile leggere le etichette + Le note sono ancora salvate e cifrate su questo dispositivo. Chiudi Encly e riaprilo per riprovare. + Hai sbloccato Encly con la frase di recupero. Imposta un nuovo PIN per continuare. + Elimina attività + Attività eliminata + Impossibile eliminare l\'attività. Riprova. + Modalità + Carattere + Carta + Foresta + Oceano + Grafite + Mezzanotte + Segue l\'impostazione di sistema. Ora chiaro. + Segue l\'impostazione di sistema. Ora scuro. + Sempre chiaro. + Sempre scuro. + Mezzanotte è sempre scuro. + Prende il colore d\'accento dallo sfondo. Android 12 e versioni successive. + I colori dinamici sono attivi: l\'accento viene dallo sfondo. + Playfair Display e Source Sans 3 + Poppins e Source Sans 3 + IBM Plex Sans ovunque + Checklist visita appartamento + Prima della visita: controllare la pressione dell\'acqua, chiedere quanto è costato il riscaldamento lo scorso inverno. + Personale + Anteprima + Tema, modalità e carattere + Corretto + Sblocca con l\'impronta + Passo %1$d di %2$d + Un taccuino che solo tu puoi aprire. + Note e attività, cifrate su questo telefono. La configurazione richiede circa un minuto. + Nessun account + Nessuna registrazione, nessuna email, nessun reset della password. + Funziona offline + Encly non si connette mai a internet. + Cifrato sul dispositivo + La tua cassaforte è chiusa con una chiave che hai solo tu. + Inizia + Ho un backup + Scegli un PIN + Da 6 a 12 cifre. Sblocca Encly e protegge la chiave della tua cassaforte. Non c\'è un\'email per reimpostarlo. + Inserisci di nuovo il PIN + Così sappiamo che è quello che volevi. + Il PIN resta disponibile come alternativa + Dodici parole riportano tutto indietro + Encly ti darà una frase di recupero. Scrivila su carta: è l\'unica chiave di riserva. + PIN dimenticato? + La frase sblocca la cassaforte e ti permette di impostare un nuovo PIN. + Apre i tuoi backup + I file di backup cifrati si leggono solo con queste parole. + Nuovo telefono + File di backup più frase ripristinano ogni nota, attività ed etichetta. + Nessuno può recuperarla per te, nemmeno noi. Se perdi sia il PIN sia la frase, le note sono perse. + Crea la mia frase + Salta: solo PIN, niente backup + Scrivi queste parole + Su carta, in quest\'ordine. Non in uno screenshot, non nel cloud. + Nascondi parole + Mostra parole + Le ho scritte + Controlla tre parole + Scrivile dalla tua copia su carta, non a memoria. + Parola n. %1$d + Inserisci la parola + Non è la parola n. %1$d + Conferma + Mostra di nuovo le parole + La tua cassaforte è pronta + Tutto ciò che scrivi da ora in poi viene cifrato prima di essere salvato. + PIN impostato + Richiesto a ogni apertura di Encly + Sblocco con impronta attivo + Riprova con l\'impronta + Puoi cambiarlo in Impostazioni, Sicurezza + Frase di recupero verificata + Conserva il foglio in un posto sicuro + Nessuna frase di recupero + I backup sono disattivati. Puoi creare una frase più tardi in Impostazioni, Sicurezza. + Backup ripristinato + Le tue note, attività ed etichette sono tornate + Suggerimento: ogni tanto esporta un backup cifrato da Impostazioni, Backup. È la tua frase ad aprirlo. + Apri il mio taccuino + Ripristina, poi imposta un PIN + Scegli il file di backup di Encly, poi inserisci le 12 parole con cui è stato creato. + 1 · File di backup + Tocca per scegliere un altro file + 2 · Frase di recupero + Queste parole diventano anche la frase di recupero di questo telefono. + Note + Blocca ora + Cerca nei titoli e nel testo + Opzioni di visualizzazione + Nessun risultato per «%1$s» + Oggi + Ieri + + Attività · %d aperta + Attività · %d aperte + Attività · %d aperte + + Attività · nessuna aperta + Ancora nessuna attività + Tocca Nuova attività per aggiungere la prima. + Tutto fatto + Completate · %1$d + Nessuna nota con il tag «%1$s» + Qui compariranno le note con questo tag. + Mostra tutte le note + Il cestino è vuoto + Tocca una nota per leggerla. Tieni premuto per selezionare. + Elimina per sempre + Svuota cestino + Cerca note + Note e attività + Versione, privacy e contatti + Risposte alle domande frequenti + Licenze open source + Le librerie e i font usati da Encly + Costruito sull’open source + Encly è fatto con queste librerie e questi font. Toccane uno per leggerne la licenza. + Librerie + Font + Ancora nessun tag + I tag raggruppano le note. Creane uno qui sopra, poi sceglilo per una nota tenendola premuta. + Tocca un tag per rinominarlo, nasconderlo o eliminarlo. Trascina la maniglia per riordinare. + Aggiungi tag + Nome + Mostra nell’elenco delle note + Un tag nascosto mantiene le note ma non ha un filtro + Trascina per riordinare + Sposta su + Sposta giù + + %d selezionata + %d selezionate + %d selezionate + + Modifica etichette + Titolo + Non salvato + Gestisci etichette + Annullare le modifiche? + Tutto ciò che hai modificato da quando hai aperto questa nota andrà perso. L\'operazione non può essere annullata. + È già tutto tuo + Ogni funzione è disponibile per tutti. Niente è bloccato dietro un pagamento. + Niente pubblicità né tracciamento + Encly non vede mai le tue note e non guadagna dai dati. Solo le donazioni lo sostengono. + Dove vanno i fondi + Dispositivi di test (il prossimo obiettivo: un Mac mini per compilare e testare) e tempo per rilasci e correzioni. + Carta o PayPal, una tantum o mensile + Apre ko-fi.com nel browser. L\'app Encly non gestisce mai i pagamenti. + Cosa fa una pressione prolungata su una nota? + Apre le azioni della nota: cambiare l’etichetta, aggiungere una breve descrizione mostrata sulla scheda, duplicare la nota o spostarla nel cestino. + Come recupero una nota eliminata? + Le note eliminate finiscono nel Cestino del menu. Lì puoi ripristinare una nota o eliminarla per sempre. Encly non svuota mai il cestino da solo. + Cosa fa «Blocca la modifica»? + Rende la nota di sola lettura, così non la modifichi per sbaglio. Puoi comunque selezionare e copiare il testo. Si attiva e disattiva dal menu dell’editor. + Come funzionano i backup? + In Impostazioni → Backup esporti un file crittografato che solo la tua frase di recupero può aprire. Per ripristinare, scegli il file, digita o incolla le 12 parole, poi uniscilo a questa cassaforte o sostituisci tutto. + Mostra tastiera + Nascondi tastiera + Cancellare tutti i dati? + Impossibile cancellare i dati. Non è stato modificato nulla. + Cancella + Sottoattività + Sottoattività + Aggiungi sottoattività + Elimina sottoattività + Tutte le sottoattività sono completate + Completa l\'attività + Mostra sottoattività + Nascondi sottoattività + Espanso + Compresso + Sottoattività eliminata + Prossimo passo: %1$s + Primo passo + + %1$d di %2$d sottoattività completata + %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 + Fissa + Non fissare più + Archivia + Rimuovi dall\'archivio + Fissate + Altre + Archivio + L\'archivio è vuoto + Le note archiviate escono dall\'elenco e dalla ricerca ma restano nella cassaforte. Archivia una nota dal suo menu. + Impossibile archiviare la nota + Impossibile rimuovere la nota dall\'archivio + Colore + Nessun colore + Rosso + Arancione + Giallo + Verde + Turchese + Blu + Viola + Rosa + Esporta come Markdown + Copia come Markdown + Copiato come Markdown + Esportazione leggibile + Salva tutte le note e le attività come file .md leggibili in uno .zip. Il file non è cifrato. + Esportare senza cifratura? + Il file esportato non è cifrato. Chiunque lo ottenga può leggere le tue note. Ovunque tu lo salvi, anche in una cartella cloud, ci sarà una copia leggibile che Encly non può proteggere né ritirare. Poi confermerai con il tuo PIN. + Esportazione Markdown salvata + Posso esportare le note come Markdown? + Sì. Nel menu di una nota scegli «Esporta come Markdown» per un file .md, oppure vai su Impostazioni → Backup → «Esporta come Markdown» per uno .zip con tutte le note (quelle archiviate in una cartella archive, senza il cestino) e un file delle attività. Questi file non sono crittografati: Encly ti avvisa prima, chiede il PIN e salva solo dove scegli tu. + Azioni con scorrimento + Scorri le note + Scorri una nota per agire su di essa + Scorri a destra + Scorri a sinistra + Fissa o non fissare + Colore successivo + Sposta nel cestino + Niente + Spostare nel cestino? + Potrai ripristinare la nota dal cestino. + A cosa servono gli scorrimenti su una nota? + Per impostazione predefinita, scorrere una nota a destra la fissa o la sblocca, a sinistra la archivia. In Impostazioni → Azioni con scorrimento puoi scegliere per ogni direzione: fissa, archivia, colore successivo, sposta nel cestino (chiede sempre conferma) o niente, oppure disattivare gli scorrimenti. TalkBack offre le stesse azioni nel menu delle azioni della nota. + Testo delle note + Il valore predefinito di ogni nota. Una nota può impostare il proprio dal suo menu. + Usa le impostazioni di testo predefinite + Codice sorgente + Encly è open source: leggi il codice su GitHub + Apri + Apri in una nuova scheda + Scegli una nota o inizia una nuova + File + Visualizza + Finestra + Aiuto + Esci + Riduci a icona + Nuova finestra + Apri in una nuova finestra + Esporta come PDF + Stampa + Esportare in PDF senza cifratura? + Il PDF non è cifrato. Chiunque lo ottenga può leggere la tua nota. Ovunque lo salvi, anche in una cartella cloud, ci sarà una copia leggibile che Encly non può proteggere né revocare. Subito dopo confermerai con il tuo PIN. + Stampare senza cifratura? + La stampa invia il testo della tua nota alla stampante senza cifratura. Lo spooler di stampa del sistema può conservarne copie temporanee e anche una stampante di rete o cloud può memorizzarlo. Encly non può rimuoverle. Subito dopo confermerai con il tuo PIN. + PDF salvato + Inviato alla stampante + Questa nota è aperta in modifica in un\'altra finestra. Qui è di sola lettura. + Novità + Le novità di questa versione + Ho capito + Le note vengono eliminate definitivamente dopo 28 giorni nel cestino. + Quanto può essere lungo il PIN? + Da 6 a 12 cifre, a tua scelta. PIN più corti non sono previsti: il blocco e la chiave del dispositivo limitano i tentativi, e 6 cifre è il minimo che resta sicuro con quei limiti. La schermata di blocco non mostra mai la lunghezza del PIN: tocchi «Sblocca» quando hai finito, e lo stesso vale per il PIN di cancellazione e il PIN di coercizione. I PIN impostati prima continuano a funzionare così come sono. + Codice + Tabella + Aggiungi riga + Aggiungi colonna + Elimina riga + Elimina colonna + Riga di intestazione + Opzioni cella + Barra dell\'editor + Scegli e ordina gli strumenti + Trascina la maniglia per riordinare. L\'occhio nasconde uno strumento o lo ripristina. Gli strumenti in «Altro menu» stanno dietro la freccia. + Ripristina + Barra + Altro menu + Nascosti + Nascondi strumento + Mostra strumento + Su + Giù + + %1$d altra etichetta + altre %1$d etichette + altre %1$d etichette + + diff --git a/app/src/main/res/values-it/strings_handoff.xml b/core/resources/src/commonMain/strings/values-it/strings_handoff.xml similarity index 100% rename from app/src/main/res/values-it/strings_handoff.xml rename to core/resources/src/commonMain/strings/values-it/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values-it/strings_import.xml b/core/resources/src/commonMain/strings/values-it/strings_import.xml new file mode 100644 index 00000000..ccfe09d8 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-it/strings_import.xml @@ -0,0 +1,79 @@ + + + + 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 + Note attività importate come note normali: %1$d + Elementi cifrati o non supportati saltati: %1$d + File non utilizzabili: %1$d + Archivi dentro archivi, non aperti: %1$d + Importazione… + Importato + %1$d aggiunte, %2$d saltate perché duplicate, %3$d non importate. + I tuoi file originali non sono stati modificati. Eliminali tu quando hai controllato le note qui. + Di quale app sono? + I file sembrano provenire da più di un\'app. Scegli quella da importare; il resto viene saltato. + Non è stato importato nulla + Questo backup di Standard Notes è cifrato. Scarica un backup decifrato in Standard Notes e scegli quello. + Questo è un backup cifrato di Notesnook. In Notesnook esporta invece le note come Markdown e scegli il .zip. + Questi file non sono un\'esportazione supportata. Guarda le app supportate o scegli file Markdown o di testo. + Questa selezione è più grande di quanto Encly possa importare in una volta (64 MB di note, 2 GB in totale o 20.000 note). Importala a parti. + Nessuna nota trovata in questa selezione. + Non è stato possibile leggere i file scelti. + Questo telefono non ha un\'app di selezione file che Encly possa usare. + La cassaforte si è chiusa durante l\'importazione. Sblocca Encly e riprova. + Qualcosa è andato storto e non è stato importato nulla. La tua cassaforte è invariata. + %1$d di %2$d selezionate + Seleziona tutto + Deseleziona tutto + Tutte + Solo nuove + Nel cestino + Senza titolo + Checklist + Nel cestino + Già nella cassaforte + Importa %1$d + Scegli di nuovo + diff --git a/core/resources/src/commonMain/strings/values-it/strings_security.xml b/core/resources/src/commonMain/strings/values-it/strings_security.xml new file mode 100644 index 00000000..d7857b7a --- /dev/null +++ b/core/resources/src/commonMain/strings/values-it/strings_security.xml @@ -0,0 +1,146 @@ + + + Scrivi o incolla le tue 12 parole + Parola %1$d di %2$d + La parola %1$d non è una parola della frase di recupero. Controlla come l\'hai scritta. + Queste 12 parole non formano una frase di recupero valida. Controlla ogni parola e il loro ordine. + Questa cassaforte ha già una frase di recupero + Aggiungere alle tue note o sostituirle? + Questo backup contiene %1$s, %2$s e %3$s. + Tutto ciò che è qui resta e si aggiunge ciò che manca. Gli elementi già presenti vengono saltati. + Sostituisci tutto + Elimina tutte le note, le attività e i tag su questo telefono, poi ripristina il backup. + Conserva tutte le note, le attività e i tag in un file cifrato e ripristinali su qualsiasi telefono. + Esporta + Importa + Le tue note sono cifrate. Il tuo PIN (e l\'impronta, se attiva) le sblocca. + Le tue note sono cifrate. Il tuo PIN (e l\'impronta, se attiva) le sblocca, e la frase di recupero se dimentichi il PIN. + Creata durante la configurazione. Encly non salva mai le parole e non può mostrarle di nuovo. Non le hai? Sostituisci la frase qui sotto. + Configurata + Dopo il ripristino potrai recuperare un file di backup con le sue 12 parole. + Impossibile aprire la cassaforte al momento + Encly non è riuscita a leggere i suoi dati. Un\'altra app o il sistema potrebbe usarli, oppure il dispositivo è ancora bloccato. Le tue note non sono perse. Riprova tra un attimo. + Riprova + Ancora non leggibile. Riprova tra un attimo. + Cambia PIN + + %d nota + %d di note + %d note + + + %d attività + %d di attività + %d attività + + + %d tag + %d di tag + %d tag + + + Il tuo PIN non può più sbloccare Encly su questo telefono: la sua chiave hardware è stata reimpostata. Usa la frase di recupero (o l\'impronta), poi imposta un nuovo PIN. + Il dispositivo segnala di essere ancora bloccato, quindi Encly è rimasto chiuso. Sblocca il dispositivo e riprova. + + Troppi tentativi. Riprova tra %d minuto + Troppi tentativi. Riprova tra %d minuti + Troppi tentativi. Riprova tra %d minuti + + + Troppi tentativi. Riprova tra %d ora + Troppi tentativi. Riprova tra %d ore + Troppi tentativi. Riprova tra %d ore + + Sostituisci la frase di recupero + Ricevi 12 nuove parole. I backup fatti prima richiedono ancora quelle vecchie. + Sostituire la frase di recupero? + Riceverai 12 nuove parole. La vecchia frase non sblocca più le tue note su questo telefono. I backup già fatti si aprono solo con la vecchia frase: conservala finché conservi quei backup, oppure fai un nuovo backup dopo. + Ricevi nuove parole + La vecchia frase qui non funziona più + Una volta confermato, solo queste parole sbloccano le tue note se dimentichi il PIN. I backup fatti prima richiedono ancora le vecchie parole. + Frase di recupero sostituita. Fai un nuovo backup per usarla anche per i backup. + Privacy + Privacy rigorosa della tastiera + Chiede alla tastiera nessun suggerimento, nessuna previsione nel cloud e nessuna lettura intorno al cursore. Disattiva la correzione automatica e alcune tastiere nascondono emoji o input vocale. + Consenti screenshot finché l’app non si blocca + Screenshot e registrazione dello schermo restano bloccati a meno che tu non li consenta qui. Si disattiva da solo quando Encly si blocca. + Consentire gli screenshot? + Gli screenshot possono finire nella tua galleria e nei backup cloud, con le tue note. La schermata di blocco, l’inserimento del PIN e la frase di recupero restano protetti. Il permesso termina quando Encly si blocca. + Blocca dopo l’uscita + Per quanto Encly resta aperto quando passi a un’altra app. Spegnere lo schermo o bloccare il telefono lo blocca sempre subito. + Subito + Dopo 15 secondi + Dopo 30 secondi + Dopo 1 minuto + Dopo 2 minuti + Dopo 5 minuti + Dopo 10 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. + Un PIN legato a questo telefono + Il PIN è combinato con una chiave del chip sicuro del telefono, quindi non si può indovinare altrove. + Una serratura per ogni accesso + PIN, frase di recupero e impronta aprono ciascuno il proprio spazio cifrato. L’impronta viene chiesta ogni volta. + Zona pericolosa + Tieni premuto 5 secondi per cancellare + + PIN di cancellazione + Un secondo PIN che cancella le tue note se lo digiti nella schermata di blocco + Disattivato quando la chiave del PIN è stata reimpostata. Impostalo di nuovo se ne usavi uno. + Se qualcuno ti costringe ad aprire Encly, digita il PIN di cancellazione invece del tuo PIN. Encly cancella note, attività ed etichette senza lasciare segni e si apre come una cassaforte vuota. Funziona solo nella schermata di blocco. + Dopo la cancellazione, Encly si apre con il PIN di cancellazione: diventa il PIN della nuova cassaforte vuota. Il vecchio PIN smette di funzionare, così chi ti ha visto digitare il PIN di cancellazione può riaprire Encly con esso senza notare nulla di strano. + I file di backup che hai già esportato non vengono toccati. Si aprono ancora con la frase di recupero e sono l\'unico modo per tornare indietro. + Lo sblocco biometrico resta attivo: qualcuno può costringerti ad appoggiare il dito sul sensore. Disattivalo se per te è un rischio. + Disattiva lo sblocco biometrico + Ti protegge quando qualcuno ti costringe a sbloccare, non da un\'analisi forense del telefono. Durante un blocco dopo tentativi errati anche il PIN di cancellazione non funziona. Encly non mostra mai se un PIN di cancellazione è impostato: uno nuovo sostituisce il precedente, e se cambi il tuo PIN nel PIN di cancellazione, questo viene disattivato. + Imposta PIN di cancellazione + Disattiva PIN di cancellazione + Conferma il tuo PIN per configurare il PIN di cancellazione + Crea un PIN di cancellazione + Da 6 a 12 cifre, diverse dal tuo PIN + Conferma il PIN di cancellazione + Digita di nuovo il PIN di cancellazione + Il PIN di cancellazione deve essere diverso dal tuo PIN + Impossibile salvare il PIN di cancellazione. Riprova. + 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 + Da 6 a 12 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 + Mostra anteprima delle note + L\'elenco delle note mostra le prime righe di ogni nota. Disattivalo per mostrare solo titolo, etichetta e data; la ricerca trova comunque tutto il testo. + Cifre inserite: %1$d + Sblocca + Frase di recupero accettata + Il tuo PIN funziona ancora. Tienilo o imposta un nuovo PIN. + Tieni il mio PIN + Imposta un nuovo PIN + diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml new file mode 100644 index 00000000..7bd9c4e4 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -0,0 +1,722 @@ + + + Over de app + Een lokale, versleutelde plek voor notities, ideeën en taken, zonder cloudsynchronisatie. + Gegevensbescherming + De kluisgegevens worden lokaal opgeslagen in een versleutelde SQLCipher-database + Veelgestelde vragen + Feedback + Meld een bug of stel een verbetering voor op GitHub + Waarom het telt en hoe je helpt via Ko-fi + Geen browser gevonden. Open de link handmatig: %1$s + Geen e-mailapp gevonden + Privacybeleid + Hoe we je gegevens opslaan en beschermen + App beoordelen + Geef een beoordeling in de Google Play Store + Ondersteuning en contact + Contact opnemen met de ontwikkelaar + Heb je vragen of suggesties? Stuur me gerust een e-mail. + + Doorgaan + + Toevoegen + + Alle + + 6 tot 12 cijfers. Tik om je pincode te wijzigen. + Pincode + Verificatie + + Terug + + De wijziging van de biometrische beveiliging is niet bevestigd + Kan de biometrische sleutel niet maken + Bevestig je identiteit + Verificatie + Bevestig je biometrie + Toegang tot de sleutel + Bevestig je biometrie om de beveiliging te wijzigen + Beveiligingsbevestiging + Bevestig je biometrie om toegang te krijgen tot de versleutelde sleutel + Encly ontgrendelen + Pincode gebruiken + Biometrische verificatie is niet beschikbaar + + Meer bloktools + Lijst met opsommingstekens + Checklist + Kop + Kop %1$d + Link + Lijst + Checklist vergrendelen + Checklist ontgrendelen + Blok selecteren + Item verplaatsen + Item omhoog verplaatsen + Item omlaag verplaatsen + Item inspringen + Inspringing van item verkleinen + Geselecteerde blokken: %1$d + Selectie wissen + Kopiëren + Knippen + Geselecteerde blokken verwijderen + Alles selecteren + Gekopieerd + Blok omlaag verplaatsen + Blok omhoog verplaatsen + Genummerde lijst + Scheidingslijn + + Annuleren + + + Weet je zeker dat je deze notities definitief wilt verwijderen? Dit kan niet ongedaan worden gemaakt. + Weet je zeker dat je de prullenbak definitief wilt legen? Dit kan niet ongedaan worden gemaakt. + Prullenbak legen? + + Wissen + + Sluiten + + Samenvouwen + + Nieuw label maken + + Vandaag %1$s + Gisteren %1$s + + Verwijderen + Blok verwijderen + Verwijderen + Label verwijderen + Dit label wordt uit al je notities verwijderd. De notities zelf worden niet verwijderd. + + Verwijderen + Annuleren + Weet je zeker dat je deze notitie definitief wilt verwijderen? Dit kan niet ongedaan worden gemaakt. + Notitie verwijderen? + + Encly is gratis en open source, zonder advertenties, tracking en accounts. Donaties zijn de enige inkomsten. + Steun de ontwikkeling + + Klaar + + Dupliceren + + Dynamische kleuren + + Bewerken + + Bewerken vergrendelen + + Nog geen notities + Tik op Nieuwe notitie om je eerste te schrijven. + Verwijderde notities blijven hier tot je ze herstelt of definitief verwijdert. + + Voer een link in + + Kan de database niet openen + + Uitvouwen + + Kan de inhoud niet laden + + Encly slaat je wijzigingen op en sluit na de vertraging die je in Instellingen → Beveiliging kiest (standaard 15 seconden) de versleutelde database en wist de sessiesleutel. Het scherm uitzetten of de telefoon vergrendelen doet dit meteen. Daarna ontgrendel je opnieuw. + Wat gebeurt er als ik de app verlaat? + Gebruik de biometrieschakelaar in Instellingen → Beveiliging. Voor het maken of verwijderen van het biometrische slot is sterke biometrische verificatie nodig. + Hoe zet ik biometrie aan of uit? + Open Instellingen → Beveiliging, bevestig je huidige pincode en voer daarna twee keer de nieuwe in. + Hoe wijzig ik mijn pincode? + Encly maakt een willekeurige databasesleutel van 256 bits. Je pincode beschermt een afzonderlijk AES-GCM-ontgrendelslot en biometrie gebruikt een Android Keystore-sleutel die bij elk gebruik verificatie vereist. + Hoe is de databasesleutel beveiligd? + Tik op het vergrendelscherm op ‘Toegang herstellen’ en voer je 12 woorden in. Werkt je pincode nog, dan kun je die behouden of een nieuwe pincode instellen; zo niet, dan stel je meteen een nieuwe pincode in. Zonder herstelzin is er geen andere weg naar binnen: niemand, ook de ontwikkelaar niet, kan hem resetten. + Wat als ik mijn pincode vergeet? + Open Instellingen → Taal en kies een van de beschikbare talen, of \'Systeemstandaard\' om de taal van je apparaat te volgen. De wijziging gaat meteen in. + Hoe wijzig ik de taal? + Tik op het startscherm op ‘Nieuwe notitie’. De balk boven het toetsenbord voegt koppen, checklists, opsommingen, genummerde lijsten, citaten, links en scheidingslijnen toe. + Hoe maak ik een nieuwe notitie? + Het is een reservesleutel van 12 woorden. Hij ontgrendelt de kluis als je je pincode vergeet en is de enige sleutel tot je back-upbestanden. Je maakt hem tijdens het instellen, of later via Instellingen → Beveiliging als je hem hebt overgeslagen. Bewaar hem op papier, offline. + Waarvoor is de herstelzin? + Open \'Over de app\' en gebruik de feedbackoptie, of schrijf de ontwikkelaar. Voeg nooit je herstelzin, pincode of privétekst uit notities toe aan een bugmelding. + Hoe meld ik een bug? + Automatisch terwijl je typt, en als je de editor verlaat wordt de laatste opslag afgerond. Mislukt het opslaan ooit, dan toont de editor ‘Niet opgeslagen’ in rood. + Hoe worden wijzigingen opgeslagen? + Notities, taken, lokale versleuteling en toegangsherstel, zonder cloudsynchronisatie. + Antwoorden op de meest gestelde vragen + Standaard niet. Schermbeveiliging staat aan: Encly blokkeert screenshots, schermopnames en onbeveiligde voorbeelden in de lijst met recente apps. Heb je er een nodig, dan heft Instellingen → Beveiliging → ‘Screenshots toestaan tot de app vergrendelt’ die op na je pincode; de vergrendel-, pincode- en herstelzinschermen blijven beveiligd, en de toestemming stopt zodra Encly vergrendelt. + Kan ik screenshots maken van Encly? + De basis + Beveiliging en herstel + Instellingen + Problemen oplossen + Notities, labels en taken worden alleen opgeslagen in de lokale SQLCipher-database van Encly. Er is geen cloudsynchronisatie en geen automatische back-up: je kunt zelf een versleutelde back-up exporteren via Instellingen → Back-up, of een leesbare Markdown-kopie, die niet versleuteld is. + Waar worden mijn gegevens opgeslagen? + Labels groeperen je notities. Je kunt ze maken, hernoemen, verbergen en verwijderen, in elke volgorde slepen en het startscherm op label filteren. + Hoe werken labels? + Taken hebben een beschrijving en een prioriteit. Je kunt ze op prioriteit filteren en als voltooid markeren. Encly zet nooit de tekst van taken in systeemmeldingen. + Wat kun je met taken? + Via Instellingen → Weergave kies je een van vijf thema’s, lichte, donkere of systeemmodus, een van drie letterstijlen en de tekstgrootte. Vanaf Android 12 kan de accentkleur ook van je achtergrond komen. + Hoe wijzig ik het thema? + Encly maakt bewust nooit een lege database aan in plaats van een verloren of onleesbare kluis. Bevestig het volledig wissen niet als je je lokale gegevens nog nodig hebt. + Encly meldt dat de kluis beschadigd is + + Tekstgrootte + Regelafstand + Standaard + Compact + Ruim + Redactioneel + Modern + Technisch + + + Taal kiezen + Systeemstandaard + + Link toevoegen + + Lijstitem + + + Terug naar pincode + Toegang herstellen + Voer je herstelzin van 12 woorden in + Herstel + Voer je pincode in om door te gaan + Ontgrendel je notities + Ontgrendelen… + Toegang herstellen met je herstelzin + Onjuiste pincode + Onjuiste herstelzin + + Houd ingedrukt om alles te wissen + Het lijkt erop dat de versleutelde gegevens of de versleutelingssleutels zijn gewijzigd of beschadigd, waardoor herstel niet mogelijk is.\n\nOm de app te blijven gebruiken, moet je hem resetten en de beschadigde gegevens wissen. Hierdoor worden alle opgeslagen notities en instellingen definitief verwijderd. + Helaas zijn je gegevens beschadigd + Deze gegevens komen uit een oudere versie + Encly heeft notities gevonden die zijn opgeslagen door een versie van vóór 2.0. Encly 2.0 gebruikt een nieuwe versleutelde indeling en kan ze niet openen, en omzetten is niet mogelijk.\n\nBegin opnieuw om de app te gebruiken. De oude notities en instellingen op deze telefoon worden dan definitief verwijderd. + Houd ingedrukt om opnieuw te beginnen + + Veelgestelde vragen + Instellingen + Steun de ontwikkelaar + Labels + Taken + Prullenbak + + Meer opties + + + Geen label + + Nieuwe notitie + Kan de notitie niet verwijderen + Beschrijving… + Alle wijzigingen negeren + Kan de wijzigingen niet negeren + Kan geen kopie maken + Kan de inhoud van de notitie niet lezen. Bewerken is uitgeschakeld zodat de gegevens niet beschadigd raken. + + %1$s (kopie) + Kan de notitie niet terugzetten + Kan de wijzigingen niet opslaan + Kan de notitie niet naar de prullenbak verplaatsen + + Kan de app niet initialiseren + Kan de herstelzin niet maken + Kan de versleutelde kluis niet maken + + Menu openen + + %1$d%% + + Voer de pincode nogmaals in om te bevestigen + Bevestig je pincode + Bevestig je huidige pincode voordat je je toegangssleutel wijzigt + Voer je huidige pincode in + 6 tot 12 cijfers om je notities te beschermen + Pincode gewijzigd + Maak een pincode + De huidige pincode is onjuist + Cijfer wissen + De pincodes komen niet overeen. Probeer het opnieuw. + Kan de pincode niet opslaan + Kan de pincode niet bijwerken. Probeer het opnieuw. + + + Tik om te beginnen met bewerken… + + Hoog + Laag + Gemiddeld + Kies een prioriteit + + Citaat + Opnieuw + + Terugzetten + + Opslaan + + + + + Zoekopdracht wissen + + Vingerafdruk + Biometrisch ontgrendelen + Niet beschikbaar op dit apparaat + Geen vingerafdruk ingesteld. Voeg er een toe in de systeeminstellingen. + Versleuteling staat aan + Niet ingesteld. Zonder deze zin betekent een vergeten pincode dat je alles kwijt bent. Tik om er een te maken. + Alle gegevens wissen + Alle notities, taken, labels en de versleutelingssleutels op dit apparaat verwijderen + Alle notities, taken, labels en de versleutelingssleutels worden van dit apparaat verwijderd en Encly begint opnieuw. Dit kan niet ongedaan worden gemaakt: exporteer eerst een back-up als je iets wilt bewaren. + Versleuteling en gegevensbescherming + Beveiliging + + + Weergave + Algemeen + Taal + Privacy + Taken tonen + De takenkaart op het startscherm tonen + Eenvoudig bewerken + Dynamische blokken uitzetten en notities als platte tekst bewerken + + Sorteren op: + Op aanmaakdatum (oudste eerst) + Gemaakt ↑ + Op aanmaakdatum (nieuwste eerst) + Gemaakt ↓ + Op titel (A tot Z) + Titel A–Z + Op titel (Z tot A) + Titel Z–A + Op laatst bewerkt (oudste eerst) + Bewerkt ↑ + Op laatst bewerkt (nieuwste eerst) + Bewerkt ↓ + Steun de ontwikkelaar + + Kan het label niet opslaan. Probeer het opnieuw. + Kan het label niet verwijderen. Probeer het opnieuw. + Label bewerken + Verborgen label + Er bestaat al een label met deze naam + Kan de volgorde van de labels niet opslaan. Probeer het opnieuw. + Kan het label niet bijwerken. Probeer het opnieuw. + + Tik nogmaals om te verwijderen + + Nieuwe taak + Kan de voltooide taken niet wissen. Probeer het opnieuw. + Weet je zeker dat je alle voltooide taken wilt verwijderen? Dit kan niet ongedaan worden gemaakt. + Voltooide taken wissen + Voltooid: %1$s + Kan de taak niet opslaan. Probeer het opnieuw. + Beschrijving + Taak bewerken + Geen actieve taken. + Geen taken die aan de gekozen filters voldoen. + Alle taken + Voltooid + Nieuwe taak + Voortgang + Kan de status van de taak niet wijzigen. Probeer het opnieuw. + Kan de taak niet bijwerken. Probeer het opnieuw. + + Tekst + + Thema + Donker + Licht + Systeem + + + Ongedaan maken + + Zonder titel + + Naar rasterweergave + Naar lijstweergave + + + %d notitie verwijderen? + %d notities verwijderen? + + + Te veel pogingen. Probeer het over %d seconde opnieuw + Te veel pogingen. Probeer het over %d seconden opnieuw + + + %1$d van %2$d taak voltooid + %1$d van %2$d taken voltooid + + Back-up + Versleutelde back-up, import en Markdown + Back-up + Versleuteld met je herstelzin + Een back-up bevat al je notities, taken en labels, versleuteld met je herstelzin van 12 woorden. Encly uploadt hem nergens heen: jij kiest waar het bestand wordt opgeslagen. Op een nieuwe telefoon zetten dezelfde 12 woorden hem terug. + Laatste export: %1$s + Nog geen back-up geëxporteerd + Back-up exporteren + Back-up importeren + Notities, taken en labels terugzetten uit een back-upbestand + Back-up opgeslagen + Bevestig dat jij het bent + Voer je pincode in om door te gaan + Herstelzin + Back-ups worden versleuteld met een herstelzin, dus alleen de 12 woorden daarvan kunnen ze openen. Deze kluis heeft er nog geen. Maak er nu een: je kunt de kluis er ook mee ontgrendelen als je je pincode vergeet. + Herstelzin maken + Schrijf deze 12 woorden op papier, in volgorde, en bewaar ze offline. Iedereen die ze heeft, kan je back-ups openen. + Ik heb ze opgeschreven + De woorden kloppen niet. Controleer wat je hebt opgeschreven. + Herstelzin opgeslagen + Bevestig je herstelzin + Voer de herstelzin van 12 woorden van deze kluis één keer in, zodat Encly er back-ups mee kan versleutelen. + Herstelzin invoeren + Voer de 12 woorden in van de herstelzin waarmee deze back-up is gemaakt. + Ontsleutelen + Back-up importeren + Samenvoegen + Vervangen + Alle gegevens vervangen? + Alle notities, taken en labels in deze kluis worden verwijderd en vervangen door de back-up. Dit kan niet ongedaan worden gemaakt. + Geïmporteerd: notities %1$d, taken %2$d, labels %3$d. Overgeslagen (al aanwezig): %4$d. + Dit bestand is geen Encly-back-up + Deze back-up is gemaakt met een nieuwere versie van Encly. Werk de app bij en probeer het opnieuw. + Het back-upbestand is beschadigd of onvolledig + Het bestand is te groot voor een Encly-back-up + Je kluis is te groot voor één back-upbestand (64 MB). Er is niets opgeslagen. + Dat bestand bestaat al. Kies een nieuwe naam: een oudere back-up wordt nooit overschreven. + Geen app op dit apparaat kan bestanden openen of opslaan. + Deze woorden openen deze back-up niet, of het bestand is gewijzigd + Voer een geldige herstelzin van 12 woorden in + De kluis is vergrendeld. Ontgrendel hem en probeer het opnieuw. + Kan de back-up niet lezen of schrijven. Er is niets gewijzigd. + Deze woorden zijn niet de herstelzin van deze kluis + Kan de herstelzin niet opslaan + Terugzetten uit een back-up + Een back-upbestand kiezen + De back-up kon niet worden teruggezet, dus de instelling is nog niet voltooid. Probeer het opnieuw, of ga verder met een lege kluis en importeer het bestand later via Instellingen → Back-up. + Back-up niet teruggezet + Opnieuw proberen + Doorgaan zonder + Deze versie van Encly kan deze notitie niet lezen. + Link openen + Link kopiëren + Link bewerken + Link gekopieerd + Geen app op dit apparaat kan deze link openen. + Alleen weblinks (http, https) en e-maillinks kunnen worden opgeslagen of geopend. + Kan je notities niet lezen + Kan je labels niet lezen + Je notities staan nog steeds versleuteld op dit apparaat. Sluit Encly en open het opnieuw om het nog eens te proberen. + Je hebt Encly ontgrendeld met je herstelzin. Stel een nieuwe pincode in om door te gaan. + Taak verwijderen + Taak verwijderd + Kan de taak niet verwijderen. Probeer het opnieuw. + Modus + Lettertype + Papier + Bos + Oceaan + Grafiet + Middernacht + Volgt de systeeminstelling. Nu licht. + Volgt de systeeminstelling. Nu donker. + Altijd licht. + Altijd donker. + Middernacht is altijd donker. + Neemt de accentkleur van je achtergrond. Android 12 en hoger. + Dynamische kleuren staan aan: het accent komt van je achtergrond. + Playfair Display en Source Sans 3 + Poppins en Source Sans 3 + Overal IBM Plex Sans + Checklist bezichtiging + Voor de bezichtiging: waterdruk controleren, vragen wat de verwarming vorige winter kostte. + Persoonlijk + Voorbeeld + Thema, modus en lettertype + Juist + Ontgrendelen met vingerafdruk + Stap %1$d van %2$d + Een notitieboek dat alleen jij kunt openen. + Notities en taken, versleuteld op deze telefoon. Instellen duurt ongeveer een minuut. + Geen account + Geen aanmelding, geen e-mail, geen wachtwoordherstel. + Werkt offline + Encly maakt nooit verbinding met internet. + Versleuteld op het apparaat + Je kluis is vergrendeld met een sleutel die alleen jij hebt. + Aan de slag + Ik heb een back-up + Kies een pincode + 6 tot 12 cijfers. Die ontgrendelt Encly en beschermt de sleutel van je kluis. Er is geen e-mail om hem te herstellen. + Voer de pincode opnieuw in + Zo weten we zeker dat het de bedoelde is. + De pincode blijft werken als reserve + Twaalf woorden brengen alles terug + Encly geeft je een herstelzin. Schrijf die op papier; het is de enige reservesleutel. + Pincode vergeten? + De zin ontgrendelt je kluis en laat je een nieuwe pincode instellen. + Opent je back-ups + Versleutelde back-upbestanden zijn alleen met deze woorden te lezen. + Nieuwe telefoon + Back-upbestand plus zin zet elke notitie, taak en elk label terug. + Niemand kan hem voor je herstellen, ook wij niet. Verlies je zowel de pincode als de zin, dan zijn de notities weg. + Mijn zin maken + Overslaan: alleen pincode, geen back-ups + Schrijf deze woorden op + Op papier, in deze volgorde. Niet in een screenshot, niet in de cloud. + Woorden verbergen + Woorden tonen + Ik heb ze opgeschreven + Controleer drie woorden + Typ ze over van je papieren kopie, niet uit je hoofd. + Woord %1$d + Voer het woord in + Dat is niet woord %1$d + Bevestigen + Woorden opnieuw tonen + Je kluis is klaar + Alles wat je vanaf nu schrijft, wordt versleuteld voordat het wordt opgeslagen. + Pincode ingesteld + Gevraagd telkens als Encly opent + Ontgrendelen met vingerafdruk aan + Vingerafdruk opnieuw proberen + Wijzig dit in Instellingen, Beveiliging + Herstelzin gecontroleerd + Bewaar het papier op een veilige plek + Geen herstelzin + Back-ups staan uit. Je kunt later een zin maken in Instellingen, Beveiliging. + Back-up teruggezet + Je notities, taken en labels zijn terug + Tip: exporteer af en toe een versleutelde back-up via Instellingen, Back-up. Je zin is wat hem opent. + Mijn notitieboek openen + Terugzetten, dan pincode instellen + Kies je Encly-back-upbestand en voer dan de 12 woorden in waarmee het is gemaakt. + 1 · Back-upbestand + Tik om een ander bestand te kiezen + 2 · Herstelzin + Deze woorden worden ook de herstelzin van deze telefoon. + Notities + Nu vergrendelen + Zoeken in titels en tekst + Weergaveopties + Niets gevonden voor ‘%1$s’ + Vandaag + Gisteren + + Taken · %d open + Taken · %d open + + Taken · geen open + Nog geen taken + Tik op Nieuwe taak om je eerste toe te voegen. + Alles klaar + Klaar · %1$d + Geen notities met de tag ‘%1$s’ + Notities met deze tag verschijnen hier. + Alle notities tonen + De prullenbak is leeg + Tik op een notitie om te lezen. Houd ingedrukt om te selecteren. + Definitief verwijderen + Prullenbak legen + Notities zoeken + Notities en taken + Versie, privacybeleid en contact + Antwoorden op veelgestelde vragen + Opensourcelicenties + De bibliotheken en lettertypen die Encly gebruikt + Gebouwd op open source + Encly is gemaakt met deze bibliotheken en lettertypen. Tik erop om de licentie te lezen. + Bibliotheken + Lettertypen + Nog geen tags + Tags groeperen je notities. Maak er hierboven een aan en kies hem voor een notitie via lang indrukken. + Tik op een tag om hem te hernoemen, verbergen of verwijderen. Sleep de greep om te ordenen. + Tag toevoegen + Naam + Tonen in de notitielijst + Een verborgen tag houdt zijn notities maar krijgt geen filter + Sleep om te ordenen + Omhoog + Omlaag + + %d geselecteerd + %d geselecteerd + + Labels bewerken + Titel + Niet opgeslagen + Labels beheren + Wijzigingen negeren? + Alles wat je hebt gewijzigd sinds je deze notitie opende, gaat verloren. Dit kan niet ongedaan worden gemaakt. + Alles is al van jou + Elke functie is er voor iedereen. Niets zit achter een betaling. + Geen advertenties, geen tracking + Encly ziet je notities nooit en verdient niets aan data. Alleen donaties betalen ervoor. + Waar het geld heen gaat + Testapparaten (volgende doel: een Mac mini om te bouwen en testen) en tijd voor releases en fixes. + Kaart of PayPal, eenmalig of maandelijks + Opent ko-fi.com in je browser. Encly zelf verwerkt nooit betalingen. + Wat doet lang drukken op een notitie? + Het opent de acties van de notitie: het label wijzigen, een korte beschrijving op de kaart toevoegen, de notitie dupliceren of naar de prullenbak verplaatsen. + Hoe krijg ik een verwijderde notitie terug? + Verwijderde notities gaan naar de Prullenbak in het menu. Daar kun je een notitie herstellen of definitief verwijderen. Encly leegt de prullenbak nooit vanzelf. + Wat doet ‘Bewerken vergrendelen’? + De notitie wordt alleen-lezen, zodat je hem niet per ongeluk wijzigt. Je kunt de tekst nog steeds selecteren en kopiëren. Zet het aan of uit via het menu van de editor. + Hoe werken back-ups? + Via Instellingen → Back-up exporteer je één versleuteld bestand dat alleen je herstelzin kan openen. Om te herstellen kies je het bestand, typ of plak je de 12 woorden en voeg je het samen met deze kluis of vervang je alles. + Toetsenbord tonen + Toetsenbord verbergen + Alle gegevens wissen? + De gegevens konden niet worden gewist. Er is niets gewijzigd. + Wissen + Subtaken + Subtaak + Subtaak toevoegen + Subtaak verwijderen + Alle subtaken zijn klaar + Taak voltooien + Subtaken tonen + Subtaken verbergen + Uitgevouwen + Samengevouwen + Subtaak verwijderd + Volgende stap: %1$s + Eerste stap + + %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 + Vastmaken + Losmaken + Archiveren + Uit archief halen + Vastgemaakt + Overige + Archief + Het archief is leeg + Gearchiveerde notities verdwijnen uit de lijst en de zoekopdracht, maar blijven in de kluis. Archiveer een notitie via het menu. + De notitie kon niet worden gearchiveerd + De notitie kon niet uit het archief worden gehaald + Kleur + Geen kleur + Rood + Oranje + Geel + Groen + Turquoise + Blauw + Paars + Roze + Exporteren als Markdown + Kopiëren als Markdown + Gekopieerd als Markdown + Leesbare export + Sla alle notities en taken op als leesbare .md-bestanden in een .zip. Het bestand is niet versleuteld. + Exporteren zonder versleuteling? + Het geëxporteerde bestand is niet versleuteld. Iedereen die het in handen krijgt, kan je notities lezen. Waar je het ook opslaat, ook in een cloudmap, komt een leesbare kopie te staan die Encly niet kan beschermen of terughalen. Daarna bevestig je met je pincode. + Markdown-export opgeslagen + Kan ik mijn notities als Markdown exporteren? + Ja. Kies in het menu van een notitie ‘Exporteren als Markdown’ voor één .md-bestand, of ga naar Instellingen → Back-up → ‘Exporteren als Markdown’ voor een .zip met alle notities (gearchiveerde in een map archive, zonder prullenbak) en een takenbestand. Deze bestanden zijn niet versleuteld: Encly waarschuwt eerst, vraagt om je pincode en slaat alleen op waar jij kiest. + Veegacties + Vegen op notities + Veeg een notitiekaart om erop te handelen + Naar rechts vegen + Naar links vegen + Vastmaken of losmaken + Volgende kleur + Naar prullenbak + Niets + Naar de prullenbak verplaatsen? + Je kunt de notitie uit de prullenbak terugzetten. + Wat doen veegbewegingen op een notitie? + Standaard maakt naar rechts vegen een notitie vast of los, en naar links archiveert ze. Onder Instellingen → Veegacties kies je per richting: vastmaken, archiveren, volgende kleur, naar prullenbak (vraagt altijd eerst) of niets, of zet je vegen uit. TalkBack biedt dezelfde acties in het actiemenu van de notitie. + Tekst in notities + De standaard voor elke notitie. Een notitie kan via haar menu een eigen waarde instellen. + Standaardtekstinstellingen gebruiken + Broncode + Encly is open source: lees de code op GitHub + Openen + Openen in nieuw tabblad + Kies een notitie of begin een nieuwe + Bestand + Weergave + Venster + Help + Afsluiten + Minimaliseren + Nieuw venster + Openen in nieuw venster + Exporteren als PDF + Afdrukken + Zonder versleuteling naar PDF exporteren? + De pdf is niet versleuteld. Iedereen die hem in handen krijgt, kan je notitie lezen. Waar je hem ook opslaat, ook in een cloudmap, komt een leesbare kopie te staan die Encly niet kan beschermen of terughalen. Daarna bevestig je met je pincode. + Zonder versleuteling afdrukken? + Bij het afdrukken wordt de tekst van je notitie onversleuteld naar de printer gestuurd. De afdrukwachtrij van het systeem kan tijdelijke kopieën bewaren en een netwerk- of cloudprinter kan de tekst ook opslaan. Encly kan ze niet verwijderen. Daarna bevestig je met je pincode. + PDF opgeslagen + Naar de printer gestuurd + Deze notitie is in een ander venster geopend om te bewerken. Hier is ze alleen-lezen. + Wat is er nieuw + De wijzigingen in deze versie + Begrepen + Notities worden na 28 dagen in de prullenbak definitief verwijderd. + Hoe lang mag mijn pincode zijn? + Tussen 6 en 12 cijfers, naar keuze. Kortere pincodes worden niet aangeboden: de blokkade en de sleutel van het apparaat beperken het aantal pogingen, en 6 cijfers is het kortste dat daarmee veilig blijft. Het vergrendelscherm toont nooit hoe lang je pincode is: je tikt op ‘Ontgrendelen’ als je klaar bent, en hetzelfde geldt voor de wis-pincode en de dwangpincode. Pincodes die je eerder instelde, blijven gewoon werken. + Code + Tabel + Rij toevoegen + Kolom toevoegen + Rij verwijderen + Kolom verwijderen + Koprij + Celopties + Editorwerkbalk + Kies en orden de hulpmiddelen + Sleep het handvat om te herordenen. Het oog verbergt een hulpmiddel of toont het weer. Hulpmiddelen onder “Meer menu” zitten achter de pijl. + Herstellen + Werkbalk + Meer menu + Verborgen + Hulpmiddel verbergen + Hulpmiddel tonen + Omhoog + Omlaag + + nog %1$d label + nog %1$d labels + + diff --git a/app/src/main/res/values-nl/strings_handoff.xml b/core/resources/src/commonMain/strings/values-nl/strings_handoff.xml similarity index 100% rename from app/src/main/res/values-nl/strings_handoff.xml rename to core/resources/src/commonMain/strings/values-nl/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values-nl/strings_import.xml b/core/resources/src/commonMain/strings/values-nl/strings_import.xml new file mode 100644 index 00000000..ed3720a3 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-nl/strings_import.xml @@ -0,0 +1,79 @@ + + + + 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 + Takennotities geïmporteerd als gewone notities: %1$d + Versleutelde of niet-ondersteunde items overgeslagen: %1$d + Bestanden die niet bruikbaar waren: %1$d + Archieven in archieven, niet geopend: %1$d + Importeren… + Geïmporteerd + %1$d toegevoegd, %2$d overgeslagen als dubbel, %3$d niet geïmporteerd. + Je originele bestanden zijn niet gewijzigd. Verwijder ze zelf zodra je de notities hier hebt gecontroleerd. + Uit welke app komen deze? + De bestanden lijken uit meer dan één app te komen. Kies de app om te importeren; de rest wordt overgeslagen. + Er is niets geïmporteerd + Deze Standard Notes-back-up is versleuteld. Download in Standard Notes een ontsleutelde back-up en kies die. + Dit is een versleutelde Notesnook-back-up. Exporteer je notities in Notesnook in plaats daarvan als Markdown en kies de .zip. + Deze bestanden zijn geen ondersteunde export. Bekijk de ondersteunde apps of kies Markdown- of tekstbestanden. + Deze selectie is groter dan Encly in één keer kan importeren (64 MB aan notities, 2 GB in totaal of 20.000 notities). Importeer hem in delen. + Er zijn geen notities gevonden in deze selectie. + De gekozen bestanden konden niet worden gelezen. + Deze telefoon heeft geen bestandskiezer die Encly kan gebruiken. + De kluis is tijdens het importeren gesloten. Ontgrendel Encly en probeer het opnieuw. + Er ging iets mis en er is niets geïmporteerd. Je kluis is ongewijzigd. + %1$d van %2$d geselecteerd + Alles selecteren + Niets selecteren + Alle + Alleen nieuwe + In prullenbak + Zonder titel + Checklist + In prullenbak + Al in de kluis + %1$d importeren + Opnieuw kiezen + diff --git a/core/resources/src/commonMain/strings/values-nl/strings_security.xml b/core/resources/src/commonMain/strings/values-nl/strings_security.xml new file mode 100644 index 00000000..1692fc24 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-nl/strings_security.xml @@ -0,0 +1,141 @@ + + + Typ of plak je 12 woorden + Woord %1$d van %2$d + Woord %1$d is geen woord uit een herstelzin. Controleer de spelling. + Deze 12 woorden vormen geen geldige herstelzin. Controleer elk woord en de volgorde. + Deze kluis heeft al een herstelzin + Toevoegen aan je notities of ze vervangen? + Deze back-up bevat %1$s, %2$s en %3$s. + Alles hier blijft en wat ontbreekt wordt toegevoegd. Wat er al is, wordt overgeslagen. + Alles vervangen + Verwijder alle notities, taken en tags op deze telefoon en zet daarna de back-up terug. + Bewaar al je notities, taken en tags in één versleuteld bestand en zet ze terug op elke telefoon. + Exporteren + Importeren + Je notities zijn versleuteld. Je pincode (en je vingerafdruk, als die aan staat) ontgrendelt ze. + Je notities zijn versleuteld. Je pincode (en je vingerafdruk, als die aan staat) ontgrendelt ze, en je herstelzin als je de pincode vergeet. + Gemaakt tijdens het instellen. Encly bewaart de woorden nooit en kan ze niet opnieuw tonen. Heb je ze niet? Vervang de zin hieronder. + Ingesteld + Na de reset kun je een back-upbestand terugzetten met de 12 woorden ervan. + Je kluis kan nu niet worden geopend + Encly kon zijn gegevens zojuist niet lezen. Een andere app of het systeem gebruikt ze misschien, of het apparaat is nog vergrendeld. Je notities zijn niet kwijt. Probeer het zo opnieuw. + Opnieuw proberen + Nog steeds niet leesbaar. Probeer het zo opnieuw. + Pincode wijzigen + + %d notitie + %d notities + + + %d taak + %d taken + + + %d tag + %d tags + + + Je pincode kan Encly op deze telefoon niet meer ontgrendelen: de hardwaresleutel is gereset. Gebruik je herstelzin (of vingerafdruk) en stel daarna een nieuwe pincode in. + Het apparaat meldt dat het nog vergrendeld is, daarom bleef Encly dicht. Ontgrendel het apparaat en probeer het opnieuw. + + Te veel pogingen. Probeer het over %d minuut opnieuw + Te veel pogingen. Probeer het over %d minuten opnieuw + + + Te veel pogingen. Probeer het over %d uur opnieuw + Te veel pogingen. Probeer het over %d uur opnieuw + + Herstelzin vervangen + Krijg 12 nieuwe woorden. Eerder gemaakte back-ups hebben nog steeds de oude nodig. + Herstelzin vervangen? + Je krijgt 12 nieuwe woorden. De oude zin ontgrendelt je notities op deze telefoon niet meer. Back-ups die je al hebt gemaakt, openen alleen met de oude zin, dus bewaar die zolang je die back-ups bewaart, of maak daarna een nieuwe back-up. + Nieuwe woorden krijgen + Je oude zin werkt hier niet meer + Zodra je bevestigt, ontgrendelen alleen deze woorden je notities als je de pincode vergeet. Eerder gemaakte back-ups hebben nog steeds de oude woorden nodig. + Herstelzin vervangen. Maak een nieuwe back-up om hem ook voor back-ups te gebruiken. + Privacy + Strikte toetsenbordprivacy + Vraagt het toetsenbord om geen suggesties, geen voorspelling in de cloud en geen meelezen rond de cursor. Zet autocorrectie uit, en sommige toetsenborden verbergen emoji of spraakinvoer. + Screenshots toestaan tot de app vergrendelt + Screenshots en schermopnamen blijven geblokkeerd tenzij je ze hier toestaat. Dit gaat vanzelf uit als Encly vergrendelt. + Screenshots toestaan? + Screenshots kunnen met je notities erin in je galerij en in cloudback-ups terechtkomen. Het vergrendelscherm, de pincode-invoer en de herstelzin blijven beschermd. De toestemming eindigt zodra Encly vergrendelt. + Vergrendelen na verlaten + Hoelang Encly open blijft als je naar een andere app gaat. Het scherm uitzetten of de telefoon vergrendelen vergrendelt het altijd meteen. + Meteen + Na 15 seconden + Na 30 seconden + Na 1 minuut + Na 2 minuten + Na 5 minuten + Na 10 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. + Een pincode gebonden aan deze telefoon + Je pincode wordt gecombineerd met een sleutel in de beveiligde chip van de telefoon, dus hij is nergens anders te raden. + Een eigen slot voor elke toegang + Pincode, herstelzin en vingerafdruk openen elk hun eigen versleutelde slot. De vingerafdruk is elke keer nodig. + Gevarenzone + Houd 5 seconden vast om te wissen + + Wis-pincode + Een tweede pincode die je notities wist als je hem op het vergrendelscherm invoert + Uitgeschakeld toen de sleutel van je pincode werd gereset. Stel hem opnieuw in als je er een gebruikte. + Als iemand je dwingt Encly te openen, voer dan de wis-pincode in plaats van je pincode in. Encly wist je notities, taken en labels zonder zichtbaar teken en opent als een lege kluis. Dit werkt alleen op het vergrendelscherm. + Na het wissen opent Encly met de wis-pincode: die wordt de pincode van de nieuwe, lege kluis. Je oude pincode werkt niet meer. Wie je de wis-pincode zag invoeren, kan Encly er opnieuw mee openen en merkt niets ongewoons. + Back-upbestanden die je al hebt geëxporteerd, blijven onaangeroerd. Ze openen nog steeds met je herstelzin en zijn de enige weg terug. + Biometrisch ontgrendelen blijft aan: iemand kan je vinger op de sensor dwingen. Zet het uit als dat voor jou een risico is. + Biometrisch ontgrendelen uitzetten + Dit beschermt je als iemand je dwingt te ontgrendelen, niet tegen forensisch onderzoek van de telefoon. Tijdens een blokkering na mislukte pogingen werkt ook de wis-pincode niet. Encly laat nooit zien of er een wis-pincode is ingesteld: een nieuwe vervangt de oude, en als je je pincode wijzigt in de wis-pincode, wordt die uitgeschakeld. + Wis-pincode instellen + Wis-pincode uitzetten + Bevestig je pincode om de wis-pincode in te stellen + Maak een wis-pincode + 6 tot 12 cijfers, anders dan je pincode + Bevestig de wis-pincode + Voer de wis-pincode nogmaals in + De wis-pincode moet anders zijn dan je pincode + Kan de wis-pincode niet opslaan. Probeer het opnieuw. + 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 tot 12 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 + Notitievoorbeelden tonen + De notitielijst toont de eerste regels van elke notitie. Zet dit uit om alleen titel, label en datum te tonen; zoeken vindt nog steeds de volledige tekst. + Ingevoerde cijfers: %1$d + Ontgrendelen + Herstelzin geaccepteerd + Je pincode werkt nog. Behoud hem of stel een nieuwe in. + Mijn pincode behouden + Nieuwe pincode instellen + diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml new file mode 100644 index 00000000..2021fab5 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -0,0 +1,736 @@ + + + O aplikacji + Lokalna, zaszyfrowana przestrzeń na notatki, pomysły i zadania – bez synchronizacji w chmurze. + Ochrona danych + Dane sejfu są przechowywane lokalnie w zaszyfrowanej bazie danych SQLCipher + Najczęstsze pytania + Opinie + Zgłoś błąd lub zaproponuj ulepszenie na GitHubie + Dlaczego to ważne i jak wesprzeć na Ko-fi + Nie znaleziono przeglądarki. Otwórz link ręcznie: %1$s + Nie znaleziono aplikacji pocztowej + Polityka prywatności + Jak przechowujemy i chronimy Twoje dane + Oceń aplikację + Wystaw ocenę w Sklepie Google Play + Pomoc i kontakt + Napisz do dewelopera + Jeśli masz pytania lub sugestie, możesz napisać do mnie e-mail. + + Dalej + + Dodaj + + Wszystkie + + Od 6 do 12 cyfr. Stuknij, aby zmienić PIN. + PIN + Uwierzytelnianie + + Wstecz + + Zmiana ochrony biometrycznej nie została potwierdzona + Nie udało się utworzyć klucza biometrycznego + Potwierdź swoją tożsamość + Uwierzytelnianie + Potwierdź biometrię + Dostęp do klucza + Potwierdź biometrię, aby zmienić ochronę + Potwierdzenie bezpieczeństwa + Potwierdź biometrię, aby uzyskać dostęp do zaszyfrowanego klucza + Odblokuj Encly + Użyj PIN-u + Uwierzytelnianie biometryczne jest niedostępne + + Więcej narzędzi bloku + Lista punktowana + Lista kontrolna + Nagłówek + Nagłówek %1$d + Link + Lista + Zablokuj listę kontrolną + Odblokuj listę kontrolną + Zaznacz blok + Zmień kolejność elementu + Przenieś element w górę + Przenieś element w dół + Zwiększ wcięcie elementu + Zmniejsz wcięcie elementu + Zaznaczone bloki: %1$d + Wyczyść zaznaczenie + Kopiuj + Wytnij + Usuń zaznaczone bloki + Zaznacz wszystko + Skopiowano + Przenieś blok w dół + Przenieś blok w górę + Lista numerowana + Separator + + Anuluj + + + Czy na pewno chcesz trwale usunąć te notatki? Tej operacji nie można cofnąć. + Czy na pewno chcesz trwale opróżnić kosz? Tej operacji nie można cofnąć. + Opróżnić kosz? + + Wyczyść + + Zamknij + + Zwiń + + Utwórz nową etykietę + + Dziś %1$s + Wczoraj %1$s + + Usuń + Usuń blok + Usuń + Usuń etykietę + Ta etykieta zostanie usunięta ze wszystkich notatek. Same notatki nie zostaną usunięte. + + Usuń + Anuluj + Czy na pewno chcesz trwale usunąć tę notatkę? Tej operacji nie można cofnąć. + Usunąć notatkę? + + Encly jest darmowy i open source, bez reklam, śledzenia i kont. Darowizny to jego jedyny dochód. + Wesprzyj rozwój + + Gotowe + + Duplikuj + + Dynamiczne kolory + + Edytuj + + Zablokuj edycję + + Nie ma jeszcze notatek + Stuknij Nowa notatka, aby napisać pierwszą. + Usunięte notatki czekają tu, aż je przywrócisz lub usuniesz na zawsze. + + Wpisz link + + Nie udało się otworzyć bazy danych + + Rozwiń + + Nie udało się wczytać treści + + Encly zapisuje zmiany i po opóźnieniu wybranym w Ustawienia → Bezpieczeństwo (domyślnie 15 sekund) zamyka zaszyfrowaną bazę i czyści klucz sesji. Wyłączenie ekranu lub zablokowanie telefonu robi to od razu. Potem odblokowujesz ponownie. + Co się dzieje, gdy wychodzę z aplikacji? + Użyj przełącznika biometrii w Ustawienia → Bezpieczeństwo. Utworzenie lub usunięcie slotu biometrycznego wymaga silnego uwierzytelnienia biometrycznego. + Jak włączyć lub wyłączyć biometrię? + Otwórz Ustawienia → Bezpieczeństwo, potwierdź obecny PIN, a następnie dwukrotnie wpisz nowy. + Jak zmienić PIN? + Encly tworzy losowy 256-bitowy klucz bazy danych. PIN chroni osobny slot odblokowania AES-GCM, a biometria korzysta z klucza Android Keystore, który wymaga uwierzytelnienia przy każdym użyciu. + Jak chroniony jest klucz bazy danych? + Na ekranie blokady stuknij „Odzyskaj dostęp” i wpisz 12 słów. Jeśli twój PIN nadal działa, możesz go zachować albo ustawić nowy PIN; jeśli nie, od razu ustawiasz nowy PIN. Bez frazy odzyskiwania nie ma innej drogi: nikt, nawet twórca aplikacji, nie może jej zresetować. + Co jeśli zapomnę PIN-u? + Otwórz Ustawienia → Język i wybierz jeden z dostępnych języków albo „Domyślny systemu”, aby używać języka urządzenia. Zmiana działa od razu. + Jak zmienić język? + Stuknij „Nowa notatka” na ekranie głównym. Pasek nad klawiaturą dodaje nagłówki, listy kontrolne, listy punktowane i numerowane, cytaty, linki i separatory. + Jak utworzyć nową notatkę? + To zapasowy klucz z 12 słów. Odblokowuje sejf, jeśli zapomnisz PIN-u, i jest jedynym kluczem do twoich kopii zapasowych. Tworzysz go podczas konfiguracji albo później w Ustawienia → Bezpieczeństwo, jeśli go pominięto. Przechowuj go na papierze, offline. + Do czego służy fraza odzyskiwania? + Otwórz „O aplikacji” i skorzystaj z opcji opinii albo napisz do dewelopera. Nigdy nie dołączaj do zgłoszenia błędu frazy odzyskiwania, PIN-u ani prywatnej treści notatek. + Jak zgłosić błąd? + Automatycznie podczas pisania, a wyjście z edytora kończy ostatni zapis. Jeśli zapis kiedyś się nie uda, edytor pokaże czerwone „Nie zapisano”. + Jak zapisywane są zmiany? + Notatki, zadania, lokalne szyfrowanie i odzyskiwanie dostępu – bez synchronizacji w chmurze. + Odpowiedzi na najczęstsze pytania + Domyślnie nie. Ochrona ekranu jest włączona: Encly blokuje zrzuty ekranu, nagrywanie ekranu i niezabezpieczone podglądy na liście ostatnich aplikacji. Gdy potrzebujesz zrzutu, Ustawienia → Bezpieczeństwo → „Zezwól na zrzuty ekranu do zablokowania aplikacji” zdejmuje ją po podaniu PIN-u; ekrany blokady, PIN-u i frazy odzyskiwania pozostają chronione, a zgoda wyłącza się sama, gdy Encly się zablokuje. + Czy mogę robić zrzuty ekranu w Encly? + Podstawy + Bezpieczeństwo i odzyskiwanie + Ustawienia + Rozwiązywanie problemów + Notatki, etykiety i zadania są przechowywane wyłącznie w lokalnej bazie danych SQLCipher aplikacji Encly. Nie ma synchronizacji w chmurze ani automatycznych kopii: zaszyfrowaną kopię możesz wyeksportować samodzielnie w Ustawienia → Kopia zapasowa, a także czytelną kopię w formacie Markdown, która nie jest zaszyfrowana. + Gdzie przechowywane są moje dane? + Etykiety grupują notatki. Możesz je tworzyć, zmieniać nazwy, ukrywać i usuwać, przeciągać w dowolnej kolejności oraz filtrować po nich ekran główny. + Jak działają etykiety? + Zadania mają opis i priorytet. Możesz je filtrować według priorytetu i oznaczać jako wykonane. Encly nigdy nie umieszcza treści zadań w powiadomieniach systemowych. + Co potrafią zadania? + W Ustawienia → Wygląd wybierz jeden z pięciu motywów, tryb jasny, ciemny lub systemowy, jeden z trzech stylów czcionki i rozmiar tekstu. Na Androidzie 12 i nowszym kolor akcentu może też pochodzić z tapety. + Jak zmienić motyw? + Encly celowo nigdy nie tworzy pustej bazy danych w miejscu utraconego lub nieczytelnego sejfu. Nie potwierdzaj pełnego wyczyszczenia, jeśli nadal potrzebujesz danych lokalnych. + Encly zgłasza uszkodzenie sejfu + + Rozmiar tekstu + Odstępy między wierszami + Domyślne + Kompaktowe + Przestronne + Redakcyjny + Nowoczesny + Techniczny + + + Wybierz język + Domyślny systemu + + Dodaj link + + Element listy + + + Wróć do PIN-u + Odzyskaj dostęp + Wpisz 12-wyrazową frazę odzyskiwania + Odzyskiwanie + Wpisz PIN, aby kontynuować + Odblokuj notatki + Odblokowywanie… + Odzyskaj dostęp za pomocą frazy odzyskiwania + Nieprawidłowy PIN + Nieprawidłowa fraza odzyskiwania + + Przytrzymaj, aby wyczyścić wszystko + Wygląda na to, że zaszyfrowane dane lub klucze szyfrowania zostały zmienione albo uszkodzone, więc odzyskanie danych jest niemożliwe.\n\nAby dalej korzystać z aplikacji, musisz ją zresetować i wyczyścić uszkodzone dane. Spowoduje to trwałe usunięcie wszystkich zapisanych notatek i ustawień. + Niestety Twoje dane są uszkodzone + Te dane pochodzą ze starszej wersji + Encly znalazła notatki zapisane przez wersję sprzed 2.0. Encly 2.0 używa nowego zaszyfrowanego formatu i nie może ich otworzyć, a ich konwersja nie jest możliwa.\n\nAby korzystać z aplikacji, zacznij od nowa. Stare notatki i ustawienia na tym telefonie zostaną trwale usunięte. + Przytrzymaj, aby zacząć od nowa + + FAQ + Ustawienia + Wesprzyj dewelopera + Etykiety + Zadania + Kosz + + Więcej opcji + + + Bez etykiety + + Nowa notatka + Nie udało się usunąć notatki + Opis… + Odrzuć wszystkie zmiany + Nie udało się odrzucić zmian + Nie udało się utworzyć kopii + Nie udało się odczytać treści notatki. Edycja jest wyłączona, aby nie uszkodzić danych. + + %1$s (kopia) + Nie udało się przywrócić notatki + Nie udało się zapisać zmian + Nie udało się przenieść notatki do kosza + + Nie udało się zainicjować aplikacji + Nie udało się utworzyć frazy odzyskiwania + Nie udało się utworzyć zaszyfrowanego sejfu + + Otwórz menu + + %1$d%% + + Wpisz PIN ponownie, aby go potwierdzić + Potwierdź PIN + Potwierdź obecny PIN przed zmianą klucza dostępu + Wpisz obecny PIN + Od 6 do 12 cyfr, aby chronić swoje notatki + PIN zmieniony + Utwórz PIN + Obecny PIN jest nieprawidłowy + Usuń cyfrę + PIN-y nie są zgodne. Spróbuj ponownie. + Nie udało się zapisać PIN-u + Nie udało się zaktualizować PIN-u. Spróbuj ponownie. + + + Stuknij, aby zacząć edycję… + + Wysoki + Niski + Średni + Wybierz priorytet + + Cytat + Ponów + + Przywróć + + Zapisz + + + + + Wyczyść wyszukiwanie + + Odcisk palca + Odblokowanie biometryczne + Niedostępne na tym urządzeniu + Nie dodano odcisku palca. Dodaj go w ustawieniach systemu. + Szyfrowanie jest aktywne + Nieskonfigurowana. Bez niej zapomniany PIN oznacza utratę wszystkiego. Dotknij, aby ją utworzyć. + Wymaż wszystkie dane + Usuń wszystkie notatki, zadania, etykiety i klucze szyfrowania z tego urządzenia + Wszystkie notatki, zadania, etykiety i klucze szyfrowania zostaną usunięte z tego urządzenia, a Encly zacznie od nowa. Tego nie można cofnąć: najpierw wyeksportuj kopię zapasową, jeśli chcesz coś zachować. + Szyfrowanie i ochrona danych + Bezpieczeństwo + + + Wygląd + Ogólne + Język + Prywatność + Pokazuj zadania + Pokazuj kartę zadań na ekranie głównym + Prosta edycja + Wyłącz bloki dynamiczne i edytuj notatki jako zwykły tekst + + Sortuj według: + Według daty utworzenia (od najstarszych) + Utworzono ↑ + Według daty utworzenia (od najnowszych) + Utworzono ↓ + Według tytułu (A–Z) + Tytuł A–Z + Według tytułu (Z–A) + Tytuł Z–A + Według ostatniej edycji (od najstarszych) + Edytowano ↑ + Według ostatniej edycji (od najnowszych) + Edytowano ↓ + Wesprzyj dewelopera + + Nie udało się zapisać etykiety. Spróbuj ponownie. + Nie udało się usunąć etykiety. Spróbuj ponownie. + Edytuj etykietę + Ukryta etykieta + Etykieta o tej nazwie już istnieje + Nie udało się zapisać kolejności etykiet. Spróbuj ponownie. + Nie udało się zaktualizować etykiety. Spróbuj ponownie. + + Stuknij ponownie, aby usunąć + + Nowe zadanie + Nie udało się wyczyścić wykonanych zadań. Spróbuj ponownie. + Czy na pewno chcesz usunąć wszystkie wykonane zadania? Tej operacji nie można cofnąć. + Wyczyść wykonane zadania + Wykonano: %1$s + Nie udało się zapisać zadania. Spróbuj ponownie. + Opis + Edytuj zadanie + Brak aktywnych zadań. + Brak zadań pasujących do wybranych filtrów. + Wszystkie zadania + Wykonane + Nowe zadanie + Postęp + Nie udało się zmienić stanu zadania. Spróbuj ponownie. + Nie udało się zaktualizować zadania. Spróbuj ponownie. + + Tekst + + Motyw + Ciemny + Jasny + Systemowy + + + Cofnij + + Bez tytułu + + Przełącz na widok siatki + Przełącz na widok listy + + + Usunąć %d notatkę? + Usunąć %d notatki? + Usunąć %d notatek? + Usunąć %d notatki? + + + Zbyt wiele prób. Spróbuj ponownie za %d sekundę + Zbyt wiele prób. Spróbuj ponownie za %d sekundy + Zbyt wiele prób. Spróbuj ponownie za %d sekund + Zbyt wiele prób. Spróbuj ponownie za %d sekundy + + + Wykonano %1$d z %2$d zadania + Wykonano %1$d z %2$d zadań + Wykonano %1$d z %2$d zadań + Wykonano %1$d z %2$d zadania + + Kopia zapasowa + Szyfrowana kopia, import i Markdown + Kopia zapasowa + Zaszyfrowana Twoją frazą odzyskiwania + Kopia zapasowa zawiera wszystkie Twoje notatki, zadania i etykiety, zaszyfrowane 12-wyrazową frazą odzyskiwania. Encly nigdzie jej nie wysyła: to Ty wybierasz, gdzie zapisać plik. Na nowym telefonie te same 12 słów ją przywróci. + Ostatni eksport: %1$s + Nie wyeksportowano jeszcze żadnej kopii + Eksportuj kopię + Importuj kopię + Przywróć notatki, zadania i etykiety z pliku kopii + Kopia zapasowa zapisana + Potwierdź, że to Ty + Wpisz PIN, aby kontynuować + Fraza odzyskiwania + Kopie są szyfrowane frazą odzyskiwania, więc otworzyć je może tylko jej 12 słów. Ten sejf jeszcze jej nie ma. Utwórz ją teraz: pozwoli też odblokować sejf, jeśli zapomnisz PIN. + Utwórz frazę odzyskiwania + Zapisz te 12 słów na papierze, po kolei, i przechowuj je offline. Każdy, kto je ma, może otworzyć Twoje kopie. + Zapisałem(-am) je + Słowa się nie zgadzają. Sprawdź, co zapisałeś(-aś). + Fraza odzyskiwania zapisana + Potwierdź frazę odzyskiwania + Wpisz raz 12-wyrazową frazę odzyskiwania tego sejfu, aby Encly mogła szyfrować nią kopie. + Wpisz frazę odzyskiwania + Wpisz 12 słów frazy odzyskiwania, którą utworzono tę kopię. + Odszyfruj + Import kopii + Scal + Zastąp + Zastąpić wszystkie dane? + Wszystkie notatki, zadania i etykiety w tym sejfie zostaną usunięte i zastąpione kopią. Tej operacji nie można cofnąć. + Zaimportowano: notatki %1$d, zadania %2$d, etykiety %3$d. Pominięto (już istnieją): %4$d. + Ten plik nie jest kopią zapasową Encly + Tę kopię utworzyła nowsza wersja Encly. Zaktualizuj aplikację i spróbuj ponownie. + Plik kopii jest uszkodzony lub niekompletny + Plik jest za duży, aby był kopią Encly + Sejf jest za duży na jeden plik kopii (64 MB). Nic nie zostało zapisane. + Ten plik już istnieje. Wybierz nową nazwę: starsza kopia nigdy nie jest nadpisywana. + Żadna aplikacja na tym urządzeniu nie może otwierać ani zapisywać plików. + Te słowa nie otwierają tej kopii albo plik został zmieniony + Wpisz prawidłową 12-wyrazową frazę odzyskiwania + Sejf został zablokowany. Odblokuj go i spróbuj ponownie. + Nie udało się odczytać ani zapisać kopii. Nic nie zostało zmienione. + Te słowa nie są frazą odzyskiwania tego sejfu + Nie udało się zapisać frazy odzyskiwania + Przywróć z kopii + Wybierz plik kopii + Nie udało się przywrócić kopii, więc konfiguracja nie jest jeszcze zakończona. Spróbuj ponownie albo kontynuuj z pustym sejfem i zaimportuj plik później w Ustawienia → Kopia zapasowa. + Kopia nie została przywrócona + Spróbuj ponownie + Kontynuuj bez niej + Ta wersja Encly nie może odczytać tej notatki. + Otwórz link + Kopiuj link + Edytuj link + Link skopiowany + Żadna aplikacja na tym urządzeniu nie może otworzyć tego linku. + Zapisywać i otwierać można tylko linki internetowe (http, https) i e-mailowe. + Nie udało się odczytać notatek + Nie udało się odczytać etykiet + Notatki nadal są zapisane w postaci zaszyfrowanej na tym urządzeniu. Zamknij Encly i otwórz ponownie, aby spróbować jeszcze raz. + Odblokowano Encly frazą odzyskiwania. Ustaw nowy PIN, aby kontynuować. + Usuń zadanie + Zadanie usunięte + Nie udało się usunąć zadania. Spróbuj ponownie. + Tryb + Czcionka + Papier + Las + Ocean + Grafit + Północ + Zgodnie z ustawieniem systemu. Teraz jasny. + Zgodnie z ustawieniem systemu. Teraz ciemny. + Zawsze jasny. + Zawsze ciemny. + Motyw Północ jest zawsze ciemny. + Kolor akcentu z tapety. Android 12 i nowszy. + Dynamiczne kolory są włączone: akcent pochodzi z tapety. + Playfair Display i Source Sans 3 + Poppins i Source Sans 3 + Wszędzie IBM Plex Sans + Lista na oglądanie mieszkania + Przed wizytą: sprawdzić ciśnienie wody, zapytać, ile kosztowało ogrzewanie zeszłej zimy. + Osobiste + Podgląd + Motyw, tryb i czcionka + Poprawnie + Odblokuj odciskiem palca + Krok %1$d z %2$d + Notatnik, który otworzysz tylko ty. + Notatki i zadania zaszyfrowane na tym telefonie. Konfiguracja zajmie około minuty. + Bez konta + Bez rejestracji, bez e-maila, bez resetowania hasła. + Działa offline + Encly nigdy nie łączy się z internetem. + Szyfrowane na urządzeniu + Twój sejf jest zamknięty kluczem, który masz tylko ty. + Zaczynamy + Mam kopię zapasową + Wybierz PIN + Od 6 do 12 cyfr. Odblokowuje Encly i chroni klucz do twojego sejfu. Nie da się go zresetować przez e-mail. + Wpisz PIN jeszcze raz + Żebyśmy mieli pewność, że to ten właściwy. + PIN nadal działa jako zapasowy sposób + Dwanaście słów przywraca wszystko + Encly da ci frazę odzyskiwania. Zapisz ją na papierze: to jedyny zapasowy klucz. + Nie pamiętasz PIN-u? + Fraza odblokowuje sejf i pozwala ustawić nowy PIN. + Otwiera twoje kopie + Zaszyfrowane pliki kopii da się odczytać tylko z tymi słowami. + Nowy telefon + Plik kopii i fraza przywracają każdą notatkę, zadanie i etykietę. + Nikt nie odzyska jej za ciebie, nawet my. Jeśli stracisz i PIN, i frazę, notatki przepadną. + Utwórz moją frazę + Pomiń: tylko PIN, bez kopii + Zapisz te słowa + Na papierze, w tej kolejności. Nie na zrzucie ekranu, nie w chmurze. + Ukryj słowa + Pokaż słowa + Słowa zapisane + Sprawdź trzy słowa + Przepisz je z kartki, nie z pamięci. + Słowo nr %1$d + Wpisz słowo + To nie jest słowo nr %1$d + Potwierdź + Pokaż słowa jeszcze raz + Twój sejf jest gotowy + Wszystko, co od teraz napiszesz, jest szyfrowane przed zapisaniem. + PIN ustawiony + Pytamy o niego przy każdym otwarciu Encly + Odblokowanie odciskiem palca włączone + Spróbuj odcisku palca ponownie + Zmienisz to w Ustawieniach, Bezpieczeństwo + Fraza odzyskiwania sprawdzona + Przechowuj kartkę w bezpiecznym miejscu + Brak frazy odzyskiwania + Kopie zapasowe są wyłączone. Frazę możesz utworzyć później w Ustawieniach, Bezpieczeństwo. + Kopia przywrócona + Twoje notatki, zadania i etykiety wróciły + Wskazówka: co jakiś czas eksportuj zaszyfrowaną kopię w Ustawieniach, Kopia zapasowa. Otwiera ją twoja fraza. + Otwórz mój notatnik + Przywróć, potem ustaw PIN + Wybierz plik kopii Encly, a potem wpisz 12 słów, z którymi go utworzono. + 1 · Plik kopii + Dotknij, aby wybrać inny plik + 2 · Fraza odzyskiwania + Te słowa staną się też frazą odzyskiwania tego telefonu. + Notatki + Zablokuj teraz + Szukaj w tytułach i treści + Opcje widoku + Brak wyników dla „%1$s” + Dzisiaj + Wczoraj + + Zadania · %d otwarte + Zadania · %d otwarte + Zadania · %d otwartych + Zadania · %d otwartego + + Zadania · brak otwartych + Nie ma jeszcze zadań + Stuknij Nowe zadanie, aby dodać pierwsze. + Wszystko zrobione + Zrobione · %1$d + Brak notatek z tagiem „%1$s” + Tutaj pojawią się notatki z tym tagiem. + Pokaż wszystkie notatki + Kosz jest pusty + Stuknij notatkę, aby ją przeczytać. Przytrzymaj, aby zaznaczyć. + Usuń na zawsze + Opróżnij kosz + Szukaj notatek + Notatki i zadania + Wersja, polityka prywatności i kontakt + Odpowiedzi na częste pytania + Licencje open source + Biblioteki i czcionki, których używa Encly + Zbudowane na open source + Encly powstał dzięki tym bibliotekom i czcionkom. Stuknij, aby przeczytać licencję. + Biblioteki + Czcionki + Nie ma jeszcze tagów + Tagi grupują notatki. Utwórz tag powyżej, a potem wybierz go dla notatki, przytrzymując ją. + Stuknij tag, aby zmienić nazwę, ukryć lub usunąć. Przeciągnij uchwyt, aby zmienić kolejność. + Dodaj tag + Nazwa + Pokazuj na liście notatek + Ukryty tag zachowuje notatki, ale nie ma filtra + Przeciągnij, aby zmienić kolejność + Przenieś wyżej + Przenieś niżej + + Zaznaczono %d + Zaznaczono %d + Zaznaczono %d + Zaznaczono %d + + Edytuj etykiety + Tytuł + Nie zapisano + Zarządzaj etykietami + Odrzucić zmiany? + Wszystko, co zmieniono od otwarcia tej notatki, zostanie utracone. Tej operacji nie można cofnąć. + Wszystko już jest twoje + Każda funkcja jest dostępna dla wszystkich. Nic nie jest ukryte za płatnością. + Bez reklam i śledzenia + Encly nigdy nie widzi Twoich notatek i nie zarabia na danych. Utrzymują go wyłącznie darowizny. + Na co idą pieniądze + Urządzenia testowe (najbliższy cel: Mac mini do budowania i testowania) oraz czas na wydania i poprawki. + Karta lub PayPal, jednorazowo albo co miesiąc + Otwiera ko-fi.com w przeglądarce. Sam Encly nigdy nie obsługuje płatności. + Co robi długie naciśnięcie notatki? + Otwiera akcje notatki: zmianę etykiety, dodanie krótkiego opisu widocznego na karcie, duplikowanie notatki lub przeniesienie jej do kosza. + Jak odzyskać usuniętą notatkę? + Usunięte notatki trafiają do Kosza w menu. Tam możesz przywrócić notatkę lub usunąć ją na zawsze. Encly nigdy sam nie opróżnia kosza. + Co robi „Zablokuj edycję”? + Ustawia notatkę tylko do odczytu, żeby nie zmienić jej przypadkiem. Tekst nadal można zaznaczyć i skopiować. Włączysz i wyłączysz to w menu edytora. + Jak działają kopie zapasowe? + W Ustawienia → Kopia zapasowa eksportujesz jeden zaszyfrowany plik, który otworzy tylko twoja fraza odzyskiwania. Aby przywrócić, wybierz plik, wpisz lub wklej 12 słów, a następnie scal go z tym sejfem albo zastąp wszystko. + Pokaż klawiaturę + Ukryj klawiaturę + Wymazać wszystkie dane? + Nie udało się usunąć danych. Nic nie zostało zmienione. + Wymaż + Podzadania + Podzadanie + Dodaj podzadanie + Usuń podzadanie + Wszystkie podzadania wykonane + Wykonaj zadanie + Pokaż podzadania + Ukryj podzadania + Rozwinięte + Zwinięte + Podzadanie usunięte + Następny krok: %1$s + Pierwszy krok + + Wykonano %1$d z %2$d podzadania + Wykonano %1$d z %2$d podzadań + 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 + Przypnij + Odepnij + Archiwizuj + Przywróć z archiwum + Przypięte + Pozostałe + Archiwum + Archiwum jest puste + Zarchiwizowane notatki znikają z listy i wyszukiwania, ale zostają w sejfie. Zarchiwizuj notatkę z jej menu. + Nie udało się zarchiwizować notatki + Nie udało się przywrócić notatki z archiwum + Kolor + Brak koloru + Czerwony + Pomarańczowy + Żółty + Zielony + Turkusowy + Niebieski + Fioletowy + Różowy + Eksportuj jako Markdown + Kopiuj jako Markdown + Skopiowano jako Markdown + Czytelny eksport + Zapisz wszystkie notatki i zadania jako czytelne pliki .md w archiwum .zip. Plik nie jest zaszyfrowany. + Eksportować bez szyfrowania? + Wyeksportowany plik nie jest zaszyfrowany. Każdy, kto go zdobędzie, przeczyta twoje notatki. Miejsce, w którym go zapiszesz, także folder w chmurze, będzie zawierać czytelną kopię, której Encly nie może ani chronić, ani cofnąć. Następnie potwierdzisz kodem PIN. + Eksport Markdown zapisany + Czy mogę wyeksportować notatki jako Markdown? + Tak. W menu notatki wybierz „Eksportuj jako Markdown”, aby dostać jeden plik .md, albo przejdź do Ustawienia → Kopia zapasowa → „Eksportuj jako Markdown”, aby dostać archiwum .zip ze wszystkimi notatkami (zarchiwizowane w folderze archive, bez kosza) i plikiem z zadaniami. Te pliki nie są zaszyfrowane: Encly najpierw ostrzeże, poprosi o PIN i zapisze tylko tam, gdzie wskażesz. + Gesty przesunięcia + Przesuwanie notatek + Przesuń kartę notatki, aby wykonać akcję + Przesunięcie w prawo + Przesunięcie w lewo + Przypnij lub odepnij + Następny kolor + Przenieś do kosza + Nic + Przenieść do kosza? + Notatkę można przywrócić z kosza. + Do czego służą przesunięcia notatki? + Domyślnie przesunięcie notatki w prawo przypina ją lub odpina, a w lewo archiwizuje. W Ustawienia → Gesty przesunięcia możesz wybrać dla każdego kierunku: przypnij, archiwizuj, następny kolor, przenieś do kosza (zawsze z pytaniem) lub nic, a także wyłączyć przesuwanie. TalkBack oferuje te same akcje w menu akcji notatki. + Tekst w notatkach + Wartość domyślna dla każdej notatki. Notatka może ustawić własną w swoim menu. + Użyj domyślnych ustawień tekstu + Kod źródłowy + Encly jest open source: przeczytaj kod na GitHubie + Otwórz + Otwórz w nowej karcie + Wybierz notatkę lub zacznij nową + Plik + Widok + Okno + Pomoc + Zakończ + Minimalizuj + Nowe okno + Otwórz w nowym oknie + Eksportuj jako PDF + Drukuj + Wyeksportować do PDF bez szyfrowania? + Plik PDF nie jest zaszyfrowany. Każdy, kto go zdobędzie, przeczyta twoją notatkę. Miejsce, w którym go zapiszesz, także folder w chmurze, będzie zawierać czytelną kopię, której Encly nie może ani chronić, ani cofnąć. Następnie potwierdzisz kodem PIN. + Drukować bez szyfrowania? + Drukowanie wysyła tekst notatki do drukarki bez szyfrowania. Systemowa kolejka wydruku może zachować jego tymczasowe kopie, a drukarka sieciowa lub w chmurze może go także zapisać. Encly nie może ich usunąć. Następnie potwierdzisz kodem PIN. + Zapisano PDF + Wysłano do drukarki + Ta notatka jest otwarta do edycji w innym oknie. Tutaj jest tylko do odczytu. + Co nowego + Zmiany w tej wersji + Rozumiem + Notatki są trwale usuwane po 28 dniach w koszu. + Jak długi może być mój PIN? + Od 6 do 12 cyfr, do wyboru. Krótszych PIN-ów nie oferujemy: blokada i klucz urządzenia ograniczają liczbę prób, a 6 cyfr to najkrótszy PIN, który przy tych ograniczeniach pozostaje bezpieczny. Ekran blokady nigdy nie pokazuje długości PIN-u: po wpisaniu stukasz „Odblokuj”, tak samo w przypadku PIN-u kasującego i przymusowego. PIN-y ustawione wcześniej działają bez zmian. + Kod + Tabela + Dodaj wiersz + Dodaj kolumnę + Usuń wiersz + Usuń kolumnę + Wiersz nagłówka + Opcje komórki + Pasek edytora + Wybierz i ułóż narzędzia + Przeciągnij uchwyt, aby zmienić kolejność. Oko ukrywa narzędzie lub je przywraca. Narzędzia z sekcji „Więcej” są za strzałką. + Przywróć + Pasek + Więcej + Ukryte + Ukryj narzędzie + Pokaż narzędzie + W górę + W dół + + jeszcze %1$d etykieta + jeszcze %1$d etykiety + jeszcze %1$d etykiet + jeszcze %1$d etykiety + + diff --git a/app/src/main/res/values-pl/strings_handoff.xml b/core/resources/src/commonMain/strings/values-pl/strings_handoff.xml similarity index 100% rename from app/src/main/res/values-pl/strings_handoff.xml rename to core/resources/src/commonMain/strings/values-pl/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values-pl/strings_import.xml b/core/resources/src/commonMain/strings/values-pl/strings_import.xml new file mode 100644 index 00000000..7bbae07c --- /dev/null +++ b/core/resources/src/commonMain/strings/values-pl/strings_import.xml @@ -0,0 +1,79 @@ + + + + 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 + Notatki-zadania zaimportowane jako zwykłe notatki: %1$d + Pominięte elementy zaszyfrowane lub nieobsługiwane: %1$d + Pliki, których nie dało się użyć: %1$d + Archiwa w archiwach, nieotwarte: %1$d + Importuję… + Zaimportowano + Dodano: %1$d, pominięto jako duplikaty: %2$d, nie zaimportowano: %3$d. + Twoje oryginalne pliki nie zostały zmienione. Usuń je sam po sprawdzeniu notatek tutaj. + Z jakiej aplikacji są te pliki? + Pliki wyglądają na pochodzące z więcej niż jednej aplikacji. Wybierz tę do zaimportowania; reszta zostanie pominięta. + Nic nie zaimportowano + Ta kopia Standard Notes jest zaszyfrowana. Pobierz w Standard Notes odszyfrowaną kopię i wybierz ją. + To zaszyfrowana kopia Notesnook. W Notesnook wyeksportuj notatki jako Markdown i wybierz plik .zip. + Te pliki nie są obsługiwanym eksportem. Sprawdź obsługiwane aplikacje albo wybierz pliki Markdown lub tekstowe. + Ten wybór jest większy, niż Encly może zaimportować naraz (64 MB notatek, 2 GB łącznie lub 20 000 notatek). Zaimportuj go w częściach. + W tym wyborze nie znaleziono notatek. + Nie udało się odczytać wybranych plików. + Na tym telefonie nie ma aplikacji do wybierania plików, której Encly mógłby użyć. + Sejf zamknął się podczas importu. Odblokuj Encly i spróbuj ponownie. + Coś poszło nie tak i nic nie zaimportowano. Twój sejf pozostał bez zmian. + Zaznaczono %1$d z %2$d + Zaznacz wszystkie + Odznacz wszystkie + Wszystkie + Tylko nowe + W koszu + Bez tytułu + Lista kontrolna + W koszu + Już w sejfie + Importuj: %1$d + Wybierz ponownie + diff --git a/core/resources/src/commonMain/strings/values-pl/strings_security.xml b/core/resources/src/commonMain/strings/values-pl/strings_security.xml new file mode 100644 index 00000000..5b4e13d6 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-pl/strings_security.xml @@ -0,0 +1,151 @@ + + + Wpisz lub wklej swoje 12 słów + Słowo %1$d z %2$d + Słowo %1$d nie należy do słów frazy odzyskiwania. Sprawdź pisownię. + Te 12 słów nie tworzy prawidłowej frazy odzyskiwania. Sprawdź każde słowo i ich kolejność. + Ten sejf ma już frazę odzyskiwania + Dodać do notatek czy je zastąpić? + Ta kopia zawiera: %1$s, %2$s i %3$s. + Wszystko tutaj zostaje, a brakujące elementy zostaną dodane. Te, które już są, zostaną pominięte. + Zastąp wszystko + Usuń wszystkie notatki, zadania i tagi z tego telefonu, a potem przywróć kopię. + Zachowaj wszystkie notatki, zadania i tagi w jednym zaszyfrowanym pliku i przywróć je na dowolnym telefonie. + Eksport + Import + Twoje notatki są zaszyfrowane. Odblokowuje je Twój PIN (i odcisk palca, jeśli jest włączony). + Twoje notatki są zaszyfrowane. Odblokowuje je Twój PIN (i odcisk palca, jeśli jest włączony), a gdy zapomnisz PIN, fraza odzyskiwania. + Utworzona podczas konfiguracji. Encly nigdy nie przechowuje słów i nie może ich ponownie pokazać. Nie masz ich? Zastąp frazę poniżej. + Skonfigurowana + Po zresetowaniu możesz przywrócić plik kopii zapasowej za pomocą jego 12 słów. + Nie można teraz otworzyć sejfu + Encly nie mogło odczytać swoich danych. Może używa ich inna aplikacja lub system albo urządzenie jest nadal zablokowane. Twoje notatki nie zostały utracone. Spróbuj ponownie za chwilę. + Spróbuj ponownie + Nadal nie można odczytać. Spróbuj ponownie za chwilę. + Zmień PIN + + %d notatka + %d notatki + %d notatek + %d notatki + + + %d zadanie + %d zadania + %d zadań + %d zadania + + + %d tag + %d tagi + %d tagów + %d tagu + + + Twój PIN nie może już odblokować Encly na tym telefonie: jego klucz sprzętowy został zresetowany. Użyj frazy odzyskiwania (lub odcisku palca), a potem ustaw nowy PIN. + Urządzenie zgłasza, że jest nadal zablokowane, więc Encly pozostało zamknięte. Odblokuj urządzenie i spróbuj ponownie. + + Zbyt wiele prób. Spróbuj ponownie za %d minutę + Zbyt wiele prób. Spróbuj ponownie za %d minuty + Zbyt wiele prób. Spróbuj ponownie za %d minut + Zbyt wiele prób. Spróbuj ponownie za %d minuty + + + Zbyt wiele prób. Spróbuj ponownie za %d godzinę + Zbyt wiele prób. Spróbuj ponownie za %d godziny + Zbyt wiele prób. Spróbuj ponownie za %d godzin + Zbyt wiele prób. Spróbuj ponownie za %d godziny + + Zmień frazę odzyskiwania + Otrzymaj 12 nowych słów. Kopie zapasowe zrobione wcześniej nadal wymagają starych. + Zmienić frazę odzyskiwania? + Otrzymasz 12 nowych słów. Stara fraza przestanie odblokowywać Twoje notatki na tym telefonie. Kopie zapasowe, które już masz, otworzą się tylko starą frazą, więc zachowaj ją, dopóki przechowujesz te kopie, albo zrób potem nową kopię. + Otrzymaj nowe słowa + Stara fraza przestaje tu działać + Po potwierdzeniu tylko te słowa odblokują Twoje notatki, gdy zapomnisz PIN. Kopie zapasowe zrobione wcześniej nadal wymagają starych słów. + Fraza odzyskiwania zmieniona. Zrób nową kopię zapasową, aby używać jej także do kopii. + Prywatność + Ścisła prywatność klawiatury + Prosi klawiaturę o brak podpowiedzi, brak przewidywania w chmurze i nieodczytywanie tekstu wokół kursora. Wyłącza autokorektę, a niektóre klawiatury ukrywają emoji lub wprowadzanie głosowe. + Zezwól na zrzuty ekranu do zablokowania aplikacji + Zrzuty i nagrywanie ekranu pozostają zablokowane, dopóki nie zezwolisz na nie tutaj. Opcja wyłącza się sama, gdy Encly się zablokuje. + Zezwolić na zrzuty ekranu? + Zrzuty ekranu mogą trafić do galerii i kopii zapasowych w chmurze razem z Twoimi notatkami. Ekran blokady, wpisywanie PIN-u i fraza odzyskiwania pozostają chronione. Zezwolenie kończy się, gdy Encly się zablokuje. + Blokuj po wyjściu + Jak długo Encly pozostaje otwarty, gdy przełączasz się do innej aplikacji. Wyłączenie ekranu lub zablokowanie telefonu zawsze blokuje go od razu. + Od razu + Po 15 sekundach + Po 30 sekundach + Po 1 minucie + Po 2 minutach + Po 5 minutach + Po 10 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. + PIN powiązany z tym telefonem + PIN jest łączony z kluczem w bezpiecznym układzie telefonu, więc nie da się go odgadnąć nigdzie indziej. + Osobny zamek dla każdego wejścia + PIN, fraza odzyskiwania i odcisk palca otwierają własne zaszyfrowane miejsca. Odcisk jest wymagany za każdym razem. + Strefa zagrożenia + Przytrzymaj 5 sekund, aby usunąć + + PIN kasujący + Drugi PIN, który kasuje notatki, gdy wpiszesz go na ekranie blokady + Wyłączony po zresetowaniu klucza PIN. Ustaw go ponownie, jeśli go używałeś. + Jeśli ktoś zmusza cię do otwarcia Encly, wpisz PIN kasujący zamiast swojego PIN-u. Encly bez żadnego śladu skasuje notatki, zadania i etykiety i otworzy się jako pusty sejf. Działa tylko na ekranie blokady. + Po skasowaniu Encly otwiera się PIN-em kasującym: staje się on PIN-em nowego, pustego sejfu. Twój stary PIN przestaje działać, więc ktoś, kto widział, jak wpisujesz PIN kasujący, może nim znów otworzyć Encly i nie zauważy nic dziwnego. + Wyeksportowane już pliki kopii zapasowych pozostają nietknięte. Nadal otwierają się frazą odzyskiwania i są jedyną drogą powrotu. + Odblokowanie biometryczne pozostaje włączone: ktoś może zmusić cię do przyłożenia palca do czytnika. Wyłącz je, jeśli to dla ciebie ryzyko. + Wyłącz odblokowanie biometryczne + Chroni, gdy ktoś zmusza cię do odblokowania, ale nie przed analizą kryminalistyczną telefonu. Podczas blokady po nieudanych próbach PIN kasujący również nie działa. Encly nigdy nie pokazuje, czy PIN kasujący jest ustawiony: nowy zastępuje stary, a jeśli zmienisz swój PIN na PIN kasujący, zostanie on wyłączony. + Ustaw PIN kasujący + Wyłącz PIN kasujący + Potwierdź swój PIN, aby skonfigurować PIN kasujący + Utwórz PIN kasujący + Od 6 do 12 cyfr, inny niż twój PIN + Potwierdź PIN kasujący + Wpisz PIN kasujący ponownie + PIN kasujący musi różnić się od twojego PIN-u + Nie udało się zapisać PIN-u kasującego. Spróbuj ponownie. + 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 + Od 6 do 12 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 + Pokazuj podgląd notatek + Lista notatek pokazuje pierwsze wiersze każdej notatki. Wyłącz, aby widzieć tylko tytuł, etykietę i datę; wyszukiwanie nadal przeszukuje cały tekst. + Wpisane cyfry: %1$d + Odblokuj + Fraza odzyskiwania zaakceptowana + Twój PIN nadal działa. Zachowaj go albo ustaw nowy. + Zachowaj mój PIN + Ustaw nowy PIN + diff --git a/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml new file mode 100644 index 00000000..2830c2fe --- /dev/null +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -0,0 +1,729 @@ + + + Sobre + Um espaço local e criptografado para notas, ideias e tarefas, sem sincronização na nuvem. + Proteção de dados + Os dados do cofre são armazenados localmente em um banco de dados SQLCipher criptografado + Perguntas frequentes + Feedback + Relate um bug ou sugira uma melhoria no GitHub + Porque importa e como ajudar no Ko-fi + Nenhum navegador encontrado. Abra o link manualmente: %1$s + Nenhum app de e-mail encontrado + Política de privacidade + Como armazenamos e protegemos seus dados + Avaliar o app + Deixe uma avaliação na Google Play Store + Suporte e contato + Falar com o desenvolvedor + Se você tiver dúvidas ou sugestões, pode me enviar um e-mail. + + Continuar + + Adicionar + + Todas + + De 6 a 12 dígitos. Toque para alterar o PIN. + PIN + Autenticação + + Voltar + + A alteração da proteção biométrica não foi confirmada + Não foi possível criar a chave biométrica + Confirme sua identidade + Autenticação + Confirme sua biometria + Acesso à chave + Confirme sua biometria para alterar a proteção + Confirmação de segurança + Confirme sua biometria para acessar a chave criptografada + Desbloquear o Encly + Usar PIN + A autenticação biométrica não está disponível + + Mais ferramentas de bloco + Lista com marcadores + Lista de verificação + Título + Título %1$d + Link + Lista + Bloquear lista de tarefas + Desbloquear lista de tarefas + Selecionar bloco + Reordenar item + Mover item para cima + Mover item para baixo + Aumentar recuo do item + Diminuir recuo do item + Blocos selecionados: %1$d + Limpar seleção + Copiar + Cortar + Excluir blocos selecionados + Selecionar tudo + Copiado + Mover bloco para baixo + Mover bloco para cima + Lista numerada + Separador + + Cancelar + + + Quer mesmo excluir estas notas permanentemente? Esta ação não pode ser desfeita. + Quer mesmo esvaziar a lixeira permanentemente? Esta ação não pode ser desfeita. + Esvaziar a lixeira? + + Limpar + + Fechar + + Recolher + + Criar uma etiqueta + + Hoje %1$s + Ontem %1$s + + Excluir + Excluir bloco + Excluir + Excluir etiqueta + Esta etiqueta será removida de todas as suas notas. As notas em si não serão excluídas. + + Excluir + Cancelar + Quer mesmo excluir esta nota permanentemente? Esta ação não pode ser desfeita. + Excluir a nota? + + O Encly é gratuito e de código aberto, sem anúncios, sem rastreamento e sem contas. As doações são a sua única receita. + Apoiar o desenvolvimento + + Concluído + + Duplicar + + Cores dinâmicas + + Editar + + Bloquear edição + + Ainda não há notas + Toque em Nova nota para escrever a primeira. + As notas eliminadas ficam aqui até as restaurar ou eliminar de vez. + + Digite um link + + Não foi possível abrir o banco de dados + + Expandir + + Não foi possível carregar o conteúdo + + O Encly salva as suas alterações e, após o atraso escolhido em Configurações → Segurança (15 segundos por padrão), fecha o banco de dados criptografado e apaga a chave de sessão. Desligar a tela ou bloquear o telefone faz isso na hora. Depois, você desbloqueia de novo. + O que acontece quando saio do app? + Use a chave de biometria em Configurações → Segurança. Criar ou remover o slot biométrico exige autenticação biométrica forte. + Como ativo ou desativo a biometria? + Abra Configurações → Segurança, confirme seu PIN atual e digite o novo duas vezes. + Como altero meu PIN? + O Encly gera uma chave aleatória de 256 bits para o banco de dados. Seu PIN protege um slot de desbloqueio AES-GCM separado, e a biometria usa uma chave do Android Keystore que exige autenticação a cada uso. + Como a chave do banco de dados é protegida? + Na tela de bloqueio, toque em \"Recuperar acesso\" e digite as 12 palavras. Se o PIN ainda funcionar, você pode mantê-lo ou definir um novo PIN; se não, define um novo PIN na hora. Sem frase de recuperação não há outra forma de entrar: ninguém, nem o desenvolvedor, pode redefini-la. + E se eu esquecer meu PIN? + Abra Configurações → Idioma e escolha um dos idiomas disponíveis, ou \"Padrão do sistema\" para seguir o idioma do dispositivo. A alteração é aplicada na hora. + Como altero o idioma? + Toque em \"Nova nota\" na tela inicial. A barra acima do teclado adiciona títulos, listas de verificação, listas com marcadores e numeradas, citações, links e separadores. + Como crio uma nova nota? + É uma chave reserva de 12 palavras. Ela desbloqueia o cofre se você esquecer o PIN e é a única chave dos seus arquivos de backup. Você a cria durante a configuração, ou depois em Configurações → Segurança se tiver pulado. Guarde-a no papel, offline. + Para que serve a frase de recuperação? + Abra \"Sobre\" e use a opção de feedback ou escreva para o desenvolvedor. Nunca inclua sua frase de recuperação, seu PIN ou o texto privado das notas em um relatório de bug. + Como relato um bug? + Automaticamente enquanto você digita, e sair do editor conclui o último salvamento. Se um salvamento falhar, o editor mostra \"Não guardado\" em vermelho. + Como as alterações são salvas? + Notas, tarefas, criptografia local e recuperação de acesso, sem sincronização na nuvem. + Respostas às perguntas mais comuns + Não por padrão. A proteção de tela está ativa: o Encly bloqueia capturas de tela, gravação de tela e pré-visualizações sem proteção na lista de apps recentes. Se precisar de uma, Configurações → Segurança → «Permitir capturas de tela até o app bloquear» a remove após o seu PIN; as telas de bloqueio, PIN e frase de recuperação continuam protegidas, e a permissão se desliga quando o Encly bloqueia. + Posso fazer capturas de tela do Encly? + Noções básicas + Segurança e recuperação + Configurações + Solução de problemas + Notas, etiquetas e tarefas são armazenadas apenas no banco de dados SQLCipher local do Encly. Não há sincronização na nuvem nem backup automático: você mesmo pode exportar um backup criptografado em Configurações → Backup, ou uma cópia legível em Markdown, que não é criptografada. + Onde meus dados ficam armazenados? + As etiquetas agrupam as notas. Você pode criá-las, renomeá-las, ocultá-las e excluí-las, arrastá-las em qualquer ordem e filtrar a tela inicial por etiqueta. + Como funcionam as etiquetas? + As tarefas têm descrição e prioridade. Você pode filtrá-las por prioridade e marcá-las como concluídas. O Encly nunca coloca o texto das tarefas nas notificações do sistema. + O que as tarefas fazem? + Em Configurações → Aparência escolha um de cinco temas, modo claro, escuro ou do sistema, um de três estilos de fonte e o tamanho do texto. No Android 12 e posteriores você também pode usar a cor de destaque do papel de parede. + Como altero o tema? + O Encly nunca cria de propósito um banco de dados vazio no lugar de um cofre perdido ou ilegível. Não confirme a limpeza total se ainda precisar dos seus dados locais. + O Encly informa que o cofre está danificado + + Tamanho do texto + Espaçamento entre linhas + Padrão + Compacto + Espaçoso + Editorial + Moderno + Técnico + + + Escolher idioma + Padrão do sistema + + Adicionar link + + Item da lista + + + Voltar ao PIN + Recuperar acesso + Digite sua frase de recuperação de 12 palavras + Recuperação + Digite seu PIN para continuar + Desbloqueie suas notas + Desbloqueando… + Recuperar o acesso com a frase de recuperação + PIN incorreto + Frase de recuperação incorreta + + Mantenha pressionado para apagar tudo + Parece que os dados criptografados ou as chaves de criptografia foram alterados ou danificados, por isso não é possível recuperá-los.\n\nPara continuar usando o app, você precisa redefini-lo e apagar os dados danificados. Isso exclui permanentemente todas as notas e configurações salvas. + Infelizmente, seus dados estão danificados + Estes dados são de uma versão anterior + O Encly encontrou notas salvas por uma versão anterior à 2.0. O Encly 2.0 usa um novo formato criptografado e não consegue abri-las, e não é possível convertê-las.\n\nPara usar o app, comece do zero. Isso exclui para sempre as notas e configurações antigas deste telefone. + Mantenha pressionado para começar do zero + + Perguntas frequentes + Configurações + Apoiar o desenvolvedor + Etiquetas + Tarefas + Lixeira + + Mais opções + + + Sem etiqueta + + Nova nota + Não foi possível excluir a nota + Descrição… + Descartar todas as alterações + Não foi possível descartar as alterações + Não foi possível criar uma cópia + Não foi possível ler o conteúdo da nota. A edição foi desativada para não danificar os dados. + + %1$s (cópia) + Não foi possível restaurar a nota + Não foi possível salvar as alterações + Não foi possível mover a nota para a lixeira + + Não foi possível inicializar o app + Não foi possível criar a frase de recuperação + Não foi possível criar o cofre criptografado + + Abrir menu + + %1$d%% + + Digite o PIN novamente para confirmar + Confirme seu PIN + Confirme seu PIN atual antes de alterar sua chave de acesso + Digite seu PIN atual + De 6 a 12 dígitos para proteger suas notas + PIN alterado + Crie um PIN + O PIN atual está incorreto + Apagar dígito + Os PINs não coincidem. Tente novamente. + Não foi possível salvar o PIN + Não foi possível atualizar o PIN. Tente novamente. + + + Toque para começar a editar… + + Alta + Baixa + Média + Escolha uma prioridade + + Citação + Refazer + + Restaurar + + Salvar + + + + + Limpar pesquisa + + Impressão digital + Desbloqueio biométrico + Indisponível neste dispositivo + Nenhuma digital cadastrada. Adicione uma nas configurações do sistema. + A criptografia está ativa + Não configurada. Sem ela, esquecer o PIN significa perder tudo. Toque para criar uma. + Apagar todos os dados + Excluir todas as notas, tarefas e etiquetas e as chaves de criptografia deste dispositivo + Todas as notas, tarefas e etiquetas e as chaves de criptografia serão excluídas deste dispositivo, e o Encly vai recomeçar do zero. Isso não pode ser desfeito: exporte um backup antes se quiser guardar algo. + Criptografia e proteção de dados + Segurança + + + Aparência + Geral + Idioma + Privacidade + Mostrar tarefas + Mostrar o cartão de tarefas na tela inicial + Edição simples + Desativar os blocos dinâmicos e editar as notas como texto simples + + Ordenar por: + Por data de criação (mais antigas primeiro) + Criada ↑ + Por data de criação (mais recentes primeiro) + Criada ↓ + Por título (A a Z) + Título A–Z + Por título (Z a A) + Título Z–A + Por última edição (mais antigas primeiro) + Editada ↑ + Por última edição (mais recentes primeiro) + Editada ↓ + Apoiar o desenvolvedor + + Não foi possível salvar a etiqueta. Tente novamente. + Não foi possível excluir a etiqueta. Tente novamente. + Editar etiqueta + Etiqueta oculta + Já existe uma etiqueta com este nome + Não foi possível salvar a ordem das etiquetas. Tente novamente. + Não foi possível atualizar a etiqueta. Tente novamente. + + Toque de novo para excluir + + Nova tarefa + Não foi possível limpar as tarefas concluídas. Tente novamente. + Quer mesmo excluir todas as tarefas concluídas? Esta ação não pode ser desfeita. + Limpar tarefas concluídas + Concluída: %1$s + Não foi possível salvar a tarefa. Tente novamente. + Descrição + Editar tarefa + Nenhuma tarefa ativa. + Nenhuma tarefa corresponde aos filtros selecionados. + Todas as tarefas + Concluídas + Nova tarefa + Progresso + Não foi possível alterar o status da tarefa. Tente novamente. + Não foi possível atualizar a tarefa. Tente novamente. + + Texto + + Tema + Escuro + Claro + Sistema + + + Desfazer + + Sem título + + Mudar para visualização em grade + Mudar para visualização em lista + + + Excluir %d nota? + Excluir %d notas? + Excluir %d notas? + + + Muitas tentativas. Tente novamente em %d segundo + Muitas tentativas. Tente novamente em %d segundos + Muitas tentativas. Tente novamente em %d segundos + + + %1$d de %2$d tarefa concluída + %1$d de %2$d tarefas concluídas + %1$d de %2$d tarefas concluídas + + Backup + Backup criptografado, importação e Markdown + Backup + Criptografado com sua frase de recuperação + Um backup contém todas as suas notas, tarefas e etiquetas, criptografadas com sua frase de recuperação de 12 palavras. O Encly nunca o envia para lugar nenhum: você escolhe onde o arquivo é salvo. Em um celular novo, as mesmas 12 palavras o restauram. + Última exportação: %1$s + Nenhum backup exportado ainda + Exportar backup + Importar backup + Restaurar notas, tarefas e etiquetas de um arquivo de backup + Backup salvo + Confirme que é você + Digite seu PIN para continuar + Frase de recuperação + Os backups são criptografados com uma frase de recuperação, então só as 12 palavras dela podem abri-los. Este cofre ainda não tem uma. Crie agora: ela também permite desbloquear o cofre se você esquecer o PIN. + Criar frase de recuperação + Anote estas 12 palavras em papel, na ordem, e guarde-as offline. Quem as tiver pode abrir seus backups. + Já anotei + As palavras não conferem. Verifique o que você anotou. + Frase de recuperação salva + Confirme sua frase de recuperação + Digite uma vez a frase de recuperação de 12 palavras deste cofre para que o Encly possa criptografar backups com ela. + Digite a frase de recuperação + Digite as 12 palavras da frase de recuperação com a qual este backup foi feito. + Descriptografar + Importar backup + Mesclar + Substituir + Substituir todos os dados? + Todas as notas, tarefas e etiquetas deste cofre serão apagadas e substituídas pelo backup. Isso não pode ser desfeito. + Importado: notas %1$d, tarefas %2$d, etiquetas %3$d. Ignorado (já existia): %4$d. + Este arquivo não é um backup do Encly + Este backup foi feito por uma versão mais nova do Encly. Atualize o app e tente novamente. + O arquivo de backup está danificado ou incompleto + O arquivo é grande demais para ser um backup do Encly + Seu cofre é grande demais para um único arquivo de backup (64 MB). Nada foi salvo. + Esse arquivo já existe. Escolha um novo nome: um backup anterior nunca é substituído. + Nenhum app neste dispositivo pode abrir ou salvar arquivos. + Estas palavras não abrem este backup, ou o arquivo foi modificado + Digite uma frase de recuperação válida de 12 palavras + O cofre foi bloqueado. Desbloqueie-o e tente novamente. + Não foi possível ler ou gravar o backup. Nada foi alterado. + Estas palavras não são a frase de recuperação deste cofre + Não foi possível salvar a frase de recuperação + Restaurar de um backup + Escolher um arquivo de backup + Não foi possível restaurar o backup, então a configuração ainda não foi concluída. Tente de novo ou continue com um cofre vazio e importe o arquivo depois em Configurações → Backup. + Backup não restaurado + Tentar de novo + Continuar sem ele + Esta versão do Encly não consegue ler esta nota. + Abrir link + Copiar link + Editar link + Link copiado + Nenhum app deste dispositivo consegue abrir este link. + Só é possível salvar ou abrir links da web (http, https) e de e-mail. + Não foi possível ler suas notas + Não foi possível ler suas etiquetas + Suas notas continuam guardadas e criptografadas neste dispositivo. Feche o Encly e abra de novo para tentar outra vez. + Você desbloqueou o Encly com a frase de recuperação. Defina um novo PIN para continuar. + Excluir tarefa + Tarefa excluída + Não foi possível excluir a tarefa. Tente novamente. + Modo + Fonte + Papel + Floresta + Oceano + Grafite + Meia-noite + Segue a configuração do sistema. Agora claro. + Segue a configuração do sistema. Agora escuro. + Sempre claro. + Sempre escuro. + Meia-noite é sempre escuro. + Usa a cor de destaque do seu papel de parede. Android 12 ou mais recente. + As cores dinâmicas estão ativadas: o destaque vem do seu papel de parede. + Playfair Display e Source Sans 3 + Poppins e Source Sans 3 + IBM Plex Sans em tudo + Checklist da visita ao apartamento + Antes da visita: verificar a pressão da água, perguntar quanto custou o aquecimento no inverno passado. + Pessoal + Prévia + Tema, modo e fonte + Correto + Desbloquear com a digital + Passo %1$d de %2$d + Um caderno que só você pode abrir. + Notas e tarefas, criptografadas neste celular. A configuração leva cerca de um minuto. + Sem conta + Sem cadastro, sem e-mail, sem redefinição de senha. + Funciona offline + O Encly nunca se conecta à internet. + Criptografado no aparelho + Seu cofre é trancado com uma chave que só você tem. + Começar + Tenho um backup + Escolha um PIN + De 6 a 12 dígitos. Ele desbloqueia o Encly e protege a chave do seu cofre. Não há e-mail para redefini-lo. + Digite o PIN novamente + Para termos certeza de que é o que você queria. + O PIN continua funcionando como alternativa + Doze palavras trazem tudo de volta + O Encly vai lhe dar uma frase de recuperação. Anote-a no papel; ela é a única chave reserva. + Esqueceu o PIN? + A frase desbloqueia seu cofre e permite definir um novo PIN. + Abre seus backups + Arquivos de backup criptografados só podem ser lidos com estas palavras. + Celular novo + Arquivo de backup mais frase restauram cada nota, tarefa e etiqueta. + Ninguém pode recuperá-la por você, nem mesmo nós. Se perder o PIN e a frase, as notas se perdem. + Criar minha frase + Pular: só PIN, sem backups + Anote estas palavras + No papel, nesta ordem. Nem em captura de tela, nem na nuvem. + Ocultar palavras + Mostrar palavras + Já anotei + Confira três palavras + Digite-as a partir do papel, não de memória. + Palavra nº %1$d + Digite a palavra + Essa não é a palavra nº %1$d + Confirmar + Mostrar as palavras de novo + Seu cofre está pronto + Tudo o que você escrever a partir de agora é criptografado antes de ser salvo. + PIN definido + Pedido sempre que o Encly abre + Desbloqueio por digital ativado + Tentar a digital novamente + Altere em Configurações, Segurança + Frase de recuperação conferida + Guarde o papel em um lugar seguro + Sem frase de recuperação + Os backups estão desativados. Você pode criar uma frase depois em Configurações, Segurança. + Backup restaurado + Suas notas, tarefas e etiquetas estão de volta + Dica: exporte de vez em quando um backup criptografado em Configurações, Backup. É a sua frase que o abre. + Abrir meu caderno + Restaurar e depois definir um PIN + Escolha seu arquivo de backup do Encly e digite as 12 palavras com que ele foi criado. + 1 · Arquivo de backup + Toque para escolher outro arquivo + 2 · Frase de recuperação + Estas palavras também passam a ser a frase de recuperação deste celular. + Notas + Bloquear agora + Pesquisar títulos e texto + Opções de visualização + Nada corresponde a “%1$s” + Hoje + Ontem + + Tarefas · %d aberta + Tarefas · %d abertas + Tarefas · %d abertas + + Tarefas · nenhuma aberta + Ainda não há tarefas + Toque em Nova tarefa para adicionar a primeira. + Tudo feito + Concluídas · %1$d + Nenhuma nota com a etiqueta «%1$s» + As notas com esta etiqueta aparecem aqui. + Mostrar todas as notas + A lixeira está vazia + Toque numa nota para a ler. Mantenha premido para selecionar. + Eliminar de vez + Esvaziar lixeira + Pesquisar notas + Notas e tarefas + Versão, privacidade e contacto + Respostas a perguntas frequentes + Licenças de código aberto + As bibliotecas e tipos de letra que o Encly usa + Feito com código aberto + O Encly é feito com estas bibliotecas e tipos de letra. Toque numa para ler a licença. + Bibliotecas + Tipos de letra + Ainda não há etiquetas + As etiquetas agrupam as notas. Crie uma acima e escolha-a para uma nota com um toque longo. + Toque numa etiqueta para a renomear, ocultar ou eliminar. Arraste a pega para reordenar. + Adicionar etiqueta + Nome + Mostrar na lista de notas + Uma etiqueta oculta mantém as notas, mas não tem filtro + Arraste para reordenar + Mover para cima + Mover para baixo + + %d selecionada + %d selecionadas + %d selecionadas + + Editar etiquetas + Título + Não guardado + Gerir etiquetas + Descartar as alterações? + Tudo o que alterou desde que abriu esta nota será perdido. Esta ação não pode ser desfeita. + Tudo já é seu + Todas as funcionalidades estão disponíveis para todos. Nada fica bloqueado atrás de um pagamento. + Sem anúncios nem rastreio + O Encly nunca vê as suas notas e não ganha nada com dados. Só as doações o sustentam. + Para onde vai o dinheiro + Dispositivos de teste (próximo objetivo: um Mac mini para compilar e testar) e tempo para versões e correções. + Cartão ou PayPal, uma vez ou todos os meses + Abre ko-fi.com no seu navegador. O próprio Encly nunca processa pagamentos. + O que faz um toque longo numa nota? + Abre as ações da nota: mudar a etiqueta, adicionar uma descrição curta mostrada no cartão, duplicar a nota ou movê-la para a lixeira. + Como recupero uma nota excluída? + As notas excluídas vão para a Lixeira no menu. Lá você pode restaurar uma nota ou excluí-la para sempre. O Encly nunca esvazia a lixeira sozinho. + O que faz \"Bloquear edição\"? + Deixa a nota somente leitura para que não seja alterada por engano. Você ainda pode selecionar e copiar o texto. Ative ou desative no menu do editor. + Como funcionam os backups? + Em Configurações → Backup você exporta um arquivo criptografado que só a sua frase de recuperação abre. Para restaurar, escolha o arquivo, digite ou cole as 12 palavras e depois mescle com este cofre ou substitua tudo. + Mostrar teclado + Ocultar teclado + Apagar todos os dados? + Não foi possível apagar os dados. Nada foi alterado. + Apagar + Subtarefas + Subtarefa + Adicionar subtarefa + Excluir subtarefa + Todas as subtarefas estão concluídas + Concluir tarefa + Mostrar subtarefas + Ocultar subtarefas + Expandido + Recolhido + Subtarefa excluída + Próximo passo: %1$s + Primeiro passo + + %1$d de %2$d subtarefa concluída + %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 + Fixar + Desafixar + Arquivar + Desarquivar + Fixadas + Outras + Arquivo + O arquivo está vazio + As notas arquivadas saem da lista e da pesquisa, mas continuam no cofre. Arquive uma nota a partir do seu menu. + Não foi possível arquivar a nota + Não foi possível tirar a nota do arquivo + Cor + Sem cor + Vermelho + Laranja + Amarelo + Verde + Turquesa + Azul + Roxo + Rosa + Exportar como Markdown + Copiar como Markdown + Copiado como Markdown + Exportação legível + Salve todas as notas e tarefas como arquivos .md legíveis em um .zip. O arquivo não é criptografado. + Exportar sem criptografia? + O arquivo exportado não é criptografado. Qualquer pessoa que o obtenha pode ler suas notas. Onde quer que você o salve, inclusive em uma pasta na nuvem, ficará uma cópia legível que o Encly não pode proteger nem retirar. Em seguida, você confirmará com seu PIN. + Exportação Markdown salva + Posso exportar minhas notas como Markdown? + Sim. No menu de uma nota, escolha “Exportar como Markdown” para um arquivo .md, ou use Configurações → Backup → “Exportar como Markdown” para um .zip com todas as notas (as arquivadas em uma pasta archive, sem a lixeira) e um arquivo de tarefas. Esses arquivos não são criptografados: o Encly avisa antes, pede seu PIN e salva apenas onde você escolher. + Ações de deslizar + Deslizar notas + Deslize uma nota para agir sobre ela + Deslizar para a direita + Deslizar para a esquerda + Fixar ou desafixar + Próxima cor + Mover para a lixeira + Nada + Mover para a lixeira? + Você poderá restaurar a nota da lixeira. + Para que servem os deslizes em uma nota? + Por padrão, deslizar uma nota para a direita a fixa ou desafixa, e para a esquerda a arquiva. Em Configurações → Ações de deslizar, você escolhe para cada direção: fixar, arquivar, próxima cor, mover para a lixeira (sempre pergunta) ou nada, ou desativa os deslizes. O TalkBack oferece as mesmas ações no menu de ações da nota. + Texto das notas + O padrão de cada nota. Uma nota pode definir o seu no próprio menu. + Usar o texto padrão + Código-fonte + O Encly é de código aberto: leia o código no GitHub + Abrir + Abrir em nova guia + Escolha uma nota ou comece uma nova + Arquivo + Exibir + Janela + Ajuda + Sair + Minimizar + Nova janela + Abrir numa nova janela + Exportar como PDF + Imprimir + Exportar para PDF sem encriptação? + O PDF não está encriptado. Quem o obtiver pode ler a tua nota. Onde quer que o guardes, incluindo uma pasta na nuvem, ficará uma cópia legível que o Encly não pode proteger nem retirar. A seguir, confirmas com o teu PIN. + Imprimir sem encriptação? + Ao imprimir, o texto da tua nota é enviado para a impressora sem encriptação. A fila de impressão do sistema pode manter cópias temporárias e uma impressora de rede ou na nuvem também o pode guardar. O Encly não as pode remover. A seguir, confirmas com o teu PIN. + PDF guardado + Enviado para a impressora + Esta nota está aberta para edição noutra janela. Aqui é só de leitura. + Novidades + As alterações desta versão + Entendi + As notas são eliminadas definitivamente após 28 dias na lixeira. + Qual pode ser o tamanho do meu PIN? + Entre 6 e 12 dígitos, à sua escolha. PINs mais curtos não são oferecidos: o bloqueio e a chave do dispositivo limitam as tentativas, e 6 dígitos é o mínimo que continua seguro com esses limites. A tela de bloqueio nunca mostra o tamanho do seu PIN: você toca em «Desbloquear» quando terminar, e o mesmo vale para o PIN de apagamento e o PIN de coação. Os PINs definidos antes continuam funcionando como estão. + Código + Tabela + Adicionar linha + Adicionar coluna + Excluir linha + Excluir coluna + Linha de cabeçalho + Opções da célula + Barra do editor + Escolha e ordene as ferramentas + Arraste a alça para reordenar. O olho oculta uma ferramenta ou a traz de volta. As ferramentas de «Mais opções» ficam atrás da seta. + Repor + Barra + Mais opções + Ocultas + Ocultar ferramenta + Mostrar ferramenta + Subir + Descer + + mais %1$d etiqueta + mais %1$d etiquetas + mais %1$d etiquetas + + diff --git a/app/src/main/res/values-pt/strings_handoff.xml b/core/resources/src/commonMain/strings/values-pt/strings_handoff.xml similarity index 100% rename from app/src/main/res/values-pt/strings_handoff.xml rename to core/resources/src/commonMain/strings/values-pt/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values-pt/strings_import.xml b/core/resources/src/commonMain/strings/values-pt/strings_import.xml new file mode 100644 index 00000000..59ba206b --- /dev/null +++ b/core/resources/src/commonMain/strings/values-pt/strings_import.xml @@ -0,0 +1,79 @@ + + + + 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 + Notas de tarefa importadas como notas comuns: %1$d + Itens criptografados ou sem suporte ignorados: %1$d + Arquivos que não puderam ser usados: %1$d + Arquivos dentro de arquivos, não abertos: %1$d + Importando… + Importado + %1$d adicionadas, %2$d ignoradas como duplicadas, %3$d não importadas. + Seus arquivos originais não foram alterados. Apague-os você mesmo depois de conferir as notas aqui. + De qual app são? + Os arquivos parecem vir de mais de um app. Escolha o que importar; o resto é ignorado. + Nada foi importado + Este backup do Standard Notes está criptografado. Baixe um backup descriptografado no Standard Notes e escolha-o. + Este é um backup criptografado do Notesnook. No Notesnook exporte as notas como Markdown e escolha o .zip. + Estes arquivos não são uma exportação compatível. Veja os apps compatíveis ou escolha arquivos Markdown ou de texto. + Esta seleção é maior do que o Encly consegue importar de uma vez (64 MB de notas, 2 GB no total ou 20.000 notas). Importe em partes. + Nenhuma nota foi encontrada nesta seleção. + Não foi possível ler os arquivos escolhidos. + Este telefone não tem um app seletor de arquivos que o Encly possa usar. + O cofre foi fechado durante a importação. Desbloqueie o Encly e tente de novo. + Algo deu errado e nada foi importado. Seu cofre não mudou. + %1$d de %2$d selecionadas + Selecionar tudo + Limpar seleção + Todas + Só novas + Na lixeira + Sem título + Checklist + Na lixeira + Já no cofre + Importar %1$d + Escolher de novo + diff --git a/core/resources/src/commonMain/strings/values-pt/strings_security.xml b/core/resources/src/commonMain/strings/values-pt/strings_security.xml new file mode 100644 index 00000000..1854e936 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-pt/strings_security.xml @@ -0,0 +1,146 @@ + + + Digite ou cole suas 12 palavras + Palavra %1$d de %2$d + A palavra %1$d não é uma palavra de frase de recuperação. Confira a grafia. + Estas 12 palavras não formam uma frase de recuperação válida. Confira cada palavra e a ordem delas. + Este cofre já tem uma frase de recuperação + Adicionar às suas notas ou substituí-las? + Este backup contém %1$s, %2$s e %3$s. + Tudo o que está aqui fica e o que falta é adicionado. Itens que já existem são ignorados. + Substituir tudo + Apaga todas as notas, tarefas e etiquetas deste celular e depois restaura o backup. + Guarde todas as suas notas, tarefas e etiquetas em um arquivo criptografado e restaure-as em qualquer celular. + Exportar + Importar + Suas notas estão criptografadas. Seu PIN (e sua digital, se ativada) as desbloqueia. + Suas notas estão criptografadas. Seu PIN (e sua digital, se ativada) as desbloqueia, e sua frase de recuperação se você esquecer o PIN. + Criada durante a configuração. O Encly nunca guarda as palavras e não pode mostrá-las de novo. Não as tem? Substitua a frase abaixo. + Configurada + Depois de redefinir, você pode restaurar um arquivo de backup com as 12 palavras dele. + Não é possível abrir o seu cofre agora + O Encly não conseguiu ler os seus dados agora. Outra app ou o sistema pode estar a usá-los, ou o dispositivo ainda está bloqueado. As suas notas não se perderam. Tente novamente daqui a pouco. + Tentar novamente + Ainda não é possível ler. Tente novamente daqui a pouco. + Alterar PIN + + %d nota + %d de notas + %d notas + + + %d tarefa + %d de tarefas + %d tarefas + + + %d etiqueta + %d de etiquetas + %d etiquetas + + + Seu PIN não pode mais desbloquear o Encly neste telefone: a chave de hardware dele foi redefinida. Use sua frase de recuperação (ou impressão digital) e depois defina um novo PIN. + O dispositivo informa que ainda está bloqueado, por isso o Encly continuou fechado. Desbloqueie o dispositivo e tente de novo. + + Muitas tentativas. Tente novamente em %d minuto + Muitas tentativas. Tente novamente em %d minutos + Muitas tentativas. Tente novamente em %d minutos + + + Muitas tentativas. Tente novamente em %d hora + Muitas tentativas. Tente novamente em %d horas + Muitas tentativas. Tente novamente em %d horas + + Substituir a frase de recuperação + Receba 12 palavras novas. Os backups feitos antes continuam precisando das antigas. + Substituir a frase de recuperação? + Você vai receber 12 palavras novas. A frase antiga deixa de desbloquear suas notas neste telefone. Os backups que você já fez só abrem com a frase antiga, então guarde-a enquanto guardar esses backups, ou faça um novo backup depois. + Receber palavras novas + Sua frase antiga deixa de funcionar aqui + Depois de confirmar, só estas palavras desbloqueiam suas notas se você esquecer o PIN. Os backups feitos antes continuam precisando das palavras antigas. + Frase de recuperação substituída. Faça um novo backup para usá-la também nos backups. + Privacidade + Privacidade rigorosa do teclado + Pede ao teclado que não mostre sugestões, não use previsão na nuvem e não leia o texto ao redor do cursor. Desativa a correção automática, e alguns teclados ocultam emojis ou a entrada por voz. + Permitir capturas de tela até o app bloquear + Capturas e gravação de tela continuam bloqueadas, a menos que você as permita aqui. Isso se desativa sozinho quando o Encly bloqueia. + Permitir capturas de tela? + As capturas de tela podem ir parar na sua galeria e em backups na nuvem, com as suas notas. A tela de bloqueio, a entrada do PIN e a frase de recuperação continuam protegidas. A permissão termina quando o Encly bloqueia. + Bloquear ao sair + Quanto tempo o Encly fica aberto quando você muda para outro app. Desligar a tela ou bloquear o telefone sempre o bloqueia na hora. + Imediatamente + Após 15 segundos + Após 30 segundos + Após 1 minuto + Após 2 minutos + Após 5 minutos + Após 10 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. + Um PIN ligado a este telefone + O seu PIN é combinado com uma chave do chip seguro do telefone, por isso não pode ser adivinhado em nenhum outro lugar. + Uma fechadura para cada acesso + PIN, frase de recuperação e impressão digital abrem cada um o seu próprio espaço criptografado. A digital é pedida sempre. + Zona de perigo + Segure 5 segundos para apagar + + PIN de apagamento + Um segundo PIN que apaga suas notas quando digitado na tela de bloqueio + Desativado quando a chave do seu PIN foi redefinida. Defina-o de novo se você usava um. + Se alguém obrigar você a abrir o Encly, digite o PIN de apagamento em vez do seu PIN. O Encly apaga suas notas, tarefas e etiquetas sem deixar sinal e abre como um cofre vazio. Só funciona na tela de bloqueio. + Depois do apagamento, o Encly abre com o PIN de apagamento: ele passa a ser o PIN do novo cofre vazio. Seu PIN antigo deixa de funcionar, então quem viu você digitar o PIN de apagamento pode abrir o Encly com ele de novo sem notar nada estranho. + Os arquivos de backup que você já exportou não são tocados. Eles continuam abrindo com sua frase de recuperação e são o único caminho de volta. + O desbloqueio biométrico continua ativado: alguém pode forçar seu dedo no sensor. Desative-o se isso for um risco para você. + Desativar desbloqueio biométrico + Isso protege você quando alguém o obriga a desbloquear, não contra uma perícia forense do telefone. Durante um bloqueio por tentativas erradas, o PIN de apagamento também não funciona. O Encly nunca mostra se há um PIN de apagamento: um novo substitui o anterior, e se você mudar seu PIN para o PIN de apagamento, ele é desativado. + Definir PIN de apagamento + Desativar PIN de apagamento + Confirme seu PIN para configurar o PIN de apagamento + Crie um PIN de apagamento + De 6 a 12 dígitos, diferentes do seu PIN + Confirme o PIN de apagamento + Digite o PIN de apagamento de novo + O PIN de apagamento precisa ser diferente do seu PIN + Não foi possível salvar o PIN de apagamento. Tente novamente. + 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 + De 6 a 12 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 + Mostrar pré-visualização das notas + A lista de notas mostra as primeiras linhas de cada nota. Desative para mostrar só o título, a etiqueta e a data; a pesquisa continua a encontrar o texto completo. + Dígitos inseridos: %1$d + Desbloquear + Frase de recuperação aceita + Seu PIN ainda funciona. Mantenha-o ou defina um novo. + Manter meu PIN + Definir um novo PIN + diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml new file mode 100644 index 00000000..5677b3e7 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -0,0 +1,736 @@ + + + Про застосунок + Локальний зашифрований простір для нотаток, ідей та завдань без хмарної синхронізації. + Захист даних + Дані сховища зберігаються локально в зашифрованій базі даних SQLCipher + Часті запитання + Зворотний зв\'язок + Повідомити про помилку або запропонувати покращення на GitHub + Навіщо це і як підтримати на Ko-fi + Не знайдено браузера. Відкрийте посилання вручну: %1$s + Поштовий клієнт не знайдено + Політика конфіденційності + Як ми зберігаємо та захищаємо ваші дані + Оцінити застосунок + Поставте оцінку в Google Play Store + Підтримка та зв\'язок + Написати розробнику + Якщо у вас є питання або пропозиції, ви можете написати мені на пошту. + + Продовжити + + Додати + + Всі + + Від 6 до 12 цифр. Натисніть, щоб змінити PIN. + PIN + Авторизація + + Назад + + Зміну біометричного захисту не підтверджено + Не вдалося створити біометричний ключ + Підтвердіть свою особу + Автентифікація + Підтвердіть біометрію + Доступ до ключа + Підтвердіть біометрію для зміни захисту + Підтвердження безпеки + Підтвердіть біометрію для доступу до зашифрованого ключа + Розблокування Encly + Використати PIN + Біометрична автентифікація недоступна + + Більше інструментів блоку + Маркований список + Список завдань + Заголовок + Заголовок %1$d + Посилання + Список + Заблокувати чекліст + Розблокувати чекліст + Виділити блок + Перемістити пункт + Перемістити пункт вгору + Перемістити пункт вниз + Збільшити відступ пункту + Зменшити відступ пункту + Виділено блоків: %1$d + Зняти виділення + Копіювати + Вирізати + Видалити виділені блоки + Виділити все + Скопійовано + Перемістити блок вниз + Перемістити блок вгору + Нумерований список + Роздільник + + Скасувати + + + Ви дійсно хочете видалити ці нотатки назавжди? Цю дію не можна буде скасувати. + Ви дійсно хочете назавжди очистити кошик? Цю дію не можна буде скасувати. + Очистити кошик? + + Очистити + + Закрити + + Згорнути + + Створити нову мітку + + Сьогодні %1$s + Вчора %1$s + + Видалити + Видалити блок + Видалити + Видалити мітку + Цю мітку буде видалено з усіх ваших нотаток. Самі нотатки не видалятимуться + + Видалити + Скасувати + Ви дійсно хочете видалити нотатку назавжди? Цю дію не можна буде скасувати. + Видалити нотатку? + + Encly безкоштовний і з відкритим кодом: без реклами, стеження й облікових записів. Пожертви — його єдиний дохід. + Підтримати розробку + + Готово + + Дублікат + + Динамічний колір + + Редагувати + + Заблокувати редагування + + Нотаток ще немає + Натисніть «Нова нотатка», щоб написати першу. + Видалені нотатки чекають тут, доки ви їх не відновите або не видалите назавжди. + + Введіть посилання + + Не вдалося відкрити базу даних + + Розгорнути + + Не вдалося завантажити вміст + + Encly зберігає зміни й після затримки, вибраної в Налаштування → Безпека (за замовчуванням 15 секунд), закриває зашифровану базу та очищає сесійний ключ. Вимкнення екрана чи блокування телефона робить це одразу. Після цього потрібно розблокувати знову. + Що відбувається після згортання застосунку? + У Налаштування → Безпека використайте перемикач біометрії. Щоб увімкнути або вимкнути розблокування біометрією, потрібно підтвердити його надійною біометрією (наприклад, відбитком пальця). + Як увімкнути або вимкнути біометрію? + Відкрийте Налаштування → Безпека, підтвердьте поточний PIN, а потім двічі введіть новий. + Як змінити PIN? + Encly створює випадковий 256-бітний ключ бази даних. PIN захищає окрему зашифровану (AES-GCM) копію цього ключа, а біометрія використовує ключ Android Keystore, який щоразу потребує підтвердження біометрією. + Як захищений ключ бази даних? + На екрані блокування натисніть «Відновити доступ» і введіть 12 слів. Якщо ваш PIN ще працює, його можна залишити або задати новий PIN; якщо ні, новий PIN задається одразу. Без фрази відновлення іншого способу увійти немає: скинути його не може ніхто, навіть розробник. + Що буде, якщо я забуду PIN? + Відкрийте Налаштування → Мова та виберіть одну з доступних мов або «Як у системі», щоб використовувати мову пристрою. Зміна застосовується одразу. + Як змінити мову? + Натисніть «Нова нотатка» на головному екрані. Панель над клавіатурою додає заголовки, чеклісти, марковані й нумеровані списки, цитати, посилання та роздільники. + Як створити нову нотатку? + Це запасний ключ із 12 слів. Він відкриває сховище, якщо ви забули PIN, і це єдиний ключ до ваших резервних копій. Фразу створюють під час налаштування або пізніше в Налаштування → Безпека, якщо ви її пропустили. Зберігайте її на папері, офлайн. + Для чого потрібна фраза відновлення? + Відкрийте «Про застосунок» і скористайтеся зворотним зв\'язком або напишіть розробнику. Ніколи не додавайте до повідомлення про помилку фразу відновлення, PIN або приватний текст нотаток. + Як повідомити про помилку? + Автоматично, поки ви пишете, а вихід із редактора завершує останнє збереження. Якщо збереження колись не вдасться, редактор покаже червоне «Не збережено». + Як зберігаються зміни? + Нотатки, завдання, локальне шифрування та відновлення доступу без хмарної синхронізації. + Відповіді на найпоширеніші запитання + Типово ні. Захист екрана увімкнений: Encly блокує знімки екрана, запис екрана та незахищений попередній перегляд у списку нещодавніх застосунків. Якщо знімок потрібен, Налаштування → Безпека → «Дозволити знімки екрана до блокування» знімає захист після введення PIN; екрани блокування, PIN і фрази відновлення лишаються захищеними, а дозвіл вимикається сам, коли Encly блокується. + Чи можна робити скріншоти Encly? + Основні функції + Безпека та відновлення + Налаштування + Вирішення проблем + Нотатки, мітки та завдання зберігаються тільки в локальній SQLCipher-базі Encly. Хмарної синхронізації та автоматичного резервного копіювання немає: зашифровану копію можна експортувати самостійно в Налаштування → Резервна копія, а також читабельну копію у форматі Markdown — вона не зашифрована. + Де зберігаються мої дані? + Мітки групують нотатки. Їх можна створювати, перейменовувати, приховувати й видаляти, перетягувати в будь-якому порядку та фільтрувати за ними головний екран. + Як працюють мітки? + Завдання мають опис і пріоритет. Їх можна фільтрувати за пріоритетом і відмічати виконаними. Encly не надсилає текст завдань у системні сповіщення. + Що вміють завдання? + У Налаштування → Зовнішній вигляд виберіть одну з п’яти тем, світлий, темний чи системний режим, один із трьох стилів шрифту та розмір тексту. На Android 12 і новіших можна також брати акцентний колір зі шпалер. + Як змінити тему? + Encly навмисно ніколи не створює порожню базу даних поверх втраченого або нечитабельного сховища. Не підтверджуйте повне очищення, якщо вам ще потрібні локальні дані. + Encly повідомляє про пошкодження сховища + + Розмір тексту + Міжряддя + Звичайне + Компактне + Просторе + Редакційний + Сучасний + Технічний + + + Виберіть мову + Як у системі + + Додати посилання + + Елемент списку + + + Повернутися до PIN + Відновити доступ + Введіть фразу відновлення з 12 слів + Відновлення + Введіть PIN для доступу + Розблокуйте нотатки + Розблокування… + Відновити доступ за фразою відновлення + Неправильний PIN-код + Неправильна фраза відновлення + + Утримуйте, щоб очистити все + Схоже, що зашифровані дані або ключі шифрування були змінені або пошкоджені, тож відновлення неможливе.\n\nЩоб і далі користуватися застосунком, потрібно скинути його та стерти пошкоджені дані. Усі збережені нотатки й налаштування буде видалено назавжди. + На жаль, дані пошкоджені + Ці дані зі старішої версії + Encly знайшла нотатки, збережені версією до 2.0. Encly 2.0 використовує новий формат шифрування і не може їх відкрити, а перетворити їх неможливо.\n\nЩоб користуватися застосунком, почніть з нуля. Старі нотатки й налаштування на цьому телефоні буде видалено назавжди. + Утримуйте, щоб почати з нуля + + Часті запитання + Налаштування + Підтримка розробника + Мітки + Завдання + Кошик + + Інші дії + + + Без мітки + + Нова нотатка + Не вдалося видалити нотатку + Опис… + Скасувати всі зміни + Не вдалося скасувати зміни + Не вдалося створити копію + Не вдалося прочитати вміст нотатки. Щоб не пошкодити дані, редагування вимкнено. + + %1$s (копія) + Не вдалося відновити нотатку + Не вдалося зберегти зміни + Не вдалося перемістити нотатку в кошик + + Не вдалося запустити застосунок + Не вдалося створити фразу відновлення + Не вдалося створити захищене сховище + + Відкрити меню + + %1$d%% + + Повторно введіть PIN-код для підтвердження + Підтвердьте PIN-код + Підтвердіть поточний PIN перед зміною ключа доступу + Введіть поточний PIN + Від 6 до 12 цифр для захисту нотаток + PIN змінено + Створіть PIN-код + Поточний PIN неправильний + Стерти + PIN-коди не збігаються. Спробуйте ще раз. + Не вдалося зберегти PIN + Не вдалося оновити PIN. Спробуйте ще раз. + + + Натисніть, щоб почати редагування… + + Високий + Низький + Середній + Виберіть пріоритет + + Цитата + Повторити дію + + Відновити + + Зберегти + + + + + Очистити пошук + + Відбиток пальця + Розблокування біометрією + Недоступно на цьому пристрої + Відбиток пальця не додано. Додайте його в налаштуваннях системи. + Шифрування активне + Не налаштовано. Без неї забутий PIN означає втрату всього. Торкніться, щоб створити. + Стерти всі дані + Видалити всі нотатки, завдання, мітки та ключі шифрування на цьому пристрої + Усі нотатки, завдання, мітки та ключі шифрування буде видалено з цього пристрою, і Encly почне з початку. Цю дію неможливо скасувати: спершу експортуйте резервну копію, якщо хочете щось зберегти. + Шифрування та захист даних + Безпека + + + Зовнішній вигляд + Загальні + Мова + Конфіденційність + Показувати завдання + Відображати блок завдань на головній сторінці + Просте редагування + Відключити динамічні блоки та використовувати просте редагування нотаток + + Сортувати: + За датою створення (зростання) + Ств ↑ + За датою створення (спадання) + Ств ↓ + За назвою (А–Я) + Назва А–Я + За назвою (Я–А) + Назва Я–А + За датою редагування (зростання) + Ред ↑ + За датою редагування (спадання) + Ред ↓ + Підтримка розробника + + Не вдалося зберегти мітку. Спробуйте ще раз. + Не вдалося видалити мітку. Спробуйте ще раз. + Редагувати мітку + Прихована мітка + Мітка з такою назвою вже існує + Не вдалося зберегти порядок міток. Спробуйте ще раз. + Не вдалося оновити мітку. Спробуйте ще раз. + + Натисніть ще раз для видалення + + Нове завдання + Не вдалося очистити виконані завдання. Спробуйте ще раз. + Ви дійсно хочете видалити всі виконані завдання? Цю дію неможливо буде скасувати. + Очистити виконані завдання + Виконано: %1$s + Не вдалося зберегти завдання. Спробуйте ще раз. + Опис + Редагувати завдання + Немає активних завдань. + Немає завдань за вибраними фільтрами. + Всі завдання + Виконані + Нове завдання + Прогрес виконання + Не вдалося змінити стан завдання. Спробуйте ще раз. + Не вдалося оновити завдання. Спробуйте ще раз. + + Текст + + Тема + Темна + Світла + Як у системі + + + Скасувати дію + + Без назви + + Перемкнути на сітку + Перемкнути на список + + + Видалити %d нотатку? + Видалити %d нотатки? + Видалити %d нотаток? + Видалити %d нотатки? + + + Забагато спроб. Спробуйте через %d секунду + Забагато спроб. Спробуйте через %d секунди + Забагато спроб. Спробуйте через %d секунд + Забагато спроб. Спробуйте через %d секунди + + + Виконано %1$d з %2$d завдання + Виконано %1$d з %2$d завдань + Виконано %1$d з %2$d завдань + Виконано %1$d з %2$d завдання + + Резервна копія + Зашифрована копія, імпорт і Markdown + Резервна копія + Зашифровано вашою фразою відновлення + Резервна копія містить усі ваші нотатки, завдання та мітки, зашифровані вашою фразою відновлення з 12 слів. Encly нікуди її не вивантажує: ви самі обираєте, де зберегти файл. На новому телефоні ті самі 12 слів її відновлять. + Останній експорт: %1$s + Резервну копію ще не експортовано + Експортувати копію + Імпортувати копію + Відновити нотатки, завдання та мітки з файлу копії + Резервну копію збережено + Підтвердьте, що це ви + Введіть PIN, щоб продовжити + Фраза відновлення + Резервні копії шифруються фразою відновлення, тож відкрити їх можуть лише її 12 слів. У цьому сховищі її ще немає. Створіть її зараз: вона також дозволить розблокувати сховище, якщо ви забудете PIN. + Створити фразу відновлення + Запишіть ці 12 слів на папері, по порядку, і зберігайте офлайн. Будь-хто, хто їх має, може відкрити ваші копії. + Я їх записав(-ла) + Слова не збігаються. Перевірте, що ви записали. + Фразу відновлення збережено + Підтвердьте фразу відновлення + Один раз введіть фразу відновлення цього сховища з 12 слів, щоб Encly могла шифрувати нею копії. + Введіть фразу відновлення + Введіть 12 слів фрази відновлення, з якою створено цю копію. + Розшифрувати + Імпорт копії + Об\'єднати + Замінити + Замінити всі дані? + Усі нотатки, завдання та мітки в цьому сховищі буде видалено й замінено копією. Цю дію неможливо скасувати. + Імпортовано нотатки: %1$d, завдання: %2$d, мітки: %3$d. Пропущено (вже є): %4$d. + Цей файл не є резервною копією Encly + Цю копію створила новіша версія Encly. Оновіть застосунок і спробуйте ще раз. + Файл копії пошкоджений або неповний + Файл завеликий для резервної копії Encly + Сховище завелике для одного файлу копії (64 МБ). Нічого не збережено. + Такий файл уже існує. Виберіть нову назву: старіша копія ніколи не перезаписується. + На цьому пристрої немає застосунку для відкриття чи збереження файлів. + Ці слова не відкривають цю копію, або файл було змінено + Введіть правильну фразу відновлення з 12 слів + Сховище заблоковано. Розблокуйте його і спробуйте ще раз. + Не вдалося прочитати або записати копію. Нічого не змінено. + Ці слова не є фразою відновлення цього сховища + Не вдалося зберегти фразу відновлення + Відновити з копії + Обрати файл копії + Не вдалося відновити копію, тому налаштування ще не завершено. Спробуйте ще раз або продовжте з порожнім сховищем та імпортуйте файл пізніше в Налаштування → Резервна копія. + Копію не відновлено + Спробувати ще раз + Продовжити без неї + Цю нотатку не вдається прочитати в цій версії Encly. + Відкрити посилання + Копіювати посилання + Змінити посилання + Посилання скопійовано + На цьому пристрої немає застосунку, який може відкрити це посилання. + Зберігати й відкривати можна лише вебпосилання (http, https) та адреси пошти. + Не вдалося прочитати нотатки + Не вдалося прочитати мітки + Нотатки й далі зберігаються на пристрої в зашифрованому вигляді. Закрийте Encly і відкрийте знову, щоб повторити спробу. + Ви розблокували Encly фразою відновлення. Щоб продовжити, задайте новий PIN-код. + Видалити завдання + Завдання видалено + Не вдалося видалити завдання. Спробуйте ще раз. + Режим + Шрифт + Папір + Ліс + Океан + Графіт + Опівніч + Як у системі. Зараз світла тема. + Як у системі. Зараз темна тема. + Завжди світла. + Завжди темна. + Опівніч завжди темна. + Акцентний колір береться зі шпалер. Android 12 і новіші. + Динамічний колір увімкнено: акцент береться зі шпалер. + Playfair Display і Source Sans 3 + Poppins і Source Sans 3 + IBM Plex Sans усюди + Огляд квартири: чекліст + Перед переглядом: перевірити тиск води, спитати, скільки коштувало опалення минулої зими. + Особисте + Попередній перегляд + Тема, режим і шрифт + Правильно + Розблокувати відбитком пальця + Крок %1$d з %2$d + Записник, який можете відкрити лише ви. + Нотатки й завдання, зашифровані на цьому телефоні. Налаштування займе близько хвилини. + Без облікового запису + Без реєстрації, без пошти, без скидання пароля. + Працює офлайн + Encly ніколи не підключається до інтернету. + Зашифровано на пристрої + Ваше сховище замкнене ключем, який є лише у вас. + Почати + У мене є резервна копія + Оберіть PIN + Від 6 до 12 цифр. Він відмикає Encly і захищає ключ до вашого сховища. Скинути його через пошту неможливо. + Введіть PIN ще раз + Щоб переконатися, що це саме той PIN. + PIN і далі працюватиме як запасний спосіб + Дванадцять слів повертають усе + Encly дасть вам фразу відновлення. Запишіть її на папері: це єдиний запасний ключ. + Забули PIN? + Фраза відмикає сховище й дозволяє задати новий PIN. + Відкриває резервні копії + Зашифровані файли копій можна прочитати лише з цими словами. + Новий телефон + Файл копії разом із фразою відновлює всі нотатки, завдання й мітки. + Ніхто не відновить її за вас, навіть ми. Якщо втратити і PIN, і фразу, нотатки буде втрачено. + Створити мою фразу + Пропустити: лише PIN, без резервних копій + Запишіть ці слова + На папері, у цьому порядку. Не на скриншоті й не в хмарі. + Сховати слова + Показати слова + Слова записано + Перевірте три слова + Вводьте їх із паперу, а не з пам’яті. + Слово №%1$d + Введіть слово + Це не слово №%1$d + Підтвердити + Показати слова ще раз + Ваше сховище готове + Усе, що ви напишете відтепер, шифрується перед збереженням. + PIN задано + Запитується щоразу, коли відкривається Encly + Розблокування відбитком увімкнено + Спробувати відбиток ще раз + Змінити можна в Налаштуваннях, розділ «Безпека» + Фразу відновлення перевірено + Зберігайте папірець у надійному місці + Без фрази відновлення + Резервні копії вимкнено. Створити фразу можна пізніше в Налаштуваннях, розділ «Безпека». + Резервну копію відновлено + Ваші нотатки, завдання й мітки повернулися + Порада: час від часу експортуйте зашифровану копію в Налаштуваннях, розділ «Резервна копія». Відкриває її саме ваша фраза. + Відкрити мій записник + Відновлення, потім PIN + Оберіть файл резервної копії Encly, потім введіть 12 слів, з якими її створено. + 1 · Файл копії + Торкніться, щоб обрати інший файл + 2 · Фраза відновлення + Ці слова також стануть фразою відновлення на цьому телефоні. + Нотатки + Заблокувати зараз + Пошук у заголовках і тексті + Параметри перегляду + Нічого не знайдено за запитом «%1$s» + Сьогодні + Учора + + Завдання · %d відкрите + Завдання · %d відкриті + Завдання · %d відкритих + Завдання · %d відкритих + + Завдання · немає відкритих + Завдань ще немає + Натисніть «Нове завдання», щоб додати перше. + Усе виконано + Виконано · %1$d + Немає нотаток з тегом «%1$s» + Тут з’являться нотатки з цим тегом. + Показати всі нотатки + Кошик порожній + Натисніть нотатку, щоб прочитати. Утримуйте, щоб вибрати. + Видалити назавжди + Очистити кошик + Пошук нотаток + Нотатки й завдання + Версія, політика конфіденційності й контакти + Відповіді на часті запитання + Ліцензії відкритого коду + Бібліотеки та шрифти, які використовує Encly + Побудовано на відкритому коді + Encly створено з цими бібліотеками та шрифтами. Натисніть, щоб прочитати ліцензію. + Бібліотеки + Шрифти + Тегів ще немає + Теги групують нотатки. Створіть тег вище, а потім виберіть його для нотатки в меню довгого натискання. + Натисніть тег, щоб перейменувати, приховати чи видалити. Тягніть за ручку, щоб змінити порядок. + Додати тег + Назва + Показувати в списку нотаток + Прихований тег зберігає нотатки, але не має фільтра + Перетягніть, щоб змінити порядок + Перемістити вгору + Перемістити вниз + + Вибрано %d + Вибрано %d + Вибрано %d + Вибрано %d + + Редагувати мітки + Заголовок + Не збережено + Керувати мітками + Скасувати зміни? + Усе, що ви змінили відтоді, як відкрили цю нотатку, буде втрачено. Цю дію не можна буде скасувати. + Усе вже ваше + Кожна функція доступна всім. Нічого не заблоковано за оплатою. + Без реклами й стеження + Encly не бачить ваших нотаток і не заробляє на даних. Єдине, що його утримує, — пожертви. + На що йдуть кошти + Тестові пристрої (найближча ціль — Mac mini для збирання й тестування) та час на релізи й виправлення. + Картка або PayPal, разово чи щомісяця + Відкриває ko-fi.com у браузері. Сам Encly ніколи не обробляє платежі. + Що робить довге натискання на нотатку? + Відкриває дії з нотаткою: змінити мітку, додати короткий опис на картці, дублювати нотатку або перемістити її в кошик. + Як повернути видалену нотатку? + Видалені нотатки потрапляють у Кошик у меню. Там нотатку можна відновити або видалити назавжди. Encly ніколи не очищає кошик сам. + Що робить «Заблокувати редагування»? + Робить нотатку лише для читання, щоб її не змінити випадково. Текст однаково можна виділити й скопіювати. Вмикається й вимикається в меню редактора. + Як працюють резервні копії? + У Налаштування → Резервна копія ви експортуєте один зашифрований файл, який відкриває лише ваша фраза відновлення. Щоб відновити, виберіть файл, введіть або вставте 12 слів, а тоді об’єднайте копію з цим сховищем або замініть усе. + Показати клавіатуру + Сховати клавіатуру + Стерти всі дані? + Не вдалося стерти дані. Нічого не змінено. + Стерти + Підзавдання + Підзавдання + Додати підзавдання + Видалити підзавдання + Усі підзавдання виконано + Завершити завдання + Показати підзавдання + Сховати підзавдання + Розгорнуто + Згорнуто + Підзавдання видалено + Наступний крок: %1$s + Перший крок + + %1$d з %2$d підзавдання виконано + %1$d з %2$d підзавдань виконано + %1$d з %2$d підзавдань виконано + %1$d з %2$d підзавдання виконано + + Вставити блок + Знайти в нотатці + Знайти в цій нотатці + %1$d з %2$d + Збігів немає + Наступний збіг + Попередній збіг + Закрити пошук + Дублювати блок + Почати із шаблону + Порожня + Зустріч + Щоденник + Нотатки проєкту + Учасники + Порядок денний + Нотатки + Дії до виконання + Сьогодні + Вдячність + Завтра + Мета + Завдання + Рішення + Відкриті питання + Зробити + Закріпити + Відкріпити + Архівувати + Розархівувати + Закріплені + Інші + Архів + Архів порожній + Архівовані нотатки зникають зі списку й пошуку, але залишаються в сховищі. Архівуйте нотатку з її меню. + Не вдалося перемістити нотатку в архів + Не вдалося повернути нотатку з архіву + Колір + Без кольору + Червоний + Помаранчевий + Жовтий + Зелений + Бірюзовий + Синій + Фіолетовий + Рожевий + Експортувати як Markdown + Копіювати як Markdown + Скопійовано як Markdown + Читабельний експорт + Зберегти всі нотатки й завдання як читабельні файли .md в архіві .zip. Файл не зашифрований. + Експорт без шифрування? + Експортований файл не зашифрований. Будь-хто, хто його отримає, зможе прочитати ваші нотатки. Місце, куди ви його збережете, зокрема хмарна тека, міститиме читабельну копію, яку Encly не може ні захистити, ні відкликати. Далі потрібно буде підтвердити PIN-кодом. + Експорт Markdown збережено + Чи можна експортувати нотатки у форматі Markdown? + Так. У меню нотатки виберіть «Експортувати як Markdown», щоб отримати один файл .md, або відкрийте Налаштування → Резервна копія → «Експортувати як Markdown», щоб отримати .zip з усіма нотатками (архівні лежать у теці archive, кошик не входить) і файлом із завданнями. Ці файли не зашифровані: Encly спершу попередить, попросить PIN-код і збереже їх лише туди, куди ви вкажете. + Дії свайпом + Свайп по нотатках + Проведіть по картці нотатки, щоб виконати дію + Свайп праворуч + Свайп ліворуч + Закріпити або відкріпити + Наступний колір + Перемістити в кошик + Нічого + Перемістити в кошик? + Нотатку можна відновити з кошика. + Що роблять свайпи по нотатці? + За замовчуванням свайп нотатки праворуч закріплює або відкріплює її, а ліворуч — архівує. У Налаштування → Дії свайпом можна вибрати для кожного напрямку: закріпити, архівувати, наступний колір, перемістити в кошик (завжди з підтвердженням) або нічого, а також вимкнути свайпи. TalkBack пропонує ті самі дії в меню дій нотатки. + Текст у нотатках + Типове значення для всіх нотаток. Нотатка може задати своє у власному меню. + Використати типові налаштування тексту + Вихідний код + Encly має відкритий код: читайте його на GitHub + Відкрити + Відкрити в новій вкладці + Оберіть нотатку або створіть нову + Файл + Вигляд + Вікно + Довідка + Вийти + Згорнути + Нове вікно + Відкрити в новому вікні + Експортувати як PDF + Друкувати + Експортувати в PDF без шифрування? + PDF не зашифрований. Будь-хто, хто його отримає, зможе прочитати вашу нотатку. Місце, куди ви його збережете, зокрема хмарна тека, міститиме читабельну копію, яку Encly не може ні захистити, ні відкликати. Далі потрібно буде підтвердити PIN-кодом. + Друкувати без шифрування? + Під час друку текст нотатки надсилається на принтер без шифрування. Системна черга друку може зберігати його тимчасові копії, а мережевий або хмарний принтер також може його зберегти. Encly не може їх видалити. Далі потрібно буде підтвердити PIN-кодом. + PDF збережено + Надіслано на принтер + Ця нотатка відкрита для редагування в іншому вікні. Тут її можна лише читати. + Що нового + Зміни в цій версії + Зрозуміло + Нотатки остаточно видаляються через 28 днів у кошику. + Якої довжини може бути PIN? + Від 6 до 12 цифр на ваш вибір. Коротших PIN не пропонується: блокування й ключ пристрою обмежують кількість спроб, а 6 цифр — найкоротший PIN, що лишається безпечним за цих обмежень. Екран блокування ніколи не показує довжину вашого PIN: коли закінчите, натисніть «Розблокувати» — так само для PIN для стирання та PIN під примусом. PIN, задані раніше, працюють без змін. + Код + Таблиця + Додати рядок + Додати стовпець + Видалити рядок + Видалити стовпець + Рядок заголовка + Параметри клітинки + Панель редактора + Оберіть інструменти та їхній порядок + Перетягуйте за ручку, щоб змінити порядок. Око ховає інструмент або повертає його. Інструменти з розділу «Додаткове меню» ховаються за стрілкою. + Скинути + Панель + Додаткове меню + Приховані + Приховати інструмент + Показати інструмент + Вгору + Вниз + + ще %1$d мітка + ще %1$d мітки + ще %1$d міток + ще %1$d мітки + + diff --git a/app/src/main/res/values-uk/strings_handoff.xml b/core/resources/src/commonMain/strings/values-uk/strings_handoff.xml similarity index 100% rename from app/src/main/res/values-uk/strings_handoff.xml rename to core/resources/src/commonMain/strings/values-uk/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values-uk/strings_import.xml b/core/resources/src/commonMain/strings/values-uk/strings_import.xml new file mode 100644 index 00000000..e4f8489c --- /dev/null +++ b/core/resources/src/commonMain/strings/values-uk/strings_import.xml @@ -0,0 +1,79 @@ + + + + Імпорт з інших застосунків + Імпорт нотаток + З 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, пропущено як дублікати: %2$d, не імпортовано: %3$d. + Ваші початкові файли не змінено. Видаліть їх самі, коли перевірите нотатки тут. + З якого це застосунку? + Файли схожі на експорти з кількох застосунків. Оберіть той, який імпортувати; решту буде пропущено. + Нічого не імпортовано + Ця резервна копія Standard Notes зашифрована. Завантажте в Standard Notes розшифровану копію й оберіть її. + Це зашифрована резервна копія Notesnook. Експортуйте нотатки в Notesnook як Markdown і оберіть .zip. + Ці файли не є підтримуваним експортом. Перегляньте підтримувані застосунки або оберіть файли Markdown чи текстові. + Цей вибір більший, ніж Encly може імпортувати за раз (64 МБ нотаток, 2 ГБ загалом або 20 000 нотаток). Імпортуйте його частинами. + У цьому виборі не знайдено нотаток. + Не вдалося прочитати вибрані файли. + На цьому телефоні немає застосунку для вибору файлів, який може використати Encly. + Сховище закрилося під час імпорту. Розблокуйте Encly і спробуйте ще раз. + Щось пішло не так, нічого не імпортовано. Ваше сховище не змінилося. + Вибрано %1$d з %2$d + Вибрати всі + Зняти вибір + Усі + Лише нові + У кошику + Без назви + Чекліст + У кошику + Уже в сховищі + Імпортувати: %1$d + Обрати ще раз + diff --git a/core/resources/src/commonMain/strings/values-uk/strings_security.xml b/core/resources/src/commonMain/strings/values-uk/strings_security.xml new file mode 100644 index 00000000..7ea63052 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-uk/strings_security.xml @@ -0,0 +1,151 @@ + + + Введіть або вставте свої 12 слів + Слово %1$d з %2$d + Слово %1$d не зі списку слів фрази відновлення. Перевірте написання. + Ці 12 слів не утворюють правильної фрази відновлення. Перевірте кожне слово та їхній порядок. + У цього сховища вже є фраза відновлення + Додати до нотаток чи замінити їх? + У цій копії: %1$s, %2$s і %3$s. + Усе, що тут є, залишиться, а відсутнє додасться. Те, що вже є, буде пропущено. + Замінити все + Видалити всі нотатки, завдання й теги на цьому телефоні, а потім відновити копію. + Збережіть усі нотатки, завдання й теги в одному зашифрованому файлі та відновіть їх на будь-якому телефоні. + Експорт + Імпорт + Ваші нотатки зашифровано. Їх відкриває ваш PIN (і відбиток пальця, якщо ввімкнено). + Ваші нотатки зашифровано. Їх відкриває ваш PIN (і відбиток пальця, якщо ввімкнено), а якщо ви забудете PIN, то фраза відновлення. + Створена під час налаштування. Encly не зберігає слова й не може показати їх знову. Немає їх? Замініть фразу нижче. + Налаштовано + Після скидання можна відновити резервну копію з файлу за її 12 словами. + Зараз не вдається відкрити сховище + Encly не змогло прочитати свої дані. Їх може використовувати інший застосунок чи система, або пристрій ще заблокований. Ваші нотатки не втрачено. Спробуйте ще раз за мить. + Спробувати ще раз + Досі не вдається прочитати. Спробуйте ще раз за мить. + Змінити PIN + + %d нотатка + %d нотатки + %d нотаток + %d нотатки + + + %d завдання + %d завдання + %d завдань + %d завдання + + + %d тег + %d теги + %d тегів + %d тега + + + Ваш PIN більше не може розблокувати Encly на цьому телефоні: його апаратний ключ скинуто. Скористайтеся фразою відновлення (або відбитком пальця), а потім задайте новий PIN. + Пристрій повідомляє, що він досі заблокований, тому Encly залишився закритим. Розблокуйте пристрій і спробуйте ще раз. + + Забагато спроб. Спробуйте через %d хвилину + Забагато спроб. Спробуйте через %d хвилини + Забагато спроб. Спробуйте через %d хвилин + Забагато спроб. Спробуйте через %d хвилини + + + Забагато спроб. Спробуйте через %d годину + Забагато спроб. Спробуйте через %d години + Забагато спроб. Спробуйте через %d годин + Забагато спроб. Спробуйте через %d години + + Замінити фразу відновлення + Отримайте 12 нових слів. Для резервних копій, зроблених раніше, і далі потрібні старі. + Замінити фразу відновлення? + Ви отримаєте 12 нових слів. Стара фраза більше не розблоковуватиме ваші нотатки на цьому телефоні. Уже зроблені резервні копії відкриваються лише старою фразою, тож зберігайте її, доки зберігаєте ці копії, або згодом зробіть нову копію. + Отримати нові слова + Стара фраза тут більше не працює + Після підтвердження лише ці слова розблоковуватимуть ваші нотатки, якщо ви забудете PIN. Для резервних копій, зроблених раніше, і далі потрібні старі слова. + Фразу відновлення замінено. Зробіть нову резервну копію, щоб використовувати її і для копій. + Приватність + Сувора приватність клавіатури + Просить клавіатуру не показувати підказки, не використовувати хмарне передбачення й не читати текст біля курсора. Вимикає автовиправлення, а деякі клавіатури ховають емодзі чи голосовий ввід. + Дозволити знімки екрана до блокування + Знімки й запис екрана заблоковані, доки ви не дозволите їх тут. Вимикається саме, коли Encly блокується. + Дозволити знімки екрана? + Знімки екрана можуть потрапити в галерею та хмарні резервні копії разом із вашими нотатками. Екран блокування, введення PIN-коду та фраза відновлення залишаються захищеними. Дозвіл закінчується, коли Encly блокується. + Блокувати після виходу + Скільки Encly лишається відкритим, коли ви переходите в інший застосунок. Вимкнення екрана чи блокування телефона завжди блокує його одразу. + Одразу + Через 15 секунд + Через 30 секунд + Через 1 хвилину + Через 2 хвилини + Через 5 хвилин + Через 10 хвилин + Через 15 хвилин + Через 30 хвилин + Довший час залишає сховище відкритим у фоні. Людина з доступом до розблокованого телефона може побачити ваші нотатки. + Як Encly захищає нотатки + Випадковий 256-бітний ключ + Уся база зашифрована ключем, створеним на цьому телефоні. + PIN прив’язаний до телефона + PIN змішується з ключем у захищеному чипі телефона, тож його не підібрати на іншому пристрої. + Окремий замок для кожного входу + PIN, фраза відновлення й відбиток відкривають кожен свій зашифрований слот. Відбиток потрібен щоразу. + Небезпечна зона + Утримуйте 5 секунд, щоб стерти + + PIN для стирання + Другий PIN, що стирає нотатки, якщо ввести його на екрані блокування + Вимкнено після скидання ключа PIN. Задайте його знову, якщо користувалися ним. + Якщо вас змушують відкрити Encly, введіть PIN для стирання замість свого PIN. Encly непомітно зітре нотатки, завдання й мітки та відкриється як порожнє сховище. Працює лише на екрані блокування. + Після стирання Encly відкривається PIN для стирання: він стає PIN нового, порожнього сховища. Ваш старий PIN більше не працює. Тож той, хто бачив, як ви вводили PIN для стирання, зможе знову відкрити ним Encly й не помітить нічого дивного. + Уже експортовані файли резервних копій не зачіпаються. Вони й далі відкриваються фразою відновлення і є єдиним шляхом назад. + Розблокування біометрією лишається ввімкненим: вас можуть змусити прикласти палець. Вимкніть його, якщо це для вас ризик. + Вимкнути розблокування біометрією + Це захищає, коли вас змушують розблокувати телефон, але не від криміналістичної експертизи. Під час блокування після невдалих спроб PIN для стирання теж не діє. Encly ніколи не показує, чи його задано: новий замінює старий, а якщо змінити свій PIN на PIN для стирання, його буде вимкнено. + Задати PIN для стирання + Вимкнути PIN для стирання + Підтвердьте свій PIN, щоб налаштувати PIN для стирання + Створіть PIN для стирання + Від 6 до 12 цифр, відмінних від вашого PIN + Підтвердьте PIN для стирання + Введіть PIN для стирання ще раз + PIN для стирання має відрізнятися від вашого PIN + Не вдалося зберегти PIN для стирання. Спробуйте ще раз. + PIN для стирання задано + PIN для стирання вимкнено + Ключ вашого PIN було скинуто, тож PIN для стирання, якщо він був, вимкнено. Задайте його знову, якщо він вам потрібен. + Цей PIN уже використовується. Оберіть інший. + PIN під примусом + Другий PIN, який відкриває окреме сховище з власними нотатками + Вимкнено, бо ключ вашого PIN було скинуто. Задайте його знову, якщо він вам потрібен. + Якщо вас змушують відкрити Encly, введіть PIN під примусом замість свого PIN. Encly відкриє окреме порожнє сховище, яким можна користуватися як звичайно: нотатки, додані там, залишаються в ньому. + Ваше справжнє сховище лишається заблокованим і недоторканим. На відміну від PIN для стирання, PIN під примусом нічого не стирає. Відбиток пальця та фраза відновлення завжди відкривають справжнє сховище. + Він захищає, коли хтось вимагає відкрити застосунок і все показати, але не від криміналістичної експертизи телефона. Під час блокування після невдалих спроб PIN під примусом теж не діє. + PIN під примусом задано. + PIN під примусом не задано. + Задати PIN під примусом + Змінити PIN під примусом + Вимкнути PIN під примусом + Підтвердьте свій PIN, щоб налаштувати PIN під примусом + Створіть PIN під примусом + Від 6 до 12 цифр, відмінних від вашого PIN + Підтвердьте PIN під примусом + Введіть PIN під примусом ще раз + PIN під примусом має відрізнятися від вашого PIN + Не вдалося зберегти PIN під примусом. Спробуйте ще раз. + PIN під примусом задано. + PIN під примусом змінено. Підставне сховище зберігає свої нотатки. + PIN під примусом вимкнено + Ключ вашого PIN було скинуто, тож PIN під примусом перестав діяти. Задайте його знову; підставне сховище зберігає свої нотатки. + Вимкнути PIN під примусом? + Підставне сховище та нотатки в ньому буде видалено. Ваше справжнє сховище це не зачепить. + Вимкнути + Показувати попередній перегляд нотаток + Список нотаток показує перші рядки кожної нотатки. Вимкніть, щоб бачити лише заголовок, мітку й дату; пошук і далі знаходить увесь текст. + Введено цифр: %1$d + Розблокувати + Фразу відновлення прийнято + Ваш PIN усе ще працює. Залиште його або задайте новий. + Залишити мій PIN + Задати новий PIN + diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml new file mode 100644 index 00000000..f8a58da5 --- /dev/null +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -0,0 +1,740 @@ + + + + + Encly + %1$d sp + English + Українська + Deutsch + Français + Español + Italiano + Polski + Português + Nederlands + العربية + + About + A local encrypted space for notes, ideas and tasks, with no cloud sync. + Data protection + Vault data is stored locally in an encrypted SQLCipher database + Frequently asked questions + Feedback + Report a bug or suggest an improvement on GitHub + Why it matters and how to chip in on Ko-fi + No browser found. Open the link manually: %1$s + No email app found + Privacy policy + How we store and protect your data + Rate the app + Leave a rating on the Google Play Store + Support and contact + Contact the developer + If you have questions or suggestions, you can email me. + + Continue + + Add + + All + + 6 to 12 digits. Tap to change your PIN. + PIN + Authorization + + Back + + The biometric protection change was not confirmed + Couldn\'t create the biometric key + Confirm your identity + Authentication + Confirm your biometrics + Key access + Confirm your biometrics to change protection + Security confirmation + Confirm your biometrics to access the encrypted key + Unlock Encly + Use PIN + Biometric authentication is unavailable + + More block tools + Bulleted list + Checklist + Heading + Heading %1$d + Link + List + Lock checklist + Unlock checklist + Select block + Reorder item + Move item up + Move item down + Indent item + Outdent item + Blocks selected: %1$d + Clear selection + Copy + Cut + Delete selected blocks + Select all + Copied + Move block down + Move block up + Numbered list + Separator + + Cancel + + + Do you really want to delete these notes permanently? This can\'t be undone. + Do you really want to empty the trash permanently? This can\'t be undone. + Empty the trash? + + Clear + + Close + + Collapse + + Create a new tag + + Today %1$s + Yesterday %1$s + + Delete + Delete block + Delete + Delete tag + This tag will be removed from all your notes. The notes themselves won\'t be deleted. + + Delete + Cancel + Do you really want to delete this note permanently? This can\'t be undone. + Delete note? + + Encly is free and open source, with no ads, no tracking and no accounts. Donations are its only income. + Support development + + Done + + Duplicate + + Dynamic color + + Edit + + Lock editing + + No notes yet + Tap New note to write your first one. + Deleted notes wait here until you restore them or delete them for good. + + Enter a link + + Couldn\'t open the database + + Expand + + Couldn\'t load the content + + Encly saves your changes and, after the delay chosen in Settings → Security (15 seconds by default), closes the encrypted database and wipes the session key. Turning off the screen or locking the phone does this at once. After that you unlock again. + What happens when I leave the app? + Use the biometrics switch in Settings → Security. Creating or removing the biometric slot requires strong biometric authentication. + How do I turn biometrics on or off? + Open Settings → Security, confirm your current PIN, then enter the new one twice. + How do I change my PIN? + Encly creates a random 256-bit database key. Your PIN protects a separate AES-GCM unlock slot, and biometrics use an auth-per-use Android Keystore key. + How is the database key protected? + On the lock screen, tap \"Recover access\" and enter your 12 words. If your PIN still works, you can keep it or set a new PIN; if not, you set a new PIN right away. Without a recovery phrase there is no other way in: nobody, not even the developer, can reset it. + What if I forget my PIN? + Open Settings → Language and pick one of the available languages, or \"System default\" to follow your device. The change applies right away. + How do I change the language? + Tap \"New note\" on the home screen. The toolbar above the keyboard adds headings, checklists, bulleted and numbered lists, quotes, links and separators. + How do I create a new note? + It is a spare key of 12 words. It unlocks the vault if you forget your PIN, and it is the only key to your backup files. You create it during setup, or later in Settings → Security if you skipped it. Keep it on paper, offline. + What is the recovery phrase for? + Open \"About\" and use the feedback option, or write to the developer. Never include your recovery phrase, PIN or private note text in a bug report. + How do I report a bug? + Automatically, as you type, and leaving the editor finishes the last save. If a save ever fails, the editor shows \"Not saved\" in red. + How are changes saved? + Notes, tasks, local encryption and access recovery, with no cloud sync. + Answers to the most common questions + Not by default. Screen protection is on: Encly blocks screenshots, screen recording and unprotected previews in the recent apps list. When you need one, Settings → Security → Allow screenshots until the app locks lifts it after your PIN; the lock, PIN and recovery phrase screens stay protected, and it switches itself off when Encly locks. + Can I take screenshots of Encly? + Basics + Security and recovery + Settings + Troubleshooting + Notes, tags and tasks are stored only in Encly\'s local SQLCipher database. There is no cloud sync and no automatic backup: you can export an encrypted backup yourself in Settings → Backup, or a readable Markdown copy, which is not encrypted. + Where is my data stored? + Tags group your notes. You can create, rename, hide and delete them, drag them into any order, and filter the home screen by tag. + How do tags work? + Tasks have a description and a priority. You can filter them by priority and mark them as done. Encly never puts task text into system notifications. + What can tasks do? + In Settings → Appearance choose one of five themes, light, dark or system mode, one of three font styles and the text size. On Android 12 and later you can also take the accent color from your wallpaper. + How do I change the theme? + Encly deliberately never creates an empty database over a lost or unreadable vault. Don\'t confirm the full wipe if you still need your local data. + Encly says the vault is damaged + + Text size + Line spacing + Default + Compact + Comfortable + Editorial + Modern + Technical + + + Choose language + System default + + Add link + + List item + + + Back to PIN + Recover access + Enter your 12-word recovery phrase + Recovery + Enter your PIN to continue + Unlock your notes + Unlocking… + Recover access with your recovery phrase + Wrong PIN + Wrong recovery phrase + + Hold to wipe everything + It looks like the encrypted data or the encryption keys were changed or damaged, so recovery is not possible.\n\nTo keep using the app, you need to reset it and wipe the damaged data. This permanently deletes all saved notes and settings. + Sorry, your data is damaged + This data is from an older version + Encly found notes saved by a version before 2.0. Encly 2.0 uses a new encrypted format and cannot open them, and there is no way to convert them.\n\nTo use the app, start fresh. This permanently deletes the old notes and settings on this phone. + Hold to start fresh + + FAQ + Settings + Support the developer + Tags + Tasks + Trash + + More options + + + No tag + + New note + Couldn\'t delete the note + Description… + Discard all changes + Couldn\'t discard changes + Couldn\'t create a copy + Couldn\'t read the note\'s content. Editing is turned off so the data isn\'t damaged. + + %1$s (Copy) + Couldn\'t restore the note + Couldn\'t save changes + Couldn\'t move the note to the trash + + Couldn\'t initialize the app + Couldn\'t create the recovery phrase + Couldn\'t create the encrypted vault + + Open menu + + %1$d%% + + Enter the PIN again to confirm + Confirm your PIN + Confirm your current PIN before changing your access key + Enter your current PIN + 6 to 12 digits to protect your notes + PIN changed + Create a PIN + Current PIN is incorrect + Delete digit + PINs don\'t match. Please try again. + Couldn\'t save the PIN + Couldn\'t update the PIN. Please try again. + + + Tap to start editing… + + High + Low + Medium + Choose a priority + + Quote + Redo + + Restore + + Save + + + + + Clear search + + Fingerprint + Biometric unlock + Not available on this device + No fingerprint enrolled. Add one in system settings. + Encryption is on + Not set up. Without it, a forgotten PIN means losing everything. Tap to create one. + Erase all data + Delete every note, task and tag and the encryption keys on this device + Every note, task and tag and the encryption keys will be deleted from this device, and Encly will start over. This can\'t be undone: export a backup first if you want to keep anything. + Encryption and data protection + Security + + + Appearance + General + Language + Privacy + Show tasks + Show the tasks card on the home screen + Simple editing + Turn off dynamic blocks and edit notes as plain text + + Sort by: + By creation date (oldest first) + Created ↑ + By creation date (newest first) + Created ↓ + By title (A to Z) + Title A–Z + By title (Z to A) + Title Z–A + By last edited (oldest first) + Edited ↑ + By last edited (newest first) + Edited ↓ + Support the developer + + Couldn\'t save the tag. Please try again. + Couldn\'t delete the tag. Please try again. + Edit tag + Hidden tag + A tag with this name already exists + Couldn\'t save the tag order. Please try again. + Couldn\'t update the tag. Please try again. + + Tap again to delete + + New task + Couldn\'t clear completed tasks. Please try again. + Do you really want to delete all completed tasks? This can\'t be undone. + Clear completed tasks + Completed: %1$s + Couldn\'t save the task. Please try again. + Description + Edit task + No active tasks. + No tasks match the selected filters. + All tasks + Completed + New task + Progress + Couldn\'t change the task status. Please try again. + Couldn\'t update the task. Please try again. + + Text + + Theme + Dark + Light + System + + + Undo + + Untitled + + Switch to grid view + Switch to list view + + + Delete %d note? + Delete %d notes? + + + Too many attempts. Try again in %d second + Too many attempts. Try again in %d seconds + + + %1$d of %2$d task completed + %1$d of %2$d tasks completed + + Backup + Encrypted backup, import and Markdown + Backup + Encrypted with your recovery phrase + A backup holds all your notes, tasks and tags, encrypted with your 12-word recovery phrase. Encly never uploads it: you choose where the file is saved. On a new phone, the same 12 words restore it. + Last export: %1$s + No backup exported yet + Export backup + Import backup + Restore notes, tasks and tags from a backup file + Backup saved + Confirm it\'s you + Enter your PIN to continue + Recovery phrase + Backups are encrypted with a recovery phrase, so only its 12 words can open them. This vault has none yet. Create one now: it also lets you unlock this vault if you forget your PIN. + Create recovery phrase + Write these 12 words down on paper, in order, and keep them offline. Anyone who has them can open your backups. + I wrote them down + These words don\'t match. Check what you wrote down. + Recovery phrase saved + Confirm your recovery phrase + Enter this vault\'s 12-word recovery phrase once, so Encly can encrypt backups with it. + Enter recovery phrase + Enter the 12 words of the recovery phrase this backup was made with. + Decrypt + Import backup + Merge + Replace + Replace all data? + Every note, task and tag in this vault will be deleted and replaced with the backup. This can\'t be undone. + Imported: notes %1$d, tasks %2$d, tags %3$d. Skipped (already here): %4$d. + This file is not an Encly backup + This backup was made by a newer version of Encly. Update the app and try again. + The backup file is damaged or incomplete + The file is too large to be an Encly backup + Your vault is too large for one backup file (64 MB). Nothing was saved. + That file already exists. Choose a new name: an older backup is never overwritten. + No app on this device can open or save files. + These words don\'t open this backup, or the file was modified + Enter a valid 12-word recovery phrase + The vault was locked. Unlock it and try again. + Couldn\'t read or write the backup. Nothing was changed. + These words are not this vault\'s recovery phrase + Couldn\'t save the recovery phrase + Restore from a backup + Choose a backup file + The backup couldn\'t be restored, so setup isn\'t finished yet. Try again, or continue with an empty vault and import the file later in Settings → Backup. + Backup not restored + Try again + Continue without it + This note can\'t be read by this version of Encly. + Open link + Copy link + Edit link + Link copied + No app on this device can open this link. + Only web (http, https) and email links can be saved or opened. + Couldn\'t read your notes + Couldn\'t read your tags + Your notes are still stored encrypted on this device. Close Encly and open it again to retry. + You unlocked Encly with your recovery phrase. Set a new PIN to continue. + Delete task + Task deleted + Couldn\'t delete the task. Please try again. + Mode + Font + Paper + Forest + Ocean + Graphite + Midnight + Follows the system setting. Showing light. + Follows the system setting. Showing dark. + Always light. + Always dark. + Midnight is always dark. + Take the accent from your wallpaper. Android 12 and later. + Dynamic color is on: the accent comes from your wallpaper. + Playfair Display and Source Sans 3 + Poppins and Source Sans 3 + IBM Plex Sans throughout + Flat viewing checklist + Before the visit: check the water pressure, ask what heating cost last winter. + Personal + Preview + Theme, mode and font + Correct + Unlock with fingerprint + Step %1$d of %2$d + Aa + A notebook only you can open. + Notes and tasks, encrypted on this phone. Setup takes about a minute. + No account + No sign-up, no email, no password reset. + Works offline + Encly never connects to the internet. + Encrypted on the device + Your vault is locked with a key only you hold. + Get started + I have a backup + Choose a PIN + 6 to 12 digits. It unlocks Encly and protects the key to your vault. There is no email to reset it. + Enter the PIN again + So we know it\'s the one you meant. + The PIN still works as a fallback + Twelve words bring everything back + Encly will give you a recovery phrase. Write it on paper; it is the only spare key. + Forgot your PIN? + The phrase unlocks your vault and lets you set a new PIN. + Opens your backups + Encrypted backup files can only be read with these words. + New phone + Backup file plus phrase restores every note, task and tag. + Nobody can recover it for you, not even us. Lose both the PIN and the phrase and the notes are gone. + Create my phrase + Skip: PIN only, no backups + Write these words down + On paper, in this order. Not in a screenshot, not in the cloud. + Hide words + Show words + I wrote them down + Check three words + Type them from your paper copy, not from memory. + Word #%1$d + Enter the word + That is not word #%1$d + Confirm + Show the words again + Your vault is ready + Everything you write from now on is encrypted before it is saved. + PIN set + Asked every time Encly opens + Fingerprint unlock on + Try fingerprint again + Change it in Settings, Security + Recovery phrase checked + Keep the paper somewhere safe + No recovery phrase + Backups are off. You can create a phrase later in Settings, Security. + Backup restored + Your notes, tasks and tags are back + Tip: export an encrypted backup now and then from Settings, Backup. Your phrase is what opens it. + Open my notebook + Restore, then set a PIN + Choose your Encly backup file, then enter the 12 words it was made with. + 1 · Backup file + Tap to choose another file + 2 · Recovery phrase + These words also become this phone\'s recovery phrase. + Notes + Lock now + Search titles and text + View options + Nothing matches “%1$s” + Today + Yesterday + + Tasks · %d open + Tasks · %d open + + Tasks · none open + No tasks yet + Tap New task to add your first one. + All done + Done · %1$d + No notes tagged “%1$s” + Notes you give this tag show up here. + Show all notes + Trash is empty + Tap a note to read it. Long-press to select. + Delete forever + Empty trash + Search notes + Notes and tasks + Version, privacy policy and contact + Answers to common questions + Open-source licenses + The libraries and fonts Encly uses + Built on open source + Encly is made with these libraries and fonts. Tap one to read its license. + Libraries + Fonts + No tags yet + Tags group your notes. Create one above, then pick it for a note from its long-press menu. + Tap a tag to rename, hide or delete it. Drag the handle to reorder. + Add tag + Name + Show in the notes list + A hidden tag keeps its notes but gets no filter chip + Drag to reorder + Move up + Move down + + %d selected + %d selected + + Edit tags + Title + Not saved + Manage tags + Discard changes? + Everything you changed since you opened this note will be lost. This can\'t be undone. + Everything is already yours + Every feature is in the app for everyone. Nothing is locked behind a payment. + No ads, no tracking + Encly never sees your notes and earns nothing from data. Donations are the only thing that pays for it. + Where the money goes + Test devices (next up: a Mac mini for building and testing) and time for releases and fixes. + Card or PayPal, one-off or monthly + Opens ko-fi.com in your browser. Encly itself never handles payments. + What does a long press on a note do? + It opens the note\'s actions: change its tag, add a short description shown on the card, duplicate the note or move it to the trash. + How do I get a deleted note back? + Deleted notes go to Trash in the menu. There you can restore a note or delete it forever. Encly never empties the trash on its own. + What does \"Lock editing\" do? + It makes a note read-only so it can\'t be changed by accident. You can still select and copy the text. Turn it on or off from the editor\'s menu. + How do backups work? + In Settings → Backup you export one encrypted file that only your recovery phrase can open. To restore, pick the file, type or paste the 12 words, then merge it into this vault or replace everything. + Show keyboard + Hide keyboard + Erase all data? + Couldn\'t erase the data. Nothing was changed. + Erase + Sub-tasks + Sub-task + Add sub-task + Delete sub-task + All sub-tasks are done + Complete task + Show sub-tasks + Hide sub-tasks + Expanded + Collapsed + Sub-task deleted + Next step: %1$s + First step + + %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 + Pin + Unpin + Archive + Unarchive + Pinned + Others + Archive + Archive is empty + Archived notes leave your list and search but stay in the vault. Archive a note from its menu. + Couldn\'t move the note to the archive + Couldn\'t take the note out of the archive + Color + No color + Red + Orange + Yellow + Green + Teal + Blue + Purple + Pink + Export as Markdown + Copy as Markdown + Copied as Markdown + Readable export + Save all notes and tasks as readable .md files in a .zip. The file is not encrypted. + Export without encryption? + The exported file is not encrypted. Anyone who gets it can read your notes. Wherever you save it, including a cloud folder, will hold a readable copy that Encly can\'t protect or take back. You\'ll confirm with your PIN next. + Markdown export saved + Can I export my notes as Markdown? + Yes. In a note\'s menu choose \"Export as Markdown\" for one .md file, or use Settings → Backup → \"Export as Markdown\" for a .zip of all notes (archived ones in an archive folder, the trash left out) and a tasks file. These files are not encrypted: Encly warns you first, asks for your PIN and saves only where you choose. + Swipe actions + Swipe on notes + Swipe a note card to act on it + Swipe right + Swipe left + Pin or unpin + Next color + Move to trash + Nothing + Move to trash? + You can restore the note from the trash. + What do swipes on a note do? + By default, swiping a note right pins or unpins it and swiping left archives it. In Settings → Swipe actions you can choose pin, archive, next color, move to trash (always asks first) or nothing for each direction, or turn swipes off. TalkBack offers the same actions in the note\'s actions menu. + Text in notes + The default for every note. A note can set its own from its menu. + Use default text settings + Source code + Encly is open source: read the code on GitHub + Open + Open in new tab + Select a note, or start a new one + File + View + Window + Help + Exit + Minimize + New window + Open in new window + Export as PDF + Print + Export to PDF without encryption? + The PDF is not encrypted. Anyone who gets it can read your note. Wherever you save it, including a cloud folder, will hold a readable copy that Encly cannot protect or take back. You will confirm with your PIN next. + Print without encryption? + Printing sends the text of your note to the printer unencrypted. The system print spooler may keep temporary copies of it, and a network or cloud printer may store it too. Encly cannot remove them. You will confirm with your PIN next. + PDF saved + Sent to the printer + This note is open for editing in another window. Here it is read-only. + What\'s new + The changes in this version + Got it + Notes are deleted for good after 28 days in the trash. + How long can my PIN be? + Between 6 and 12 digits, your choice. Shorter PINs are not offered: the lockout and the device key limit how many guesses anyone gets, and 6 digits is the shortest that stays safe with those limits. The lock screen never shows how long your PIN is: you press Unlock when you are done, and the same goes for the wipe PIN and the duress PIN. PINs you set before this option keep working as they are. + Code + Table + Add row + Add column + Delete row + Delete column + Header row + Cell options + Editor toolbar + Choose and order the editing tools + Drag the handle to reorder. Tap the eye to hide a tool or bring it back. Tools under “More menu” sit behind the chevron. + Reset + Toolbar + More menu + Hidden + Hide tool + Show tool + Move up + Move down + + %1$d more tag + %1$d more tags + + diff --git a/app/src/main/res/values/strings_handoff.xml b/core/resources/src/commonMain/strings/values/strings_handoff.xml similarity index 100% rename from app/src/main/res/values/strings_handoff.xml rename to core/resources/src/commonMain/strings/values/strings_handoff.xml diff --git a/core/resources/src/commonMain/strings/values/strings_import.xml b/core/resources/src/commonMain/strings/values/strings_import.xml new file mode 100644 index 00000000..f75ad4e6 --- /dev/null +++ b/core/resources/src/commonMain/strings/values/strings_import.xml @@ -0,0 +1,81 @@ + + + + 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 + To-do notes imported as ordinary notes: %1$d + Encrypted or unsupported entries skipped: %1$d + Files that could not be used: %1$d + Archives inside archives, not opened: %1$d + Importing… + Imported + %1$d added, %2$d skipped as duplicates, %3$d not imported. + Your original files were not changed. Delete them yourself once you have checked your notes here. + Which app are these from? + The files look like they come from more than one app. Choose the one to import; the rest is skipped. + Nothing was imported + This Standard Notes backup is encrypted. Download a decrypted backup in Standard Notes and choose that instead. + This is an encrypted Notesnook backup. In Notesnook export your notes as Markdown instead and choose the .zip. + These files are not a supported export. Check the supported apps, or choose Markdown or text files. + This selection is larger than Encly can import at once (64 MB of notes, 2 GB in all, or 20,000 notes). Import it in smaller parts. + No notes were found in this selection. + The selected files could not be read. + This phone has no file picker app that Encly can use. + The vault closed during the import. Unlock Encly and try again. + Something went wrong and nothing was imported. Your vault is unchanged. + %1$d of %2$d selected + Select all + Select none + All + New only + In trash + Untitled + Checklist + In trash + Already in vault + Import %1$d + Choose again + diff --git a/core/resources/src/commonMain/strings/values/strings_security.xml b/core/resources/src/commonMain/strings/values/strings_security.xml new file mode 100644 index 00000000..d5715a3c --- /dev/null +++ b/core/resources/src/commonMain/strings/values/strings_security.xml @@ -0,0 +1,141 @@ + + + Type or paste your 12 words + Word %1$d of %2$d + Word %1$d is not a recovery phrase word. Check the spelling. + These 12 words don\'t form a valid recovery phrase. Check each word and their order. + This vault already has a recovery phrase + Add to your notes or replace them? + This backup holds %1$s, %2$s and %3$s. + Keep everything here and add what is missing. Items already here are skipped. + Replace everything + Delete every note, task and tag on this phone, then restore the backup. + Keep all your notes, tasks and tags in one encrypted file, and restore them on any phone. + Export + Import + Your notes are encrypted. Your PIN (and your fingerprint, if it is on) unlocks them. + Your notes are encrypted. Your PIN (and your fingerprint, if it is on) unlocks them, and your recovery phrase does if you forget the PIN. + Created during setup. Encly never stores the words and can\'t show them again. Don\'t have them? Replace the phrase below. + Set up + After the reset you can restore a backup file with its 12 words. + Can\'t open your vault right now + Encly could not read its data just now. Another app or the system may be using it, or the device may still be locked. Your notes are not lost. Try again in a moment. + Try again + Still can\'t read it. Try again in a moment. + Change PIN + + %d note + %d notes + + + %d task + %d tasks + + + %d tag + %d tags + + + Your PIN can no longer unlock Encly on this phone: its hardware key was reset. Use your recovery phrase (or fingerprint), then set a new PIN. + The device says it is still locked, so Encly stayed closed. Unlock the device, then try again. + + Too many attempts. Try again in %d minute + Too many attempts. Try again in %d minutes + + + Too many attempts. Try again in %d hour + Too many attempts. Try again in %d hours + + Replace recovery phrase + Get 12 new words. Backups made before keep needing the old ones. + Replace the recovery phrase? + You will get 12 new words. The old phrase stops unlocking your notes on this phone. Backups you already made still open only with the old phrase, so keep it as long as you keep them, or make a new backup afterwards. + Get new words + Your old phrase stops working here + Once you confirm, only these words unlock your notes if you forget the PIN. Backups made before still need the old words. + Recovery phrase replaced. Make a new backup to use it for backups too. + Privacy + Strict keyboard privacy + Asks the keyboard for no suggestions, no cloud prediction and no reading around the cursor. Turns off autocorrect, and some keyboards hide emoji or voice input. + Allow screenshots until the app locks + Screenshots and screen recording stay blocked unless you allow them here. This switches itself off when Encly locks. + Allow screenshots? + Screenshots can end up in your gallery and in cloud backups, with your notes in them. The lock screen, PIN entry and recovery phrase stay protected. This ends when Encly locks. + Lock after leaving the app + How long Encly stays open when you switch to another app. Turning off the screen or locking the phone always locks it at once. + Immediately + After 15 seconds + After 30 seconds + After 1 minute + After 2 minutes + After 5 minutes + After 10 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. + A PIN bound to this phone + Your PIN is mixed with a key in the phone\'s secure chip, so it can\'t be guessed anywhere else. + Separate locks for each way in + PIN, recovery phrase and fingerprint each open their own encrypted slot. Fingerprint needs your finger every time. + Danger zone + Hold for 5 seconds to erase + + Wipe PIN + A second PIN that erases your notes when typed on the lock screen + Turned off when your PIN key was reset. Set it again if you used one. + If someone makes you open Encly, type the wipe PIN instead of your PIN. Encly erases your notes, tasks and tags without any sign and opens as an empty vault. It works only on the lock screen. + After an erase, Encly opens with the wipe PIN: it becomes the PIN of the new, empty vault. Your old PIN stops working, so whoever saw you type the wipe PIN can open Encly with it again and sees nothing unusual. + Backup files you already exported are not touched. They still open with your recovery phrase and are the only way back. + Biometric unlock stays on: someone can force your finger onto the sensor. Turn it off if that is a risk for you. + Turn off biometric unlock + It protects you when someone forces you to unlock, not against a forensic examination of the phone. During a lockout the wipe PIN can\'t be used either. Encly never shows whether a wipe PIN is set: a new one replaces the old one, and changing your PIN to the wipe PIN turns it off. + Set wipe PIN + Turn off wipe PIN + Confirm your PIN to set up the wipe PIN + Create a wipe PIN + 6 to 12 digits, different from your PIN + Confirm the wipe PIN + Enter the wipe PIN again + The wipe PIN must be different from your PIN + Couldn\'t save the wipe PIN. Please try again. + 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 to 12 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 + Show note previews + The notes list shows the first lines of each note. Turn it off to show only the title, label and date; search still finds the full text. + Digits entered: %1$d + Unlock + Recovery phrase accepted + Your PIN still works. Keep it, or set a new one. + Keep my PIN + Set a new PIN + diff --git a/core/resources/src/desktopTest/kotlin/com/pasich/encly/core/resources/AndroidStrings.kt b/core/resources/src/desktopTest/kotlin/com/pasich/encly/core/resources/AndroidStrings.kt new file mode 100644 index 00000000..2c2f9f02 --- /dev/null +++ b/core/resources/src/desktopTest/kotlin/com/pasich/encly/core/resources/AndroidStrings.kt @@ -0,0 +1,154 @@ +package com.pasich.encly.core.resources + +import java.io.File +import java.util.Locale +import javax.xml.parsers.DocumentBuilderFactory + +/** + * The translations as Android would have read them: the four files per language (`strings`, + * `strings_handoff`, `strings_import`, `strings_security`) of `src/commonMain/strings/values[-]/`, + * which are the files Weblate manages, read with the rules of Android's resource compiler and formatted + * the way `Resources.getString` formats. It is the reference [AndroidStringsConversionTest] holds the + * Compose resources to. + */ +internal object AndroidStrings { + /** A language and the folder its strings were in: `values` is English, the default. */ + class Language(val tag: String, val folder: String) + + val languages = listOf( + Language("en", "values"), + Language("de", "values-de"), + Language("es", "values-es"), + Language("fr", "values-fr"), + Language("it", "values-it"), + Language("nl", "values-nl"), + Language("pl", "values-pl"), + Language("pt", "values-pt"), + Language("uk", "values-uk"), + Language("ar", "values-ar"), + ) + + private val files = listOf("strings.xml", "strings_handoff.xml", "strings_import.xml", "strings_security.xml") + + class Plural(val items: Map) + + class Entries(val strings: Map, val plurals: Map) + + /** The decoded strings and plurals of [language] alone (its own folder, no fallback). */ + fun read(language: Language): Entries { + val strings = linkedMapOf() + val plurals = linkedMapOf() + for (file in files) { + val source = File("src/commonMain/strings/${language.folder}/$file") + check(source.isFile) { "missing ${source.absolutePath}" } + val root = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(source).documentElement + val nodes = root.childNodes + for (index in 0 until nodes.length) { + val node = nodes.item(index) as? org.w3c.dom.Element ?: continue + val name = node.getAttribute("name") + when (node.tagName) { + "string" -> strings.putNew(name, decode(node.textContent)) + + "plurals" -> { + val items = linkedMapOf() + val children = node.childNodes + for (child in 0 until children.length) { + val item = children.item(child) as? org.w3c.dom.Element ?: continue + items[item.getAttribute("quantity")] = decode(item.textContent) + } + check(plurals.put(name, Plural(items)) == null) { "duplicate plurals $name" } + } + + else -> error("unexpected <${node.tagName}> $name in ${language.folder}/$file") + } + } + } + return Entries(strings, plurals) + } + + private fun MutableMap.putNew(key: String, value: String) { + check(put(key, value) == null) { "duplicate string $key" } + } + + /** + * The text of a resource as aapt stores it: surrounding whitespace dropped, runs of it outside + * double quotes collapsed to one space, `\n`, `\t`, `\'`, `\"`, `\\`, `\@`, `\?` and `\uXXXX` + * escapes resolved and unescaped double quotes (which only switch the quoting) removed. + */ + fun decode(raw: String): String { + val text = raw.trim() + val out = StringBuilder() + var quoted = false + var pendingSpace = false + var index = 0 + fun emit(char: Char) { + if (pendingSpace) out.append(' ') + pendingSpace = false + out.append(char) + } + while (index < text.length) { + val char = text[index] + when { + char == '\\' && index + 1 < text.length -> { + val next = text[index + 1] + index += 2 + when (next) { + 'n' -> emit('\n') + + 't' -> emit('\t') + + 'u' -> { + emit(text.substring(index, index + HEX_DIGITS).toInt(HEX_RADIX).toChar()) + index += HEX_DIGITS + } + + else -> emit(next) + } + continue + } + + char == '"' -> quoted = !quoted + + char.isWhitespace() && !quoted -> pendingSpace = true + + else -> emit(char) + } + index++ + } + return out.toString() + } + + /** What `Resources.getString(id, *args)` gives for [decoded]: `java.util.Formatter` semantics. */ + fun format(decoded: String, args: Array): String = String.format(Locale.ROOT, decoded, *args) + + /** Whether [decoded] has any format specifier, i.e. whether Android would format it at all. */ + fun hasSpecifiers(decoded: String): Boolean = SPECIFIER.containsMatchIn(decoded) + + /** + * Sample arguments for [decoded]: an integer for each `%d`, a word for each `%s`, in the order + * of the specifiers (positional ones by their number). + */ + fun sampleArguments(decoded: String): Array { + val kinds = sortedMapOf() + var sequential = 0 + for (match in SPECIFIER.findAll(decoded)) { + val conversion = match.groupValues[3] + if (conversion == "%") continue + val position = match.groupValues[1].takeIf { it.isNotEmpty() }?.toInt() ?: ++sequential + kinds[position] = conversion.single() + } + val last = kinds.keys.maxOrNull() ?: return emptyArray() + return Array(last) { slot -> + val position = slot + 1 + if (kinds[position] == 's') "word$position" else position * ARGUMENT_STEP + ARGUMENT_BASE + } + } + + /** Groups: the position (`%2$d`), the flags and width, the conversion letter. */ + private val SPECIFIER = Regex("""%(?:(\d+)\$)?([-#+ 0,(]*\d*(?:\.\d+)?)([a-zA-Z%])""") + + private const val HEX_DIGITS = 4 + private const val HEX_RADIX = 16 + private const val ARGUMENT_STEP = 7 + private const val ARGUMENT_BASE = 3 +} diff --git a/core/resources/src/desktopTest/kotlin/com/pasich/encly/core/resources/AndroidStringsConversionTest.kt b/core/resources/src/desktopTest/kotlin/com/pasich/encly/core/resources/AndroidStringsConversionTest.kt new file mode 100644 index 00000000..73c5feca --- /dev/null +++ b/core/resources/src/desktopTest/kotlin/com/pasich/encly/core/resources/AndroidStringsConversionTest.kt @@ -0,0 +1,245 @@ +package com.pasich.encly.core.resources + +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getPluralString +import org.jetbrains.compose.resources.getString +import java.util.Base64 +import java.util.Locale +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The translations are kept in Android's format and converted for Compose resources (see + * `ConvertAndroidStrings` in build-logic), so what the app shows must be what Android would have + * shown. [AndroidStrings] reads the files the way Android reads them and formats the way + * `Resources.getString` / `getQuantityString` format; every string and plural of every language + * must come out of the Compose resources identical, in the language selected by the default + * locale. A second check reads the shipped data, which also reaches the plural categories no whole + * number selects, and allows one difference from the Android text: the format arguments Compose + * resources cannot fill in as written (`%d` becomes `%1$d`, `%%` becomes `%`). + */ +class AndroidStringsConversionTest { + private val savedLocale = Locale.getDefault() + private val snapshots = AndroidStrings.languages.associate { it.tag to AndroidStrings.read(it) } + private val english = snapshots.getValue("en") + + @AfterTest + fun restoreLocale() = Locale.setDefault(savedLocale) + + @Test + fun theSameKeysExist() { + assertEquals(english.strings.keys, Res.allStringResources.keys) + assertEquals(english.plurals.keys, Res.allPluralStringResources.keys) + assertTrue(english.strings.size > MIN_STRINGS, "the snapshot is empty") + AndroidStrings.languages.forEach { language -> + val own = snapshots.getValue(language.tag) + assertTrue(own.strings.keys.all { it in english.strings }, "${language.tag} has keys English lacks") + assertEquals(english.plurals.keys, own.plurals.keys, "${language.tag} plurals") + } + } + + @Test + fun everyStringReadsTheSameInEveryLanguage() { + val different = mutableListOf() + var compared = 0 + AndroidStrings.languages.forEach { language -> + val own = snapshots.getValue(language.tag) + english.strings.keys.forEach { key -> + // Strings that are not translated (brand names, language names) have English text only. + val old = own.strings[key] ?: english.strings.getValue(key) + val args = AndroidStrings.sampleArguments(old) + val expected = if (AndroidStrings.hasSpecifiers( + old, + ) + ) { + AndroidStrings.format(old, args) + } else { + old + } + val actual = + inLocale(language.tag) { runBlocking { getString(Res.allStringResources.getValue(key), *args) } } + compared++ + if (expected != actual) different += "${language.tag}/$key: '$expected' != '$actual'" + } + } + assertTrue( + different.isEmpty(), + "${different.size} of $compared differ:\n" + different.take(MAX_REPORTED).joinToString("\n"), + ) + } + + @Test + fun everyPluralReadsTheSameInEveryLanguage() { + val different = mutableListOf() + var compared = 0 + AndroidStrings.languages.forEach { language -> + val own = snapshots.getValue(language.tag) + val reachable = QUANTITIES.getValue(language.tag) + english.plurals.keys.forEach { key -> + val plural = own.plurals.getValue(key) + reachable.forEach { (category, quantity) -> + val old = plural.items.getValue(category) + val args = AndroidStrings.sampleArguments(old) + val expected = if (AndroidStrings.hasSpecifiers( + old, + ) + ) { + AndroidStrings.format(old, args) + } else { + old + } + val actual = inLocale(language.tag) { + runBlocking { getPluralString(Res.allPluralStringResources.getValue(key), quantity, *args) } + } + compared++ + if (expected != + actual + ) { + different += "${language.tag}/$key[$category=$quantity]: '$expected' != '$actual'" + } + } + } + } + assertTrue( + different.isEmpty(), + "${different.size} of $compared differ:\n" + different.take(MAX_REPORTED).joinToString("\n"), + ) + } + + @Test + fun theShippedTextIsTheAndroidTextWithOnlyTheFormatRewrite() { + val different = mutableListOf() + var rewritten = 0 + AndroidStrings.languages.forEach { language -> + val own = snapshots.getValue(language.tag) + val shipped = shipped(language.folder) + assertEquals(own.strings.keys, shipped.strings.keys, "${language.tag} strings") + assertEquals(own.plurals.keys, shipped.plurals.keys, "${language.tag} plurals") + own.strings.forEach { (key, old) -> + val new = shipped.strings.getValue(key) + if (rewrite(old) != new) different += "${language.tag}/$key: '${rewrite(old)}' != '$new'" + if (rewrite(old) != old) rewritten++ + } + own.plurals.forEach { (key, plural) -> + assertEquals( + plural.items.keys, + shipped.plurals.getValue(key).items.keys, + "${language.tag}/$key quantities", + ) + plural.items.forEach { (quantity, old) -> + val new = shipped.plurals.getValue(key).items.getValue(quantity) + if (rewrite(old) != new) different += "${language.tag}/$key[$quantity]: '${rewrite(old)}' != '$new'" + if (rewrite(old) != old) rewritten++ + } + } + } + assertTrue(different.isEmpty(), different.take(MAX_REPORTED).joinToString("\n")) + assertTrue(rewritten > 0, "no format argument was rewritten?") + } + + @Test + fun arabicPicksOneOfItsSixPluralCategories() { + fun lockout(count: Int, unit: String) = "محاولات كثيرة جدًا. أعد المحاولة بعد $count $unit" + val expected = listOf( + lockout(0, "ثانية"), + lockout(1, "ثانية"), + lockout(2, "ثانيتين"), + lockout(3, "ثوانٍ"), + lockout(10, "ثوانٍ"), + lockout(11, "ثانية"), + lockout(99, "ثانية"), + lockout(100, "ثانية"), + lockout(101, "ثانية"), + lockout(102, "ثانية"), + ) + + val actual = listOf(0, 1, 2, 3, 10, 11, 99, 100, 101, 102).map { count -> + inLocale("ar") { runBlocking { getPluralString(Res.plurals.lock_lockout_seconds, count, count) } } + } + + assertEquals(expected, actual) + } + + @Test + fun arabicNamesWhatABackupHoldsInTheFormOfTheNumber() { + val counts = listOf(2 to "2 ملاحظتين", 5 to "5 ملاحظات", 12 to "12 ملاحظة") + + counts.forEach { (count, text) -> + val actual = inLocale("ar") { + runBlocking { getPluralString(Res.plurals.backup_count_notes, count, count) } + } + assertEquals(text, actual) + } + } + + private fun inLocale(tag: String, block: () -> T): T { + Locale.setDefault(Locale.forLanguageTag(tag)) + try { + return block() + } finally { + Locale.setDefault(savedLocale) + } + } + + /** `%d` becomes `%$d`, numbered in order of appearance, and `%%` becomes `%`. */ + private fun rewrite(text: String): String { + var counter = 0 + return text.replace(BARE_INT) { "%${++counter}\$d" }.replace("%%", "%") + } + + private class Shipped(val strings: Map, val plurals: Map) + + /** The data the build made of the XML (`*.cvr`), decoded: what the resource reader is handed. */ + private fun shipped(folder: String): Shipped { + val strings = linkedMapOf() + val plurals = linkedMapOf() + listOf("strings", "strings_handoff", "strings_import", "strings_security").forEach { file -> + val bytes = runBlocking { Res.readBytes("$folder/$file.commonMain.cvr") } + bytes.decodeToString().lineSequence().filter { it.isNotBlank() && it != "version:0" }.forEach { line -> + val (type, key, data) = line.split('|') + when (type) { + "string" -> strings[key] = unBase64(data) + + "plurals" -> plurals[key] = AndroidStrings.Plural( + data.split(',').associate { + it.substringBefore(':').lowercase() to + unBase64(it.substringAfter(':')) + }, + ) + + else -> error("unexpected record $type") + } + } + } + return Shipped(strings, plurals) + } + + private fun unBase64(data: String) = String(Base64.getDecoder().decode(data), Charsets.UTF_8) + + private companion object { + const val MIN_STRINGS = 700 + const val MAX_REPORTED = 20 + + val BARE_INT = Regex("""(? (Lcom/pasich/encly/core/backup/BackupError;Ljava/lang/Throwable;)V + public synthetic fun (Lcom/pasich/encly/core/backup/BackupError;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getError ()Lcom/pasich/encly/core/backup/BackupError; +} + +public final class com/pasich/encly/core/backup/BackupFlags { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupFlags; + public static final field LAST_EXPORT_KEY Ljava/lang/String; +} + +public final class com/pasich/encly/core/backup/BackupFormat { + public static final field FILE_EXTENSION Ljava/lang/String; + public static final field HEADER_LENGTH I + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupFormat; + public static final field MAX_FILE_BYTES I + public static final field MIME_TYPE Ljava/lang/String; + public static final field NONCE_LENGTH I + public static final field SALT_LENGTH I + public static final field TAG_LENGTH I + public static final field VERSION I + public final fun encodeHeader (Lcom/pasich/encly/core/backup/BackupKdf;[B[BI)[B + public final fun fileSizeFor (I)J + public final fun getMAGIC ()[B + public final fun parse ([B)Lcom/pasich/encly/core/backup/ParsedBackup; + public final fun parseHeader ([BJ)Lcom/pasich/encly/core/backup/BackupHeader; + public static synthetic fun parseHeader$default (Lcom/pasich/encly/core/backup/BackupFormat;[BJILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupHeader; +} + +public final class com/pasich/encly/core/backup/BackupHeader { + public fun (ILcom/pasich/encly/core/backup/BackupKdf;[B[BI)V + public final fun getCiphertextLength ()I + public final fun getFormatVersion ()I + public final fun getKdf ()Lcom/pasich/encly/core/backup/BackupKdf; + public final fun getNonce ()[B + public final fun getSalt ()[B +} + +public final class com/pasich/encly/core/backup/BackupKdf : java/lang/Enum { + public static final field Companion Lcom/pasich/encly/core/backup/BackupKdf$Companion; + public static final field RECOVERY_PHRASE_HKDF_SHA256 Lcom/pasich/encly/core/backup/BackupKdf; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getId ()I + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/backup/BackupKdf; + public static fun values ()[Lcom/pasich/encly/core/backup/BackupKdf; +} + +public final class com/pasich/encly/core/backup/BackupKdf$Companion { + public final fun fromId (I)Lcom/pasich/encly/core/backup/BackupKdf; +} + +public final class com/pasich/encly/core/backup/BackupKeys { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupKeys; + public static final field KEY_LENGTH I + public final fun fileKeyFromRoot ([B[B)[B + public final fun normalizeMnemonic ([C)[C + public final fun rootFromMnemonic ([C)[B +} + +public final class com/pasich/encly/core/backup/BackupNote { + public static final field Companion Lcom/pasich/encly/core/backup/BackupNote$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/util/List;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/util/List;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Z + public final fun component11 ()Ljava/lang/String; + public final fun component12 ()Ljava/lang/Integer; + public final fun component13 ()Ljava/lang/Integer; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()J + public final fun component7 ()Ljava/util/List; + public final fun component8 ()Z + public final fun component9 ()Z + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/util/List;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)Lcom/pasich/encly/core/backup/BackupNote; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupNote;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/util/List;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupNote; + public fun equals (Ljava/lang/Object;)Z + public final fun getColor ()Ljava/lang/String; + public final fun getDate ()J + public final fun getDateCreate ()J + public final fun getDescription ()Ljava/lang/String; + public final fun getLineSpacing ()Ljava/lang/Integer; + public final fun getTagUids ()Ljava/util/List; + public final fun getTextSize ()Ljava/lang/Integer; + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public final fun isArchived ()Z + public final fun isPinned ()Z + public final fun isTrash ()Z + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupNote$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupNote$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupNote; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupNote;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupNote$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupPayload { + public static final field Companion Lcom/pasich/encly/core/backup/BackupPayload$Companion; + public static final field SCHEMA_V1 I + public static final field SCHEMA_V2 I + public static final field SCHEMA_V3 I + public static final field SCHEMA_V4 I + public static final field SCHEMA_V5 I + public static final field SCHEMA_VERSION I + public fun (IJLjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/Map;)V + public synthetic fun (IJLjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()J + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Ljava/util/List; + public final fun component5 ()Ljava/util/List; + public final fun component6 ()Ljava/util/Map; + public final fun copy (IJLjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/Map;)Lcom/pasich/encly/core/backup/BackupPayload; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupPayload;IJLjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupPayload; + public fun equals (Ljava/lang/Object;)Z + public final fun getExportedAt ()J + public final fun getNotes ()Ljava/util/List; + public final fun getSchema ()I + public final fun getSettings ()Ljava/util/Map; + public final fun getTags ()Ljava/util/List; + public final fun getTasks ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupPayload$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupPayload$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupPayload; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupPayload;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupPayload$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupPayloadCodec { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupPayloadCodec; + public static final field MAX_COLOR_LENGTH I + public static final field MAX_UID_LENGTH I + public final fun decode ([B)Lcom/pasich/encly/core/backup/BackupPayload; + public final fun encode (Lcom/pasich/encly/core/backup/BackupPayload;)[B + public final fun validate (Lcom/pasich/encly/core/backup/BackupPayload;)V +} + +public abstract class com/pasich/encly/core/backup/BackupSecret { +} + +public final class com/pasich/encly/core/backup/BackupSecret$RecoveryPhrase : com/pasich/encly/core/backup/BackupSecret { + public fun ([C)V + public final fun getWords ()[C +} + +public final class com/pasich/encly/core/backup/BackupSecret$RecoveryRoot : com/pasich/encly/core/backup/BackupSecret { + public fun ([B)V + public final fun getRoot ()[B +} + +public final class com/pasich/encly/core/backup/BackupSettings { + public static final field APP_LANGUAGE Ljava/lang/String; + public static final field AUTO_LOCK_DELAY Ljava/lang/String; + public static final field EDITOR_TOOLBAR Ljava/lang/String; + public static final field FONT_SIZE Ljava/lang/String; + public static final field FONT_STYLE Ljava/lang/String; + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupSettings; + public static final field IS_DYNAMIC_THEME Ljava/lang/String; + public static final field IS_GRID_NOTE_LIST Ljava/lang/String; + public static final field KEYBOARD_STRICT_PRIVACY Ljava/lang/String; + public static final field MAX_ENTRIES I + public static final field MAX_FONT_SIZE I + public static final field MIN_FONT_SIZE I + public static final field NOTE_LINE_SPACING Ljava/lang/String; + public static final field SHOW_NOTE_PREVIEWS Ljava/lang/String; + public static final field SHOW_TASKS Ljava/lang/String; + public static final field SIMPLE_EDIT Ljava/lang/String; + public static final field SORT Ljava/lang/String; + public static final field SWIPE_ENABLED Ljava/lang/String; + public static final field SWIPE_LEFT Ljava/lang/String; + public static final field SWIPE_RIGHT Ljava/lang/String; + public static final field THEME_PALETTE Ljava/lang/String; + public static final field THEME_TYPE Ljava/lang/String; + public final fun accepted (Ljava/util/Map;)Ljava/util/Map; + public final fun getExcluded ()Ljava/util/Map; + public final fun getFONT_STYLES ()Ljava/util/List; + public final fun getIncluded ()Ljava/util/Map; + public final fun getLANGUAGES ()Ljava/util/List; + public final fun getLINE_SPACINGS ()Ljava/util/List; + public final fun getSORT_OPTIONS ()Ljava/util/List; + public final fun getSWIPE_ACTIONS ()Ljava/util/List; + public final fun getTHEME_PALETTES ()Ljava/util/List; + public final fun getTHEME_TYPES ()Ljava/util/List; + public final fun getTOOLBAR_SECTIONS ()Ljava/util/List; + public final fun getTOOLBAR_TOOLS ()Ljava/util/List; +} + +public final class com/pasich/encly/core/backup/BackupSettings$SettingKind { + public final fun accepts (Ljava/lang/String;)Z +} + +public final class com/pasich/encly/core/backup/BackupSubtask { + public static final field Companion Lcom/pasich/encly/core/backup/BackupSubtask$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;ZI)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun component4 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;ZI)Lcom/pasich/encly/core/backup/BackupSubtask; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupSubtask;Ljava/lang/String;Ljava/lang/String;ZIILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupSubtask; + public fun equals (Ljava/lang/Object;)Z + public final fun getPosition ()I + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupSubtask$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupSubtask$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupSubtask; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupSubtask;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupSubtask$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupTag { + public static final field Companion Lcom/pasich/encly/core/backup/BackupTag$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;ZI)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun component4 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;ZI)Lcom/pasich/encly/core/backup/BackupTag; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupTag;Ljava/lang/String;Ljava/lang/String;ZIILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupTag; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getPosition ()I + public final fun getUid ()Ljava/lang/String; + public final fun getVisible ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupTag$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupTag$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupTag; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupTag;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupTag$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupTask { + public static final field Companion Lcom/pasich/encly/core/backup/BackupTask$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/String;ILjava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/List; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()J + public final fun component6 ()Ljava/lang/Long; + public final fun component7 ()I + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/String;ILjava/util/List;)Lcom/pasich/encly/core/backup/BackupTask; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupTask;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/String;ILjava/util/List;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupTask; + public fun equals (Ljava/lang/Object;)Z + public final fun getCategoryTagUid ()Ljava/lang/String; + public final fun getCompletedDate ()Ljava/lang/Long; + public final fun getCreatedDate ()J + public final fun getDescription ()Ljava/lang/String; + public final fun getPosition ()I + public final fun getPriority ()I + public final fun getSubtasks ()Ljava/util/List; + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupTask$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupTask$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupTask; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupTask;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupTask$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/ParsedBackup { + public fun (Lcom/pasich/encly/core/backup/BackupHeader;[B)V + public final fun getCiphertextOffset ()I + public final fun getFile ()[B + public final fun getHeader ()Lcom/pasich/encly/core/backup/BackupHeader; +} + +public final class com/pasich/encly/core/security/AuthSettings { + public fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZ)V + public synthetic fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/pasich/encly/core/security/AuthType; + public final fun component2 ()Z + public final fun component3 ()Z + public final fun component4 ()Z + public final fun component5 ()Z + public final fun component6 ()Z + public final fun component7 ()Z + public final fun copy (Lcom/pasich/encly/core/security/AuthType;ZZZZZZ)Lcom/pasich/encly/core/security/AuthSettings; + public static synthetic fun copy$default (Lcom/pasich/encly/core/security/AuthSettings;Lcom/pasich/encly/core/security/AuthType;ZZZZZZILjava/lang/Object;)Lcom/pasich/encly/core/security/AuthSettings; + public fun equals (Ljava/lang/Object;)Z + public final fun getAuthType ()Lcom/pasich/encly/core/security/AuthType; + public final fun getDuressPinTurnedOff ()Z + public final fun getHasDuressPin ()Z + public final fun getHasPinSlot ()Z + public final fun getWipePinTurnedOff ()Z + public fun hashCode ()I + public final fun isBiometricEnabled ()Z + public final fun isUserCreatedSeedKey ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/AuthStrategy : java/lang/Enum { + public static final field NONE Lcom/pasich/encly/core/security/AuthStrategy; + public static final field PIN Lcom/pasich/encly/core/security/AuthStrategy; + public static final field PIN_BIOMETRIC Lcom/pasich/encly/core/security/AuthStrategy; + public static final field RECOVERY_DATA Lcom/pasich/encly/core/security/AuthStrategy; + public static final field SEED_PHRASE Lcom/pasich/encly/core/security/AuthStrategy; + public static final field SEED_PHRASE_BIOMETRIC Lcom/pasich/encly/core/security/AuthStrategy; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/AuthStrategy; + public static fun values ()[Lcom/pasich/encly/core/security/AuthStrategy; +} + +public final class com/pasich/encly/core/security/AuthType : java/lang/Enum { + public static final field NONE Lcom/pasich/encly/core/security/AuthType; + public static final field PIN Lcom/pasich/encly/core/security/AuthType; + public static final field SEED_PHRASE Lcom/pasich/encly/core/security/AuthType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/AuthType; + public static fun values ()[Lcom/pasich/encly/core/security/AuthType; +} + +public final class com/pasich/encly/core/security/AuthenticationManager { + public static final field Companion Lcom/pasich/encly/core/security/AuthenticationManager$Companion; + public static final field FIRST_LOCKOUT_MS J + public static final field MAX_ATTEMPTS I + public static final field MAX_LOCKOUT_MS J + public static final field STRIKE_GRACE I + public fun (Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/PinHardwareFactor;Lcom/pasich/encly/core/security/LockoutClock;)V + public final fun changePin ([C[B)Lcom/pasich/encly/core/security/PinChange; + public final fun clearPendingWipe ()Z + public final fun configureDuressPin ([C[BLkotlin/jvm/functions/Function0;)Lcom/pasich/encly/core/security/DuressPinChange; + public final fun configurePin ([C[B)Z + public final fun configureWipePin ([C)Lcom/pasich/encly/core/security/WipePinChange; + public final fun deactivateBiometricAuth ()V + public final fun deleteRetiredPinKey ()V + public final fun deleteStrayPinKey ()V + public final fun duressPinTurnedOff ([B)Z + public final fun ensureWipeSlot ()Z + public final fun getAuthType ()Lcom/pasich/encly/core/security/AuthType; + public final fun hasDecoyVault ([B)Z + public final fun hasPinSlot ()Z + public final fun isAuthStrategy ()Lcom/pasich/encly/core/security/AuthStrategy; + public final fun isBiometricEnabled ()Z + public final fun markBiometricEnabled (Z)V + public final fun markWipeDatabaseCreated ()Z + public final fun pendingWipe ()Lcom/pasich/encly/core/security/WipeStage; + public final fun recoveryFollowUp ()Lcom/pasich/encly/core/security/RecoveryFollowUp; + public final fun remainingLockoutMillis ()J + public final fun removeDuressPin ()Z + public final fun removeWipePin ()Z + public final fun unlockWithPin ([C)Lcom/pasich/encly/core/security/PinUnlock; + public final fun verifyPinAuth ([CZ)Z + public static synthetic fun verifyPinAuth$default (Lcom/pasich/encly/core/security/AuthenticationManager;[CZILjava/lang/Object;)Z + public final fun wipe ()V + public final fun wipePinTurnedOff ()Z +} + +public final class com/pasich/encly/core/security/AuthenticationManager$Companion { + public final fun penaltyFor (II)J + public static synthetic fun penaltyFor$default (Lcom/pasich/encly/core/security/AuthenticationManager$Companion;IIILjava/lang/Object;)J +} + +public final class com/pasich/encly/core/security/AuthenticationManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/AuthenticationManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/AuthenticationManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/AuthenticationManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/PinHardwareFactor;Lcom/pasich/encly/core/security/LockoutClock;)Lcom/pasich/encly/core/security/AuthenticationManager; +} + +public final class com/pasich/encly/core/security/AuthenticationManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/AuthenticationManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/PinHardwareFactor;Lcom/pasich/encly/core/security/LockoutClock;)Lcom/pasich/encly/core/security/AuthenticationManager; +} + +public final class com/pasich/encly/core/security/AuthenticationManagerKt { + public static final field MAX_PIN_LENGTH I + public static final field MIN_PIN_LENGTH I +} + +public final class com/pasich/encly/core/security/AutoLock : com/pasich/encly/core/security/AutoLockPolicy { + public fun (Lcom/pasich/encly/core/platform/KeyValueStore;)V + public final fun getDelay ()Lkotlinx/coroutines/flow/StateFlow; + public fun getDelayMillis ()J + public final fun setDelay (Lcom/pasich/encly/core/security/AutoLockDelay;)V +} + +public final class com/pasich/encly/core/security/AutoLock$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/AutoLock$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/AutoLock$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/AutoLock; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;)Lcom/pasich/encly/core/security/AutoLock; +} + +public final class com/pasich/encly/core/security/AutoLock$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/AutoLock$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;)Lcom/pasich/encly/core/security/AutoLock; +} + +public final class com/pasich/encly/core/security/AutoLockDelay : java/lang/Enum { + public static final field Companion Lcom/pasich/encly/core/security/AutoLockDelay$Companion; + public static final field IMMEDIATELY Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_10 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_15 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_2 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_30 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_5 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTE_1 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field SECONDS_15 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field SECONDS_30 Lcom/pasich/encly/core/security/AutoLockDelay; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getMillis ()J + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/AutoLockDelay; + public static fun values ()[Lcom/pasich/encly/core/security/AutoLockDelay; +} + +public final class com/pasich/encly/core/security/AutoLockDelay$Companion { + public final fun getDEFAULT ()Lcom/pasich/encly/core/security/AutoLockDelay; +} + +public abstract interface class com/pasich/encly/core/security/AutoLockPolicy { + public abstract fun getDelayMillis ()J +} + +public final class com/pasich/encly/core/security/AutoLockPolicy$Immediately : com/pasich/encly/core/security/AutoLockPolicy { + public static final field INSTANCE Lcom/pasich/encly/core/security/AutoLockPolicy$Immediately; + public fun getDelayMillis ()J +} + +public abstract interface class com/pasich/encly/core/security/BiometricHost { +} + +public abstract interface class com/pasich/encly/core/security/BiometricManager { + public static final field Companion Lcom/pasich/encly/core/security/BiometricManager$Companion; + public static final field SLOT_PREFIX Ljava/lang/String; + public abstract fun authenticate (Lcom/pasich/encly/core/security/BiometricHost;Lcom/pasich/encly/core/security/BiometricManager$BiometricType;Lcom/pasich/encly/core/security/BiometricManager$BiometricCallback;Lcom/pasich/encly/core/security/BiometricManager$PromptConfig;)V + public static synthetic fun authenticate$default (Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/BiometricHost;Lcom/pasich/encly/core/security/BiometricManager$BiometricType;Lcom/pasich/encly/core/security/BiometricManager$BiometricCallback;Lcom/pasich/encly/core/security/BiometricManager$PromptConfig;ILjava/lang/Object;)V + public abstract fun disable ()V + public abstract fun enroll (Lcom/pasich/encly/core/security/BiometricHost;[BLkotlin/jvm/functions/Function1;)V + public abstract fun hasSlot ()Z + public abstract fun isStrongBiometricAvailable ()Z + public abstract fun strongBiometricStatus ()Lcom/pasich/encly/core/security/BiometricStatus; + public abstract fun unlock (Lcom/pasich/encly/core/security/BiometricHost;Lkotlin/jvm/functions/Function1;)V +} + +public abstract interface class com/pasich/encly/core/security/BiometricManager$BiometricCallback { + public fun onCancelled ()V + public abstract fun onError (ILjava/lang/String;)V + public abstract fun onFailed ()V + public abstract fun onSuccess ()V +} + +public final class com/pasich/encly/core/security/BiometricManager$BiometricCallback$DefaultImpls { + public static fun onCancelled (Lcom/pasich/encly/core/security/BiometricManager$BiometricCallback;)V +} + +public final class com/pasich/encly/core/security/BiometricManager$BiometricType : java/lang/Enum { + public static final field APP_UNLOCK Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static final field GENERAL Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static final field MASTER_KEY_ACCESS Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static final field SETTINGS_TOGGLE Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static fun values ()[Lcom/pasich/encly/core/security/BiometricManager$BiometricType; +} + +public final class com/pasich/encly/core/security/BiometricManager$Companion { + public static final field SLOT_PREFIX Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/BiometricManager$DefaultImpls { + public static synthetic fun authenticate$default (Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/BiometricHost;Lcom/pasich/encly/core/security/BiometricManager$BiometricType;Lcom/pasich/encly/core/security/BiometricManager$BiometricCallback;Lcom/pasich/encly/core/security/BiometricManager$PromptConfig;ILjava/lang/Object;)V +} + +public final class com/pasich/encly/core/security/BiometricManager$PromptConfig { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/pasich/encly/core/security/BiometricManager$PromptConfig; + public static synthetic fun copy$default (Lcom/pasich/encly/core/security/BiometricManager$PromptConfig;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/core/security/BiometricManager$PromptConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getDescription ()Ljava/lang/String; + public final fun getNegativeButtonText ()Ljava/lang/String; + public final fun getSubtitle ()Ljava/lang/String; + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/BiometricStatus : java/lang/Enum { + public static final field AVAILABLE Lcom/pasich/encly/core/security/BiometricStatus; + public static final field Companion Lcom/pasich/encly/core/security/BiometricStatus$Companion; + public static final field NOT_ENROLLED Lcom/pasich/encly/core/security/BiometricStatus; + public static final field UNAVAILABLE Lcom/pasich/encly/core/security/BiometricStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/BiometricStatus; + public static fun values ()[Lcom/pasich/encly/core/security/BiometricStatus; +} + +public final class com/pasich/encly/core/security/BiometricStatus$Companion { +} + +public abstract interface class com/pasich/encly/core/security/DeviceLockWatcher { + public abstract fun isDeviceLocked ()Z + public abstract fun watchScreenOff (Lkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/DeviceLockWatcher$None : com/pasich/encly/core/security/DeviceLockWatcher { + public static final field INSTANCE Lcom/pasich/encly/core/security/DeviceLockWatcher$None; + public fun isDeviceLocked ()Z + public fun watchScreenOff (Lkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/DuressPinChange : java/lang/Enum { + public static final field FAILED Lcom/pasich/encly/core/security/DuressPinChange; + public static final field IN_USE Lcom/pasich/encly/core/security/DuressPinChange; + public static final field SAME_AS_PIN Lcom/pasich/encly/core/security/DuressPinChange; + public static final field SET Lcom/pasich/encly/core/security/DuressPinChange; + public static final field SET_NEW_VAULT Lcom/pasich/encly/core/security/DuressPinChange; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/DuressPinChange; + public static fun values ()[Lcom/pasich/encly/core/security/DuressPinChange; +} + +public abstract interface class com/pasich/encly/core/security/HandoffStagingSweeper { + public abstract fun sweep ()V +} + +public final class com/pasich/encly/core/security/InitialStatus : java/lang/Enum { + public static final field AUTH Lcom/pasich/encly/core/security/InitialStatus; + public static final field LEGACY_VAULT Lcom/pasich/encly/core/security/InitialStatus; + public static final field LOSS_CRYPTO Lcom/pasich/encly/core/security/InitialStatus; + public static final field LOSS_DATABASE Lcom/pasich/encly/core/security/InitialStatus; + public static final field MAIN Lcom/pasich/encly/core/security/InitialStatus; + public static final field NO Lcom/pasich/encly/core/security/InitialStatus; + public static final field ONBOARDING Lcom/pasich/encly/core/security/InitialStatus; + public static final field SETUP_AUTH Lcom/pasich/encly/core/security/InitialStatus; + public static final field STORAGE_UNREADABLE Lcom/pasich/encly/core/security/InitialStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/InitialStatus; + public static fun values ()[Lcom/pasich/encly/core/security/InitialStatus; +} + +public final class com/pasich/encly/core/security/JvmMonotonicClock : com/pasich/encly/core/security/LockoutClock { + public static final field INSTANCE Lcom/pasich/encly/core/security/JvmMonotonicClock; + public fun bootCount ()I + public fun elapsedRealtime ()J +} + +public final class com/pasich/encly/core/security/JvmVaultFileIo : com/pasich/encly/core/security/VaultFileIo { + public fun (Ljava/io/File;)V + public fun delete ()Z + public fun deleteTemp ()V + public fun exists ()Z + public fun read ()[B + public fun replace ([BLkotlin/jvm/functions/Function0;)V +} + +public final class com/pasich/encly/core/security/JvmVaultFileIoKt { + public static final fun VaultStore (Ljava/io/File;)Lcom/pasich/encly/core/security/VaultStore; +} + +public final class com/pasich/encly/core/security/KeyboardPrivacy { + public fun (Lcom/pasich/encly/core/platform/KeyValueStore;)V + public final fun getStrict ()Lkotlinx/coroutines/flow/StateFlow; + public final fun setStrict (Z)V +} + +public final class com/pasich/encly/core/security/KeyboardPrivacy$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/KeyboardPrivacy$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/KeyboardPrivacy$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/KeyboardPrivacy; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;)Lcom/pasich/encly/core/security/KeyboardPrivacy; +} + +public final class com/pasich/encly/core/security/KeyboardPrivacy$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/KeyboardPrivacy$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;)Lcom/pasich/encly/core/security/KeyboardPrivacy; +} + +public abstract interface class com/pasich/encly/core/security/LockoutClock { + public abstract fun bootCount ()I + public abstract fun elapsedRealtime ()J +} + +public final class com/pasich/encly/core/security/Mnemonic { + public static final field INSTANCE Lcom/pasich/encly/core/security/Mnemonic; + public final fun generate ()[C +} + +public final class com/pasich/encly/core/security/NeverLocked : com/pasich/encly/core/security/VaultLockEvents { + public static final field INSTANCE Lcom/pasich/encly/core/security/NeverLocked; + public fun getLocked ()Lkotlinx/coroutines/flow/StateFlow; + public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/PinChange : java/lang/Enum { + public static final field FAILED Lcom/pasich/encly/core/security/PinChange; + public static final field IN_USE Lcom/pasich/encly/core/security/PinChange; + public static final field SET Lcom/pasich/encly/core/security/PinChange; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/PinChange; + public static fun values ()[Lcom/pasich/encly/core/security/PinChange; +} + +public final class com/pasich/encly/core/security/PinFactorException : java/lang/Exception { + public fun (ZLjava/lang/Throwable;)V + public synthetic fun (ZLjava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getLost ()Z +} + +public abstract interface class com/pasich/encly/core/security/PinHardwareFactor { + public abstract fun delete (Lcom/pasich/encly/core/security/PinKeySlot;)V + public abstract fun ensureKey (Lcom/pasich/encly/core/security/PinKeySlot;)Z + public abstract fun mac (Lcom/pasich/encly/core/security/PinKeySlot;[B)[B + public abstract fun reset (Lcom/pasich/encly/core/security/PinKeySlot;)V +} + +public final class com/pasich/encly/core/security/PinKeySlot : java/lang/Enum { + public static final field A Lcom/pasich/encly/core/security/PinKeySlot; + public static final field B Lcom/pasich/encly/core/security/PinKeySlot; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getOther ()Lcom/pasich/encly/core/security/PinKeySlot; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/PinKeySlot; + public static fun values ()[Lcom/pasich/encly/core/security/PinKeySlot; +} + +public abstract interface class com/pasich/encly/core/security/PinUnlock { +} + +public final class com/pasich/encly/core/security/PinUnlock$Decoy : com/pasich/encly/core/security/PinUnlock { + public fun ([B)V + public final fun getDek ()[B +} + +public final class com/pasich/encly/core/security/PinUnlock$Erased : com/pasich/encly/core/security/PinUnlock { + public fun ([B)V + public final fun getDek ()[B +} + +public final class com/pasich/encly/core/security/PinUnlock$Failed : com/pasich/encly/core/security/PinUnlock { + public static final field INSTANCE Lcom/pasich/encly/core/security/PinUnlock$Failed; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/PinUnlock$KeyLost : com/pasich/encly/core/security/PinUnlock { + public static final field INSTANCE Lcom/pasich/encly/core/security/PinUnlock$KeyLost; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/PinUnlock$LockedOut : com/pasich/encly/core/security/PinUnlock { + public static final field INSTANCE Lcom/pasich/encly/core/security/PinUnlock$LockedOut; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/PinUnlock$Success : com/pasich/encly/core/security/PinUnlock { + public fun ([B)V + public final fun getDek ()[B +} + +public final class com/pasich/encly/core/security/PinUnlock$WrongPin : com/pasich/encly/core/security/PinUnlock { + public static final field INSTANCE Lcom/pasich/encly/core/security/PinUnlock$WrongPin; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/RecoveryFollowUp : java/lang/Enum { + public static final field KEEP_OR_CHANGE Lcom/pasich/encly/core/security/RecoveryFollowUp; + public static final field MUST_SET_PIN Lcom/pasich/encly/core/security/RecoveryFollowUp; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/RecoveryFollowUp; + public static fun values ()[Lcom/pasich/encly/core/security/RecoveryFollowUp; +} + +public final class com/pasich/encly/core/security/RecoveryStage : java/lang/Enum { + public static final field CHOOSE Lcom/pasich/encly/core/security/RecoveryStage; + public static final field MUST_SET_PIN Lcom/pasich/encly/core/security/RecoveryStage; + public static final field NONE Lcom/pasich/encly/core/security/RecoveryStage; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/RecoveryStage; + public static fun values ()[Lcom/pasich/encly/core/security/RecoveryStage; +} + +public final class com/pasich/encly/core/security/RecoveryWords { + public static final field COUNT I + public static final field INSTANCE Lcom/pasich/encly/core/security/RecoveryWords; + public final fun isPrefix (Ljava/lang/String;)Z + public final fun isValidPhrase ([C)Z + public final fun isWord (Ljava/lang/String;)Z +} + +public final class com/pasich/encly/core/security/ScreenshotAllowance { + public fun ()V + public final fun allow ()V + public final fun getAllowed ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getCapturable ()Z + public final fun observe (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; + public final fun protect ()Lkotlin/jvm/functions/Function0; + public final fun revoke ()V +} + +public final class com/pasich/encly/core/security/ScreenshotAllowance$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/ScreenshotAllowance$MetroFactory$Companion; + public static final fun create ()Lcom/pasich/encly/core/security/ScreenshotAllowance$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/ScreenshotAllowance; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance ()Lcom/pasich/encly/core/security/ScreenshotAllowance; +} + +public final class com/pasich/encly/core/security/ScreenshotAllowance$MetroFactory$Companion { + public final fun create ()Lcom/pasich/encly/core/security/ScreenshotAllowance$MetroFactory; + public final fun newInstance ()Lcom/pasich/encly/core/security/ScreenshotAllowance; +} + +public final class com/pasich/encly/core/security/SecurityManager { + public static final field Companion Lcom/pasich/encly/core/security/SecurityManager$Companion; + public fun (Lcom/pasich/encly/core/platform/KeyValueStore;Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/SeedPhraseManager;Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/AuthenticationManager;Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/HandoffStagingSweeper;)V + public synthetic fun (Lcom/pasich/encly/core/platform/KeyValueStore;Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/SeedPhraseManager;Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/AuthenticationManager;Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/HandoffStagingSweeper;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addRecoverySeed ([C)Z + public final fun authStrategy ()Lcom/pasich/encly/core/security/AuthStrategy; + public final fun biometricAvailable ()Z + public final fun biometricStatus ()Lcom/pasich/encly/core/security/BiometricStatus; + public final fun canReauthWithBiometric ()Z + public final fun canResetPinWithoutCurrent ()Z + public final fun changePin ([C)Lcom/pasich/encly/core/security/PinChange; + public final fun commitInitialSetup ()Z + public final fun completePendingWipe ()V + public final fun configureDuressPin ([C)Lcom/pasich/encly/core/security/DuressPinChange; + public final fun configurePin ([C)Z + public final fun configureWipePin ([C)Lcom/pasich/encly/core/security/WipePinChange; + public final fun confirmBiometric (Lcom/pasich/encly/core/security/BiometricHost;Lkotlin/jvm/functions/Function1;)V + public final fun copyBackupRootKey ()[B + public final fun createBackupKey ([C)Z + public final fun disableBiometric ()V + public final fun duressPinTurnedOff ()Z + public final fun enrollBiometric (Lcom/pasich/encly/core/security/BiometricHost;Lkotlin/jvm/functions/Function1;)V + public final fun eraseEpoch ()J + public final fun finishInitialSetup ()Z + public final fun generateMnemonicCode ()[C + public final fun getSecurityStatus ()Lcom/pasich/encly/core/security/InitialStatus; + public final fun getSettingsAuth ()Lcom/pasich/encly/core/security/AuthSettings; + public final fun hasBackupKey ()Z + public final fun hasDuressPin ()Z + public final fun hasRecoverySeed ()Z + public final fun initializeNewVault ([C)Z + public final fun isBiometricEnabled ()Z + public final fun isDatabaseUnlocked ()Z + public final fun isDecoySession ()Z + public final fun isLockable ()Z + public final fun isOnboardingShow ()Z + public final fun isValidRecoveryPhrase ([C)Z + public final fun keepUnlockMethodsAfterRecovery ()Z + public final fun lock ()V + public final fun openInitialVault ()Z + public final fun pinLockoutRemainingMillis ()J + public final fun recoveryStage ()Lcom/pasich/encly/core/security/RecoveryStage; + public final fun removeDuressPin ()Z + public final fun removeWipePin ()Z + public final fun replaceRecoverySeed ([C)Z + public final fun requestBiometricKey (Lcom/pasich/encly/core/security/BiometricHost;Lkotlin/jvm/functions/Function1;)V + public final fun resolveInitialStatus ()Lcom/pasich/encly/core/security/InitialStatus; + public final fun setOnboardingShown ()Z + public final fun setSecurityStatus (Lcom/pasich/encly/core/security/InitialStatus;)V + public final fun unlockWithPin ([C)Lcom/pasich/encly/core/security/VaultUnlockResult; + public final fun unlockWithRawKey ([BZ)Z + public static synthetic fun unlockWithRawKey$default (Lcom/pasich/encly/core/security/SecurityManager;[BZILjava/lang/Object;)Z + public final fun unlockWithSeed ([C)Lcom/pasich/encly/core/security/VaultUnlockResult; + public final fun verifyPin ([C)Z + public final fun verifySeed ([C)Z + public final fun wipeAndReset ()Z +} + +public final class com/pasich/encly/core/security/SecurityManager$Companion { +} + +public final class com/pasich/encly/core/security/SecurityManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/SecurityManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SecurityManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/SecurityManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/SeedPhraseManager;Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/AuthenticationManager;Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/HandoffStagingSweeper;)Lcom/pasich/encly/core/security/SecurityManager; +} + +public final class com/pasich/encly/core/security/SecurityManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SecurityManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/SeedPhraseManager;Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/AuthenticationManager;Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/HandoffStagingSweeper;)Lcom/pasich/encly/core/security/SecurityManager; +} + +public final class com/pasich/encly/core/security/SeedPhraseManager { + public static final field Companion Lcom/pasich/encly/core/security/SeedPhraseManager$Companion; + public fun (Lcom/pasich/encly/core/security/VaultStore;)V + public final fun addRecoverySeed ([C[B)Z + public final fun clearBootstrapKey ()V + public final fun copyBootstrapKey ()[B + public final fun createBackupKey ([C[B)Z + public final fun generateMnemonic ()[C + public final fun hasBackupKey ()Z + public final fun hasBootstrapKey ()Z + public final fun hasRecoverySeed ()Z + public final fun hasStoredSeed ()Z + public final fun initializeVault ([C)Z + public final fun isValidMnemonic ([C)Z + public final fun replaceRecoverySeed ([C[B)Z + public final fun unlockWithSeed ([C)[B + public final fun unwrapBackupRoot ([B)[B + public final fun verificationKeyData ()Z + public final fun verifyMnemonic ([C)Z + public final fun wipe ()V +} + +public final class com/pasich/encly/core/security/SeedPhraseManager$Companion { +} + +public final class com/pasich/encly/core/security/SeedPhraseManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/SeedPhraseManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SeedPhraseManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/SeedPhraseManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/security/VaultStore;)Lcom/pasich/encly/core/security/SeedPhraseManager; +} + +public final class com/pasich/encly/core/security/SeedPhraseManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SeedPhraseManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/security/VaultStore;)Lcom/pasich/encly/core/security/SeedPhraseManager; +} + +public final class com/pasich/encly/core/security/SessionLockManager : androidx/lifecycle/DefaultLifecycleObserver, com/pasich/encly/core/security/VaultLockEvents { + public fun (Lcom/pasich/encly/core/security/SecurityManager;Lcom/pasich/encly/core/security/DeviceLockWatcher;Lcom/pasich/encly/core/security/LockoutClock;Lcom/pasich/encly/core/security/AutoLockPolicy;Lcom/pasich/encly/core/platform/MainThreadTimer;Lcom/pasich/encly/core/security/ScreenshotAllowance;)V + public synthetic fun (Lcom/pasich/encly/core/security/SecurityManager;Lcom/pasich/encly/core/security/DeviceLockWatcher;Lcom/pasich/encly/core/security/LockoutClock;Lcom/pasich/encly/core/security/AutoLockPolicy;Lcom/pasich/encly/core/platform/MainThreadTimer;Lcom/pasich/encly/core/security/ScreenshotAllowance;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun allowSystemPicker ()V + public final fun endSystemPicker ()V + public fun getLocked ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getSessionGeneration ()J + public final fun isDeviceLocked ()Z + public final fun lockNow ()V + public final fun lockRequests ()J + public fun onCreate (Landroidx/lifecycle/LifecycleOwner;)V + public fun onDestroy (Landroidx/lifecycle/LifecycleOwner;)V + public fun onPause (Landroidx/lifecycle/LifecycleOwner;)V + public fun onResume (Landroidx/lifecycle/LifecycleOwner;)V + public fun onStart (Landroidx/lifecycle/LifecycleOwner;)V + public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V + public final fun onUnlockAbandoned ()V + public final fun onUnlocked (J)Z + public static synthetic fun onUnlocked$default (Lcom/pasich/encly/core/security/SessionLockManager;JILjava/lang/Object;)Z + public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; + public final fun requireUnlock ()V +} + +public final class com/pasich/encly/core/security/SessionLockManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/SessionLockManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SessionLockManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/SessionLockManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/security/SecurityManager;Lcom/pasich/encly/core/security/DeviceLockWatcher;Lcom/pasich/encly/core/security/LockoutClock;Lcom/pasich/encly/core/security/AutoLockPolicy;Lcom/pasich/encly/core/platform/MainThreadTimer;Lcom/pasich/encly/core/security/ScreenshotAllowance;)Lcom/pasich/encly/core/security/SessionLockManager; +} + +public final class com/pasich/encly/core/security/SessionLockManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SessionLockManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/security/SecurityManager;Lcom/pasich/encly/core/security/DeviceLockWatcher;Lcom/pasich/encly/core/security/LockoutClock;Lcom/pasich/encly/core/security/AutoLockPolicy;Lcom/pasich/encly/core/platform/MainThreadTimer;Lcom/pasich/encly/core/security/ScreenshotAllowance;)Lcom/pasich/encly/core/security/SessionLockManager; +} + +public final class com/pasich/encly/core/security/SessionLockManagerKt { + public static final field LOCK_FLUSH_TIMEOUT_MS J +} + +public abstract interface class com/pasich/encly/core/security/VaultDatabase { + public abstract fun hasEncryptedDatabase (Lcom/pasich/encly/core/security/VaultKind;)Z + public static synthetic fun hasEncryptedDatabase$default (Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/VaultKind;ILjava/lang/Object;)Z + public abstract fun isDatabaseUnlocked ()Z + public abstract fun reset ()V + public abstract fun unlockDatabase ([BZ)Z + public abstract fun unlockDatabase ([BZLcom/pasich/encly/core/security/VaultKind;)Z + public static synthetic fun unlockDatabase$default (Lcom/pasich/encly/core/security/VaultDatabase;[BZILjava/lang/Object;)Z + public abstract fun wipe ()V + public abstract fun wipeIfClosed (Lcom/pasich/encly/core/security/VaultKind;)Z + public static synthetic fun wipeIfClosed$default (Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/VaultKind;ILjava/lang/Object;)Z +} + +public final class com/pasich/encly/core/security/VaultDatabase$DefaultImpls { + public static synthetic fun hasEncryptedDatabase$default (Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/VaultKind;ILjava/lang/Object;)Z + public static synthetic fun unlockDatabase$default (Lcom/pasich/encly/core/security/VaultDatabase;[BZILjava/lang/Object;)Z + public static synthetic fun wipeIfClosed$default (Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/VaultKind;ILjava/lang/Object;)Z +} + +public abstract interface class com/pasich/encly/core/security/VaultFileIo { + public abstract fun delete ()Z + public abstract fun deleteTemp ()V + public abstract fun exists ()Z + public abstract fun read ()[B + public abstract fun replace ([BLkotlin/jvm/functions/Function0;)V +} + +public final class com/pasich/encly/core/security/VaultKind : java/lang/Enum { + public static final field DECOY Lcom/pasich/encly/core/security/VaultKind; + public static final field PRIMARY Lcom/pasich/encly/core/security/VaultKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/VaultKind; + public static fun values ()[Lcom/pasich/encly/core/security/VaultKind; +} + +public abstract interface class com/pasich/encly/core/security/VaultLockEvents { + public abstract fun getLocked ()Lkotlinx/coroutines/flow/StateFlow; + public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/VaultLockEvents$DefaultImpls { + public static fun registerPreLockFlush (Lcom/pasich/encly/core/security/VaultLockEvents;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/VaultStore { + public fun (Lcom/pasich/encly/core/security/VaultFileIo;)V + public final fun clear ()Z + public final fun contains (Ljava/lang/String;)Z + public final fun edit (Lkotlin/jvm/functions/Function1;)Z + public final fun getBoolean (Ljava/lang/String;Z)Z + public final fun getBytes (Ljava/lang/String;)[B + public final fun getInt (Ljava/lang/String;I)I + public final fun getLong (Ljava/lang/String;J)J + public final fun isCorrupt ()Z + public final fun isUnreadable ()Z +} + +public final class com/pasich/encly/core/security/VaultStore$Editor { + public final fun putBoolean (Ljava/lang/String;Z)V + public final fun putBytes (Ljava/lang/String;[B)V + public final fun putInt (Ljava/lang/String;I)V + public final fun putLong (Ljava/lang/String;J)V + public final fun remove (Ljava/lang/String;)V + public final fun removePrefix (Ljava/lang/String;)V +} + +public final class com/pasich/encly/core/security/VaultUnlockResult : java/lang/Enum { + public static final field DB_ERROR Lcom/pasich/encly/core/security/VaultUnlockResult; + public static final field INVALID_CREDENTIAL Lcom/pasich/encly/core/security/VaultUnlockResult; + public static final field LOCKED_OUT Lcom/pasich/encly/core/security/VaultUnlockResult; + public static final field PIN_KEY_LOST Lcom/pasich/encly/core/security/VaultUnlockResult; + public static final field SUCCESS Lcom/pasich/encly/core/security/VaultUnlockResult; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/VaultUnlockResult; + public static fun values ()[Lcom/pasich/encly/core/security/VaultUnlockResult; +} + +public final class com/pasich/encly/core/security/WipePinChange : java/lang/Enum { + public static final field FAILED Lcom/pasich/encly/core/security/WipePinChange; + public static final field IN_USE Lcom/pasich/encly/core/security/WipePinChange; + public static final field SAME_AS_PIN Lcom/pasich/encly/core/security/WipePinChange; + public static final field SET Lcom/pasich/encly/core/security/WipePinChange; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/WipePinChange; + public static fun values ()[Lcom/pasich/encly/core/security/WipePinChange; +} + +public final class com/pasich/encly/core/security/WipeStage : java/lang/Enum { + public static final field CLEANUP Lcom/pasich/encly/core/security/WipeStage; + public static final field DATABASE Lcom/pasich/encly/core/security/WipeStage; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/WipeStage; + public static fun values ()[Lcom/pasich/encly/core/security/WipeStage; +} + diff --git a/core/security/api/desktop/security.api b/core/security/api/desktop/security.api new file mode 100644 index 00000000..8f1c6300 --- /dev/null +++ b/core/security/api/desktop/security.api @@ -0,0 +1,1063 @@ +public final class com/pasich/encly/core/backup/BackupCipher { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupCipher; + public final fun inspect ([B)Lcom/pasich/encly/core/backup/BackupHeader; + public final fun open ([BLcom/pasich/encly/core/backup/BackupSecret;)[B + public final fun seal ([BLcom/pasich/encly/core/backup/BackupSecret;)[B +} + +public final class com/pasich/encly/core/backup/BackupError : java/lang/Enum { + public static final field CORRUPTED Lcom/pasich/encly/core/backup/BackupError; + public static final field INVALID_PAYLOAD Lcom/pasich/encly/core/backup/BackupError; + public static final field INVALID_PHRASE Lcom/pasich/encly/core/backup/BackupError; + public static final field IO Lcom/pasich/encly/core/backup/BackupError; + public static final field NOT_A_BACKUP Lcom/pasich/encly/core/backup/BackupError; + public static final field TARGET_EXISTS Lcom/pasich/encly/core/backup/BackupError; + public static final field TOO_LARGE Lcom/pasich/encly/core/backup/BackupError; + public static final field TRUNCATED Lcom/pasich/encly/core/backup/BackupError; + public static final field UNSUPPORTED_VERSION Lcom/pasich/encly/core/backup/BackupError; + public static final field VAULT_UNAVAILABLE Lcom/pasich/encly/core/backup/BackupError; + public static final field WRONG_SECRET Lcom/pasich/encly/core/backup/BackupError; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/backup/BackupError; + public static fun values ()[Lcom/pasich/encly/core/backup/BackupError; +} + +public final class com/pasich/encly/core/backup/BackupException : java/lang/Exception { + public fun (Lcom/pasich/encly/core/backup/BackupError;Ljava/lang/Throwable;)V + public synthetic fun (Lcom/pasich/encly/core/backup/BackupError;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getError ()Lcom/pasich/encly/core/backup/BackupError; +} + +public final class com/pasich/encly/core/backup/BackupFlags { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupFlags; + public static final field LAST_EXPORT_KEY Ljava/lang/String; +} + +public final class com/pasich/encly/core/backup/BackupFormat { + public static final field FILE_EXTENSION Ljava/lang/String; + public static final field HEADER_LENGTH I + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupFormat; + public static final field MAX_FILE_BYTES I + public static final field MIME_TYPE Ljava/lang/String; + public static final field NONCE_LENGTH I + public static final field SALT_LENGTH I + public static final field TAG_LENGTH I + public static final field VERSION I + public final fun encodeHeader (Lcom/pasich/encly/core/backup/BackupKdf;[B[BI)[B + public final fun fileSizeFor (I)J + public final fun getMAGIC ()[B + public final fun parse ([B)Lcom/pasich/encly/core/backup/ParsedBackup; + public final fun parseHeader ([BJ)Lcom/pasich/encly/core/backup/BackupHeader; + public static synthetic fun parseHeader$default (Lcom/pasich/encly/core/backup/BackupFormat;[BJILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupHeader; +} + +public final class com/pasich/encly/core/backup/BackupHeader { + public fun (ILcom/pasich/encly/core/backup/BackupKdf;[B[BI)V + public final fun getCiphertextLength ()I + public final fun getFormatVersion ()I + public final fun getKdf ()Lcom/pasich/encly/core/backup/BackupKdf; + public final fun getNonce ()[B + public final fun getSalt ()[B +} + +public final class com/pasich/encly/core/backup/BackupKdf : java/lang/Enum { + public static final field Companion Lcom/pasich/encly/core/backup/BackupKdf$Companion; + public static final field RECOVERY_PHRASE_HKDF_SHA256 Lcom/pasich/encly/core/backup/BackupKdf; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getId ()I + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/backup/BackupKdf; + public static fun values ()[Lcom/pasich/encly/core/backup/BackupKdf; +} + +public final class com/pasich/encly/core/backup/BackupKdf$Companion { + public final fun fromId (I)Lcom/pasich/encly/core/backup/BackupKdf; +} + +public final class com/pasich/encly/core/backup/BackupKeys { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupKeys; + public static final field KEY_LENGTH I + public final fun fileKeyFromRoot ([B[B)[B + public final fun normalizeMnemonic ([C)[C + public final fun rootFromMnemonic ([C)[B +} + +public final class com/pasich/encly/core/backup/BackupNote { + public static final field Companion Lcom/pasich/encly/core/backup/BackupNote$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/util/List;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/util/List;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Z + public final fun component11 ()Ljava/lang/String; + public final fun component12 ()Ljava/lang/Integer; + public final fun component13 ()Ljava/lang/Integer; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()J + public final fun component7 ()Ljava/util/List; + public final fun component8 ()Z + public final fun component9 ()Z + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/util/List;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)Lcom/pasich/encly/core/backup/BackupNote; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupNote;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/util/List;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupNote; + public fun equals (Ljava/lang/Object;)Z + public final fun getColor ()Ljava/lang/String; + public final fun getDate ()J + public final fun getDateCreate ()J + public final fun getDescription ()Ljava/lang/String; + public final fun getLineSpacing ()Ljava/lang/Integer; + public final fun getTagUids ()Ljava/util/List; + public final fun getTextSize ()Ljava/lang/Integer; + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public final fun isArchived ()Z + public final fun isPinned ()Z + public final fun isTrash ()Z + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupNote$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupNote$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupNote; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupNote;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupNote$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupPayload { + public static final field Companion Lcom/pasich/encly/core/backup/BackupPayload$Companion; + public static final field SCHEMA_V1 I + public static final field SCHEMA_V2 I + public static final field SCHEMA_V3 I + public static final field SCHEMA_V4 I + public static final field SCHEMA_V5 I + public static final field SCHEMA_VERSION I + public fun (IJLjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/Map;)V + public synthetic fun (IJLjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()J + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Ljava/util/List; + public final fun component5 ()Ljava/util/List; + public final fun component6 ()Ljava/util/Map; + public final fun copy (IJLjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/Map;)Lcom/pasich/encly/core/backup/BackupPayload; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupPayload;IJLjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupPayload; + public fun equals (Ljava/lang/Object;)Z + public final fun getExportedAt ()J + public final fun getNotes ()Ljava/util/List; + public final fun getSchema ()I + public final fun getSettings ()Ljava/util/Map; + public final fun getTags ()Ljava/util/List; + public final fun getTasks ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupPayload$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupPayload$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupPayload; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupPayload;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupPayload$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupPayloadCodec { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupPayloadCodec; + public static final field MAX_COLOR_LENGTH I + public static final field MAX_UID_LENGTH I + public final fun decode ([B)Lcom/pasich/encly/core/backup/BackupPayload; + public final fun encode (Lcom/pasich/encly/core/backup/BackupPayload;)[B + public final fun validate (Lcom/pasich/encly/core/backup/BackupPayload;)V +} + +public abstract class com/pasich/encly/core/backup/BackupSecret { +} + +public final class com/pasich/encly/core/backup/BackupSecret$RecoveryPhrase : com/pasich/encly/core/backup/BackupSecret { + public fun ([C)V + public final fun getWords ()[C +} + +public final class com/pasich/encly/core/backup/BackupSecret$RecoveryRoot : com/pasich/encly/core/backup/BackupSecret { + public fun ([B)V + public final fun getRoot ()[B +} + +public final class com/pasich/encly/core/backup/BackupSettings { + public static final field APP_LANGUAGE Ljava/lang/String; + public static final field AUTO_LOCK_DELAY Ljava/lang/String; + public static final field EDITOR_TOOLBAR Ljava/lang/String; + public static final field FONT_SIZE Ljava/lang/String; + public static final field FONT_STYLE Ljava/lang/String; + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupSettings; + public static final field IS_DYNAMIC_THEME Ljava/lang/String; + public static final field IS_GRID_NOTE_LIST Ljava/lang/String; + public static final field KEYBOARD_STRICT_PRIVACY Ljava/lang/String; + public static final field MAX_ENTRIES I + public static final field MAX_FONT_SIZE I + public static final field MIN_FONT_SIZE I + public static final field NOTE_LINE_SPACING Ljava/lang/String; + public static final field SHOW_NOTE_PREVIEWS Ljava/lang/String; + public static final field SHOW_TASKS Ljava/lang/String; + public static final field SIMPLE_EDIT Ljava/lang/String; + public static final field SORT Ljava/lang/String; + public static final field SWIPE_ENABLED Ljava/lang/String; + public static final field SWIPE_LEFT Ljava/lang/String; + public static final field SWIPE_RIGHT Ljava/lang/String; + public static final field THEME_PALETTE Ljava/lang/String; + public static final field THEME_TYPE Ljava/lang/String; + public final fun accepted (Ljava/util/Map;)Ljava/util/Map; + public final fun getExcluded ()Ljava/util/Map; + public final fun getFONT_STYLES ()Ljava/util/List; + public final fun getIncluded ()Ljava/util/Map; + public final fun getLANGUAGES ()Ljava/util/List; + public final fun getLINE_SPACINGS ()Ljava/util/List; + public final fun getSORT_OPTIONS ()Ljava/util/List; + public final fun getSWIPE_ACTIONS ()Ljava/util/List; + public final fun getTHEME_PALETTES ()Ljava/util/List; + public final fun getTHEME_TYPES ()Ljava/util/List; + public final fun getTOOLBAR_SECTIONS ()Ljava/util/List; + public final fun getTOOLBAR_TOOLS ()Ljava/util/List; +} + +public final class com/pasich/encly/core/backup/BackupSettings$SettingKind { + public final fun accepts (Ljava/lang/String;)Z +} + +public final class com/pasich/encly/core/backup/BackupSubtask { + public static final field Companion Lcom/pasich/encly/core/backup/BackupSubtask$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;ZI)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun component4 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;ZI)Lcom/pasich/encly/core/backup/BackupSubtask; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupSubtask;Ljava/lang/String;Ljava/lang/String;ZIILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupSubtask; + public fun equals (Ljava/lang/Object;)Z + public final fun getPosition ()I + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupSubtask$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupSubtask$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupSubtask; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupSubtask;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupSubtask$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupTag { + public static final field Companion Lcom/pasich/encly/core/backup/BackupTag$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;ZI)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun component4 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;ZI)Lcom/pasich/encly/core/backup/BackupTag; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupTag;Ljava/lang/String;Ljava/lang/String;ZIILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupTag; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getPosition ()I + public final fun getUid ()Ljava/lang/String; + public final fun getVisible ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupTag$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupTag$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupTag; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupTag;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupTag$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupTask { + public static final field Companion Lcom/pasich/encly/core/backup/BackupTask$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/String;ILjava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/List; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()J + public final fun component6 ()Ljava/lang/Long; + public final fun component7 ()I + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/String;ILjava/util/List;)Lcom/pasich/encly/core/backup/BackupTask; + public static synthetic fun copy$default (Lcom/pasich/encly/core/backup/BackupTask;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJLjava/lang/Long;ILjava/lang/String;ILjava/util/List;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupTask; + public fun equals (Ljava/lang/Object;)Z + public final fun getCategoryTagUid ()Ljava/lang/String; + public final fun getCompletedDate ()Ljava/lang/Long; + public final fun getCreatedDate ()J + public final fun getDescription ()Ljava/lang/String; + public final fun getPosition ()I + public final fun getPriority ()I + public final fun getSubtasks ()Ljava/util/List; + public final fun getTitle ()Ljava/lang/String; + public final fun getUid ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/pasich/encly/core/backup/BackupTask$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/pasich/encly/core/backup/BackupTask$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pasich/encly/core/backup/BackupTask; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pasich/encly/core/backup/BackupTask;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/BackupTask$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/pasich/encly/core/backup/ParsedBackup { + public fun (Lcom/pasich/encly/core/backup/BackupHeader;[B)V + public final fun getCiphertextOffset ()I + public final fun getFile ()[B + public final fun getHeader ()Lcom/pasich/encly/core/backup/BackupHeader; +} + +public final class com/pasich/encly/core/security/AuthSettings { + public fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZ)V + public synthetic fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/pasich/encly/core/security/AuthType; + public final fun component2 ()Z + public final fun component3 ()Z + public final fun component4 ()Z + public final fun component5 ()Z + public final fun component6 ()Z + public final fun component7 ()Z + public final fun copy (Lcom/pasich/encly/core/security/AuthType;ZZZZZZ)Lcom/pasich/encly/core/security/AuthSettings; + public static synthetic fun copy$default (Lcom/pasich/encly/core/security/AuthSettings;Lcom/pasich/encly/core/security/AuthType;ZZZZZZILjava/lang/Object;)Lcom/pasich/encly/core/security/AuthSettings; + public fun equals (Ljava/lang/Object;)Z + public final fun getAuthType ()Lcom/pasich/encly/core/security/AuthType; + public final fun getDuressPinTurnedOff ()Z + public final fun getHasDuressPin ()Z + public final fun getHasPinSlot ()Z + public final fun getWipePinTurnedOff ()Z + public fun hashCode ()I + public final fun isBiometricEnabled ()Z + public final fun isUserCreatedSeedKey ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/AuthStrategy : java/lang/Enum { + public static final field NONE Lcom/pasich/encly/core/security/AuthStrategy; + public static final field PIN Lcom/pasich/encly/core/security/AuthStrategy; + public static final field PIN_BIOMETRIC Lcom/pasich/encly/core/security/AuthStrategy; + public static final field RECOVERY_DATA Lcom/pasich/encly/core/security/AuthStrategy; + public static final field SEED_PHRASE Lcom/pasich/encly/core/security/AuthStrategy; + public static final field SEED_PHRASE_BIOMETRIC Lcom/pasich/encly/core/security/AuthStrategy; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/AuthStrategy; + public static fun values ()[Lcom/pasich/encly/core/security/AuthStrategy; +} + +public final class com/pasich/encly/core/security/AuthType : java/lang/Enum { + public static final field NONE Lcom/pasich/encly/core/security/AuthType; + public static final field PIN Lcom/pasich/encly/core/security/AuthType; + public static final field SEED_PHRASE Lcom/pasich/encly/core/security/AuthType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/AuthType; + public static fun values ()[Lcom/pasich/encly/core/security/AuthType; +} + +public final class com/pasich/encly/core/security/AuthenticationManager { + public static final field Companion Lcom/pasich/encly/core/security/AuthenticationManager$Companion; + public static final field FIRST_LOCKOUT_MS J + public static final field MAX_ATTEMPTS I + public static final field MAX_LOCKOUT_MS J + public static final field STRIKE_GRACE I + public fun (Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/PinHardwareFactor;Lcom/pasich/encly/core/security/LockoutClock;)V + public final fun changePin ([C[B)Lcom/pasich/encly/core/security/PinChange; + public final fun clearPendingWipe ()Z + public final fun configureDuressPin ([C[BLkotlin/jvm/functions/Function0;)Lcom/pasich/encly/core/security/DuressPinChange; + public final fun configurePin ([C[B)Z + public final fun configureWipePin ([C)Lcom/pasich/encly/core/security/WipePinChange; + public final fun deactivateBiometricAuth ()V + public final fun deleteRetiredPinKey ()V + public final fun deleteStrayPinKey ()V + public final fun duressPinTurnedOff ([B)Z + public final fun ensureWipeSlot ()Z + public final fun getAuthType ()Lcom/pasich/encly/core/security/AuthType; + public final fun hasDecoyVault ([B)Z + public final fun hasPinSlot ()Z + public final fun isAuthStrategy ()Lcom/pasich/encly/core/security/AuthStrategy; + public final fun isBiometricEnabled ()Z + public final fun markBiometricEnabled (Z)V + public final fun markWipeDatabaseCreated ()Z + public final fun pendingWipe ()Lcom/pasich/encly/core/security/WipeStage; + public final fun recoveryFollowUp ()Lcom/pasich/encly/core/security/RecoveryFollowUp; + public final fun remainingLockoutMillis ()J + public final fun removeDuressPin ()Z + public final fun removeWipePin ()Z + public final fun unlockWithPin ([C)Lcom/pasich/encly/core/security/PinUnlock; + public final fun verifyPinAuth ([CZ)Z + public static synthetic fun verifyPinAuth$default (Lcom/pasich/encly/core/security/AuthenticationManager;[CZILjava/lang/Object;)Z + public final fun wipe ()V + public final fun wipePinTurnedOff ()Z +} + +public final class com/pasich/encly/core/security/AuthenticationManager$Companion { + public final fun penaltyFor (II)J + public static synthetic fun penaltyFor$default (Lcom/pasich/encly/core/security/AuthenticationManager$Companion;IIILjava/lang/Object;)J +} + +public final class com/pasich/encly/core/security/AuthenticationManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/AuthenticationManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/AuthenticationManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/AuthenticationManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/PinHardwareFactor;Lcom/pasich/encly/core/security/LockoutClock;)Lcom/pasich/encly/core/security/AuthenticationManager; +} + +public final class com/pasich/encly/core/security/AuthenticationManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/AuthenticationManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/PinHardwareFactor;Lcom/pasich/encly/core/security/LockoutClock;)Lcom/pasich/encly/core/security/AuthenticationManager; +} + +public final class com/pasich/encly/core/security/AuthenticationManagerKt { + public static final field MAX_PIN_LENGTH I + public static final field MIN_PIN_LENGTH I +} + +public final class com/pasich/encly/core/security/AutoLock : com/pasich/encly/core/security/AutoLockPolicy { + public fun (Lcom/pasich/encly/core/platform/KeyValueStore;)V + public final fun getDelay ()Lkotlinx/coroutines/flow/StateFlow; + public fun getDelayMillis ()J + public final fun setDelay (Lcom/pasich/encly/core/security/AutoLockDelay;)V +} + +public final class com/pasich/encly/core/security/AutoLock$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/AutoLock$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/AutoLock$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/AutoLock; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;)Lcom/pasich/encly/core/security/AutoLock; +} + +public final class com/pasich/encly/core/security/AutoLock$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/AutoLock$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;)Lcom/pasich/encly/core/security/AutoLock; +} + +public final class com/pasich/encly/core/security/AutoLockDelay : java/lang/Enum { + public static final field Companion Lcom/pasich/encly/core/security/AutoLockDelay$Companion; + public static final field IMMEDIATELY Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_10 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_15 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_2 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_30 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTES_5 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field MINUTE_1 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field SECONDS_15 Lcom/pasich/encly/core/security/AutoLockDelay; + public static final field SECONDS_30 Lcom/pasich/encly/core/security/AutoLockDelay; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getMillis ()J + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/AutoLockDelay; + public static fun values ()[Lcom/pasich/encly/core/security/AutoLockDelay; +} + +public final class com/pasich/encly/core/security/AutoLockDelay$Companion { + public final fun getDEFAULT ()Lcom/pasich/encly/core/security/AutoLockDelay; +} + +public abstract interface class com/pasich/encly/core/security/AutoLockPolicy { + public abstract fun getDelayMillis ()J +} + +public final class com/pasich/encly/core/security/AutoLockPolicy$Immediately : com/pasich/encly/core/security/AutoLockPolicy { + public static final field INSTANCE Lcom/pasich/encly/core/security/AutoLockPolicy$Immediately; + public fun getDelayMillis ()J +} + +public abstract interface class com/pasich/encly/core/security/BiometricHost { +} + +public abstract interface class com/pasich/encly/core/security/BiometricManager { + public static final field Companion Lcom/pasich/encly/core/security/BiometricManager$Companion; + public static final field SLOT_PREFIX Ljava/lang/String; + public abstract fun authenticate (Lcom/pasich/encly/core/security/BiometricHost;Lcom/pasich/encly/core/security/BiometricManager$BiometricType;Lcom/pasich/encly/core/security/BiometricManager$BiometricCallback;Lcom/pasich/encly/core/security/BiometricManager$PromptConfig;)V + public static synthetic fun authenticate$default (Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/BiometricHost;Lcom/pasich/encly/core/security/BiometricManager$BiometricType;Lcom/pasich/encly/core/security/BiometricManager$BiometricCallback;Lcom/pasich/encly/core/security/BiometricManager$PromptConfig;ILjava/lang/Object;)V + public abstract fun disable ()V + public abstract fun enroll (Lcom/pasich/encly/core/security/BiometricHost;[BLkotlin/jvm/functions/Function1;)V + public abstract fun hasSlot ()Z + public abstract fun isStrongBiometricAvailable ()Z + public abstract fun strongBiometricStatus ()Lcom/pasich/encly/core/security/BiometricStatus; + public abstract fun unlock (Lcom/pasich/encly/core/security/BiometricHost;Lkotlin/jvm/functions/Function1;)V +} + +public abstract interface class com/pasich/encly/core/security/BiometricManager$BiometricCallback { + public fun onCancelled ()V + public abstract fun onError (ILjava/lang/String;)V + public abstract fun onFailed ()V + public abstract fun onSuccess ()V +} + +public final class com/pasich/encly/core/security/BiometricManager$BiometricCallback$DefaultImpls { + public static fun onCancelled (Lcom/pasich/encly/core/security/BiometricManager$BiometricCallback;)V +} + +public final class com/pasich/encly/core/security/BiometricManager$BiometricType : java/lang/Enum { + public static final field APP_UNLOCK Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static final field GENERAL Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static final field MASTER_KEY_ACCESS Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static final field SETTINGS_TOGGLE Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/BiometricManager$BiometricType; + public static fun values ()[Lcom/pasich/encly/core/security/BiometricManager$BiometricType; +} + +public final class com/pasich/encly/core/security/BiometricManager$Companion { + public static final field SLOT_PREFIX Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/BiometricManager$DefaultImpls { + public static synthetic fun authenticate$default (Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/BiometricHost;Lcom/pasich/encly/core/security/BiometricManager$BiometricType;Lcom/pasich/encly/core/security/BiometricManager$BiometricCallback;Lcom/pasich/encly/core/security/BiometricManager$PromptConfig;ILjava/lang/Object;)V +} + +public final class com/pasich/encly/core/security/BiometricManager$PromptConfig { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/pasich/encly/core/security/BiometricManager$PromptConfig; + public static synthetic fun copy$default (Lcom/pasich/encly/core/security/BiometricManager$PromptConfig;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/core/security/BiometricManager$PromptConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getDescription ()Ljava/lang/String; + public final fun getNegativeButtonText ()Ljava/lang/String; + public final fun getSubtitle ()Ljava/lang/String; + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/BiometricStatus : java/lang/Enum { + public static final field AVAILABLE Lcom/pasich/encly/core/security/BiometricStatus; + public static final field Companion Lcom/pasich/encly/core/security/BiometricStatus$Companion; + public static final field NOT_ENROLLED Lcom/pasich/encly/core/security/BiometricStatus; + public static final field UNAVAILABLE Lcom/pasich/encly/core/security/BiometricStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/BiometricStatus; + public static fun values ()[Lcom/pasich/encly/core/security/BiometricStatus; +} + +public final class com/pasich/encly/core/security/BiometricStatus$Companion { +} + +public abstract interface class com/pasich/encly/core/security/DeviceLockWatcher { + public abstract fun isDeviceLocked ()Z + public abstract fun watchScreenOff (Lkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/DeviceLockWatcher$None : com/pasich/encly/core/security/DeviceLockWatcher { + public static final field INSTANCE Lcom/pasich/encly/core/security/DeviceLockWatcher$None; + public fun isDeviceLocked ()Z + public fun watchScreenOff (Lkotlin/jvm/functions/Function0;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/DuressPinChange : java/lang/Enum { + public static final field FAILED Lcom/pasich/encly/core/security/DuressPinChange; + public static final field IN_USE Lcom/pasich/encly/core/security/DuressPinChange; + public static final field SAME_AS_PIN Lcom/pasich/encly/core/security/DuressPinChange; + public static final field SET Lcom/pasich/encly/core/security/DuressPinChange; + public static final field SET_NEW_VAULT Lcom/pasich/encly/core/security/DuressPinChange; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/DuressPinChange; + public static fun values ()[Lcom/pasich/encly/core/security/DuressPinChange; +} + +public abstract interface class com/pasich/encly/core/security/HandoffStagingSweeper { + public abstract fun sweep ()V +} + +public final class com/pasich/encly/core/security/InitialStatus : java/lang/Enum { + public static final field AUTH Lcom/pasich/encly/core/security/InitialStatus; + public static final field LEGACY_VAULT Lcom/pasich/encly/core/security/InitialStatus; + public static final field LOSS_CRYPTO Lcom/pasich/encly/core/security/InitialStatus; + public static final field LOSS_DATABASE Lcom/pasich/encly/core/security/InitialStatus; + public static final field MAIN Lcom/pasich/encly/core/security/InitialStatus; + public static final field NO Lcom/pasich/encly/core/security/InitialStatus; + public static final field ONBOARDING Lcom/pasich/encly/core/security/InitialStatus; + public static final field SETUP_AUTH Lcom/pasich/encly/core/security/InitialStatus; + public static final field STORAGE_UNREADABLE Lcom/pasich/encly/core/security/InitialStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/InitialStatus; + public static fun values ()[Lcom/pasich/encly/core/security/InitialStatus; +} + +public final class com/pasich/encly/core/security/JvmMonotonicClock : com/pasich/encly/core/security/LockoutClock { + public static final field INSTANCE Lcom/pasich/encly/core/security/JvmMonotonicClock; + public fun bootCount ()I + public fun elapsedRealtime ()J +} + +public final class com/pasich/encly/core/security/JvmVaultFileIo : com/pasich/encly/core/security/VaultFileIo { + public fun (Ljava/io/File;)V + public fun delete ()Z + public fun deleteTemp ()V + public fun exists ()Z + public fun read ()[B + public fun replace ([BLkotlin/jvm/functions/Function0;)V +} + +public final class com/pasich/encly/core/security/JvmVaultFileIoKt { + public static final fun VaultStore (Ljava/io/File;)Lcom/pasich/encly/core/security/VaultStore; +} + +public final class com/pasich/encly/core/security/KeyboardPrivacy { + public fun (Lcom/pasich/encly/core/platform/KeyValueStore;)V + public final fun getStrict ()Lkotlinx/coroutines/flow/StateFlow; + public final fun setStrict (Z)V +} + +public final class com/pasich/encly/core/security/KeyboardPrivacy$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/KeyboardPrivacy$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/KeyboardPrivacy$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/KeyboardPrivacy; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;)Lcom/pasich/encly/core/security/KeyboardPrivacy; +} + +public final class com/pasich/encly/core/security/KeyboardPrivacy$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/KeyboardPrivacy$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;)Lcom/pasich/encly/core/security/KeyboardPrivacy; +} + +public abstract interface class com/pasich/encly/core/security/LockoutClock { + public abstract fun bootCount ()I + public abstract fun elapsedRealtime ()J +} + +public final class com/pasich/encly/core/security/Mnemonic { + public static final field INSTANCE Lcom/pasich/encly/core/security/Mnemonic; + public final fun generate ()[C +} + +public final class com/pasich/encly/core/security/NeverLocked : com/pasich/encly/core/security/VaultLockEvents { + public static final field INSTANCE Lcom/pasich/encly/core/security/NeverLocked; + public fun getLocked ()Lkotlinx/coroutines/flow/StateFlow; + public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/PinChange : java/lang/Enum { + public static final field FAILED Lcom/pasich/encly/core/security/PinChange; + public static final field IN_USE Lcom/pasich/encly/core/security/PinChange; + public static final field SET Lcom/pasich/encly/core/security/PinChange; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/PinChange; + public static fun values ()[Lcom/pasich/encly/core/security/PinChange; +} + +public final class com/pasich/encly/core/security/PinFactorException : java/lang/Exception { + public fun (ZLjava/lang/Throwable;)V + public synthetic fun (ZLjava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getLost ()Z +} + +public abstract interface class com/pasich/encly/core/security/PinHardwareFactor { + public abstract fun delete (Lcom/pasich/encly/core/security/PinKeySlot;)V + public abstract fun ensureKey (Lcom/pasich/encly/core/security/PinKeySlot;)Z + public abstract fun mac (Lcom/pasich/encly/core/security/PinKeySlot;[B)[B + public abstract fun reset (Lcom/pasich/encly/core/security/PinKeySlot;)V +} + +public final class com/pasich/encly/core/security/PinKeySlot : java/lang/Enum { + public static final field A Lcom/pasich/encly/core/security/PinKeySlot; + public static final field B Lcom/pasich/encly/core/security/PinKeySlot; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getOther ()Lcom/pasich/encly/core/security/PinKeySlot; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/PinKeySlot; + public static fun values ()[Lcom/pasich/encly/core/security/PinKeySlot; +} + +public abstract interface class com/pasich/encly/core/security/PinUnlock { +} + +public final class com/pasich/encly/core/security/PinUnlock$Decoy : com/pasich/encly/core/security/PinUnlock { + public fun ([B)V + public final fun getDek ()[B +} + +public final class com/pasich/encly/core/security/PinUnlock$Erased : com/pasich/encly/core/security/PinUnlock { + public fun ([B)V + public final fun getDek ()[B +} + +public final class com/pasich/encly/core/security/PinUnlock$Failed : com/pasich/encly/core/security/PinUnlock { + public static final field INSTANCE Lcom/pasich/encly/core/security/PinUnlock$Failed; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/PinUnlock$KeyLost : com/pasich/encly/core/security/PinUnlock { + public static final field INSTANCE Lcom/pasich/encly/core/security/PinUnlock$KeyLost; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/PinUnlock$LockedOut : com/pasich/encly/core/security/PinUnlock { + public static final field INSTANCE Lcom/pasich/encly/core/security/PinUnlock$LockedOut; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/PinUnlock$Success : com/pasich/encly/core/security/PinUnlock { + public fun ([B)V + public final fun getDek ()[B +} + +public final class com/pasich/encly/core/security/PinUnlock$WrongPin : com/pasich/encly/core/security/PinUnlock { + public static final field INSTANCE Lcom/pasich/encly/core/security/PinUnlock$WrongPin; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/core/security/RecoveryFollowUp : java/lang/Enum { + public static final field KEEP_OR_CHANGE Lcom/pasich/encly/core/security/RecoveryFollowUp; + public static final field MUST_SET_PIN Lcom/pasich/encly/core/security/RecoveryFollowUp; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/RecoveryFollowUp; + public static fun values ()[Lcom/pasich/encly/core/security/RecoveryFollowUp; +} + +public final class com/pasich/encly/core/security/RecoveryStage : java/lang/Enum { + public static final field CHOOSE Lcom/pasich/encly/core/security/RecoveryStage; + public static final field MUST_SET_PIN Lcom/pasich/encly/core/security/RecoveryStage; + public static final field NONE Lcom/pasich/encly/core/security/RecoveryStage; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/RecoveryStage; + public static fun values ()[Lcom/pasich/encly/core/security/RecoveryStage; +} + +public final class com/pasich/encly/core/security/RecoveryWords { + public static final field COUNT I + public static final field INSTANCE Lcom/pasich/encly/core/security/RecoveryWords; + public final fun isPrefix (Ljava/lang/String;)Z + public final fun isValidPhrase ([C)Z + public final fun isWord (Ljava/lang/String;)Z +} + +public final class com/pasich/encly/core/security/ScreenshotAllowance { + public fun ()V + public final fun allow ()V + public final fun getAllowed ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getCapturable ()Z + public final fun observe (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; + public final fun protect ()Lkotlin/jvm/functions/Function0; + public final fun revoke ()V +} + +public final class com/pasich/encly/core/security/ScreenshotAllowance$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/ScreenshotAllowance$MetroFactory$Companion; + public static final fun create ()Lcom/pasich/encly/core/security/ScreenshotAllowance$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/ScreenshotAllowance; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance ()Lcom/pasich/encly/core/security/ScreenshotAllowance; +} + +public final class com/pasich/encly/core/security/ScreenshotAllowance$MetroFactory$Companion { + public final fun create ()Lcom/pasich/encly/core/security/ScreenshotAllowance$MetroFactory; + public final fun newInstance ()Lcom/pasich/encly/core/security/ScreenshotAllowance; +} + +public final class com/pasich/encly/core/security/SecurityManager { + public static final field Companion Lcom/pasich/encly/core/security/SecurityManager$Companion; + public fun (Lcom/pasich/encly/core/platform/KeyValueStore;Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/SeedPhraseManager;Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/AuthenticationManager;Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/HandoffStagingSweeper;)V + public synthetic fun (Lcom/pasich/encly/core/platform/KeyValueStore;Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/SeedPhraseManager;Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/AuthenticationManager;Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/HandoffStagingSweeper;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addRecoverySeed ([C)Z + public final fun authStrategy ()Lcom/pasich/encly/core/security/AuthStrategy; + public final fun biometricAvailable ()Z + public final fun biometricStatus ()Lcom/pasich/encly/core/security/BiometricStatus; + public final fun canReauthWithBiometric ()Z + public final fun canResetPinWithoutCurrent ()Z + public final fun changePin ([C)Lcom/pasich/encly/core/security/PinChange; + public final fun commitInitialSetup ()Z + public final fun completePendingWipe ()V + public final fun configureDuressPin ([C)Lcom/pasich/encly/core/security/DuressPinChange; + public final fun configurePin ([C)Z + public final fun configureWipePin ([C)Lcom/pasich/encly/core/security/WipePinChange; + public final fun confirmBiometric (Lcom/pasich/encly/core/security/BiometricHost;Lkotlin/jvm/functions/Function1;)V + public final fun copyBackupRootKey ()[B + public final fun createBackupKey ([C)Z + public final fun disableBiometric ()V + public final fun duressPinTurnedOff ()Z + public final fun enrollBiometric (Lcom/pasich/encly/core/security/BiometricHost;Lkotlin/jvm/functions/Function1;)V + public final fun eraseEpoch ()J + public final fun finishInitialSetup ()Z + public final fun generateMnemonicCode ()[C + public final fun getSecurityStatus ()Lcom/pasich/encly/core/security/InitialStatus; + public final fun getSettingsAuth ()Lcom/pasich/encly/core/security/AuthSettings; + public final fun hasBackupKey ()Z + public final fun hasDuressPin ()Z + public final fun hasRecoverySeed ()Z + public final fun initializeNewVault ([C)Z + public final fun isBiometricEnabled ()Z + public final fun isDatabaseUnlocked ()Z + public final fun isDecoySession ()Z + public final fun isLockable ()Z + public final fun isOnboardingShow ()Z + public final fun isValidRecoveryPhrase ([C)Z + public final fun keepUnlockMethodsAfterRecovery ()Z + public final fun lock ()V + public final fun openInitialVault ()Z + public final fun pinLockoutRemainingMillis ()J + public final fun recoveryStage ()Lcom/pasich/encly/core/security/RecoveryStage; + public final fun removeDuressPin ()Z + public final fun removeWipePin ()Z + public final fun replaceRecoverySeed ([C)Z + public final fun requestBiometricKey (Lcom/pasich/encly/core/security/BiometricHost;Lkotlin/jvm/functions/Function1;)V + public final fun resolveInitialStatus ()Lcom/pasich/encly/core/security/InitialStatus; + public final fun setOnboardingShown ()Z + public final fun setSecurityStatus (Lcom/pasich/encly/core/security/InitialStatus;)V + public final fun unlockWithPin ([C)Lcom/pasich/encly/core/security/VaultUnlockResult; + public final fun unlockWithRawKey ([BZ)Z + public static synthetic fun unlockWithRawKey$default (Lcom/pasich/encly/core/security/SecurityManager;[BZILjava/lang/Object;)Z + public final fun unlockWithSeed ([C)Lcom/pasich/encly/core/security/VaultUnlockResult; + public final fun verifyPin ([C)Z + public final fun verifySeed ([C)Z + public final fun wipeAndReset ()Z +} + +public final class com/pasich/encly/core/security/SecurityManager$Companion { +} + +public final class com/pasich/encly/core/security/SecurityManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/SecurityManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SecurityManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/SecurityManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/SeedPhraseManager;Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/AuthenticationManager;Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/HandoffStagingSweeper;)Lcom/pasich/encly/core/security/SecurityManager; +} + +public final class com/pasich/encly/core/security/SecurityManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SecurityManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/platform/KeyValueStore;Lcom/pasich/encly/core/security/VaultStore;Lcom/pasich/encly/core/security/SeedPhraseManager;Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/AuthenticationManager;Lcom/pasich/encly/core/security/BiometricManager;Lcom/pasich/encly/core/security/HandoffStagingSweeper;)Lcom/pasich/encly/core/security/SecurityManager; +} + +public final class com/pasich/encly/core/security/SeedPhraseManager { + public static final field Companion Lcom/pasich/encly/core/security/SeedPhraseManager$Companion; + public fun (Lcom/pasich/encly/core/security/VaultStore;)V + public final fun addRecoverySeed ([C[B)Z + public final fun clearBootstrapKey ()V + public final fun copyBootstrapKey ()[B + public final fun createBackupKey ([C[B)Z + public final fun generateMnemonic ()[C + public final fun hasBackupKey ()Z + public final fun hasBootstrapKey ()Z + public final fun hasRecoverySeed ()Z + public final fun hasStoredSeed ()Z + public final fun initializeVault ([C)Z + public final fun isValidMnemonic ([C)Z + public final fun replaceRecoverySeed ([C[B)Z + public final fun unlockWithSeed ([C)[B + public final fun unwrapBackupRoot ([B)[B + public final fun verificationKeyData ()Z + public final fun verifyMnemonic ([C)Z + public final fun wipe ()V +} + +public final class com/pasich/encly/core/security/SeedPhraseManager$Companion { +} + +public final class com/pasich/encly/core/security/SeedPhraseManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/SeedPhraseManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SeedPhraseManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/SeedPhraseManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/security/VaultStore;)Lcom/pasich/encly/core/security/SeedPhraseManager; +} + +public final class com/pasich/encly/core/security/SeedPhraseManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SeedPhraseManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/security/VaultStore;)Lcom/pasich/encly/core/security/SeedPhraseManager; +} + +public final class com/pasich/encly/core/security/SessionLockManager : androidx/lifecycle/DefaultLifecycleObserver, com/pasich/encly/core/security/VaultLockEvents { + public fun (Lcom/pasich/encly/core/security/SecurityManager;Lcom/pasich/encly/core/security/DeviceLockWatcher;Lcom/pasich/encly/core/security/LockoutClock;Lcom/pasich/encly/core/security/AutoLockPolicy;Lcom/pasich/encly/core/platform/MainThreadTimer;Lcom/pasich/encly/core/security/ScreenshotAllowance;)V + public synthetic fun (Lcom/pasich/encly/core/security/SecurityManager;Lcom/pasich/encly/core/security/DeviceLockWatcher;Lcom/pasich/encly/core/security/LockoutClock;Lcom/pasich/encly/core/security/AutoLockPolicy;Lcom/pasich/encly/core/platform/MainThreadTimer;Lcom/pasich/encly/core/security/ScreenshotAllowance;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun allowSystemPicker ()V + public final fun endSystemPicker ()V + public fun getLocked ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getSessionGeneration ()J + public final fun isDeviceLocked ()Z + public final fun lockNow ()V + public final fun lockRequests ()J + public fun onCreate (Landroidx/lifecycle/LifecycleOwner;)V + public fun onDestroy (Landroidx/lifecycle/LifecycleOwner;)V + public fun onPause (Landroidx/lifecycle/LifecycleOwner;)V + public fun onResume (Landroidx/lifecycle/LifecycleOwner;)V + public fun onStart (Landroidx/lifecycle/LifecycleOwner;)V + public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V + public final fun onUnlockAbandoned ()V + public final fun onUnlocked (J)Z + public static synthetic fun onUnlocked$default (Lcom/pasich/encly/core/security/SessionLockManager;JILjava/lang/Object;)Z + public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; + public final fun requireUnlock ()V +} + +public final class com/pasich/encly/core/security/SessionLockManager$MetroFactory : dev/zacsweers/metro/internal/Factory { + public static final field Companion Lcom/pasich/encly/core/security/SessionLockManager$MetroFactory$Companion; + public synthetic fun (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SessionLockManager$MetroFactory; + public final fun invoke ()Lcom/pasich/encly/core/security/SessionLockManager; + public synthetic fun invoke ()Ljava/lang/Object; + public static final fun newInstance (Lcom/pasich/encly/core/security/SecurityManager;Lcom/pasich/encly/core/security/DeviceLockWatcher;Lcom/pasich/encly/core/security/LockoutClock;Lcom/pasich/encly/core/security/AutoLockPolicy;Lcom/pasich/encly/core/platform/MainThreadTimer;Lcom/pasich/encly/core/security/ScreenshotAllowance;)Lcom/pasich/encly/core/security/SessionLockManager; +} + +public final class com/pasich/encly/core/security/SessionLockManager$MetroFactory$Companion { + public final fun create (Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;Ldev/zacsweers/metro/Provider;)Lcom/pasich/encly/core/security/SessionLockManager$MetroFactory; + public final fun newInstance (Lcom/pasich/encly/core/security/SecurityManager;Lcom/pasich/encly/core/security/DeviceLockWatcher;Lcom/pasich/encly/core/security/LockoutClock;Lcom/pasich/encly/core/security/AutoLockPolicy;Lcom/pasich/encly/core/platform/MainThreadTimer;Lcom/pasich/encly/core/security/ScreenshotAllowance;)Lcom/pasich/encly/core/security/SessionLockManager; +} + +public final class com/pasich/encly/core/security/SessionLockManagerKt { + public static final field LOCK_FLUSH_TIMEOUT_MS J +} + +public abstract interface class com/pasich/encly/core/security/VaultDatabase { + public abstract fun hasEncryptedDatabase (Lcom/pasich/encly/core/security/VaultKind;)Z + public static synthetic fun hasEncryptedDatabase$default (Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/VaultKind;ILjava/lang/Object;)Z + public abstract fun isDatabaseUnlocked ()Z + public abstract fun reset ()V + public abstract fun unlockDatabase ([BZ)Z + public abstract fun unlockDatabase ([BZLcom/pasich/encly/core/security/VaultKind;)Z + public static synthetic fun unlockDatabase$default (Lcom/pasich/encly/core/security/VaultDatabase;[BZILjava/lang/Object;)Z + public abstract fun wipe ()V + public abstract fun wipeIfClosed (Lcom/pasich/encly/core/security/VaultKind;)Z + public static synthetic fun wipeIfClosed$default (Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/VaultKind;ILjava/lang/Object;)Z +} + +public final class com/pasich/encly/core/security/VaultDatabase$DefaultImpls { + public static synthetic fun hasEncryptedDatabase$default (Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/VaultKind;ILjava/lang/Object;)Z + public static synthetic fun unlockDatabase$default (Lcom/pasich/encly/core/security/VaultDatabase;[BZILjava/lang/Object;)Z + public static synthetic fun wipeIfClosed$default (Lcom/pasich/encly/core/security/VaultDatabase;Lcom/pasich/encly/core/security/VaultKind;ILjava/lang/Object;)Z +} + +public abstract interface class com/pasich/encly/core/security/VaultFileIo { + public abstract fun delete ()Z + public abstract fun deleteTemp ()V + public abstract fun exists ()Z + public abstract fun read ()[B + public abstract fun replace ([BLkotlin/jvm/functions/Function0;)V +} + +public final class com/pasich/encly/core/security/VaultKind : java/lang/Enum { + public static final field DECOY Lcom/pasich/encly/core/security/VaultKind; + public static final field PRIMARY Lcom/pasich/encly/core/security/VaultKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/VaultKind; + public static fun values ()[Lcom/pasich/encly/core/security/VaultKind; +} + +public abstract interface class com/pasich/encly/core/security/VaultLockEvents { + public abstract fun getLocked ()Lkotlinx/coroutines/flow/StateFlow; + public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/VaultLockEvents$DefaultImpls { + public static fun registerPreLockFlush (Lcom/pasich/encly/core/security/VaultLockEvents;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/core/security/VaultStore { + public fun (Lcom/pasich/encly/core/security/VaultFileIo;)V + public final fun clear ()Z + public final fun contains (Ljava/lang/String;)Z + public final fun edit (Lkotlin/jvm/functions/Function1;)Z + public final fun getBoolean (Ljava/lang/String;Z)Z + public final fun getBytes (Ljava/lang/String;)[B + public final fun getInt (Ljava/lang/String;I)I + public final fun getLong (Ljava/lang/String;J)J + public final fun isCorrupt ()Z + public final fun isUnreadable ()Z +} + +public final class com/pasich/encly/core/security/VaultStore$Editor { + public final fun putBoolean (Ljava/lang/String;Z)V + public final fun putBytes (Ljava/lang/String;[B)V + public final fun putInt (Ljava/lang/String;I)V + public final fun putLong (Ljava/lang/String;J)V + public final fun remove (Ljava/lang/String;)V + public final fun removePrefix (Ljava/lang/String;)V +} + +public final class com/pasich/encly/core/security/VaultUnlockResult : java/lang/Enum { + public static final field DB_ERROR Lcom/pasich/encly/core/security/VaultUnlockResult; + public static final field INVALID_CREDENTIAL Lcom/pasich/encly/core/security/VaultUnlockResult; + public static final field LOCKED_OUT Lcom/pasich/encly/core/security/VaultUnlockResult; + public static final field PIN_KEY_LOST Lcom/pasich/encly/core/security/VaultUnlockResult; + public static final field SUCCESS Lcom/pasich/encly/core/security/VaultUnlockResult; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/VaultUnlockResult; + public static fun values ()[Lcom/pasich/encly/core/security/VaultUnlockResult; +} + +public final class com/pasich/encly/core/security/WipePinChange : java/lang/Enum { + public static final field FAILED Lcom/pasich/encly/core/security/WipePinChange; + public static final field IN_USE Lcom/pasich/encly/core/security/WipePinChange; + public static final field SAME_AS_PIN Lcom/pasich/encly/core/security/WipePinChange; + public static final field SET Lcom/pasich/encly/core/security/WipePinChange; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/WipePinChange; + public static fun values ()[Lcom/pasich/encly/core/security/WipePinChange; +} + +public final class com/pasich/encly/core/security/WipeStage : java/lang/Enum { + public static final field CLEANUP Lcom/pasich/encly/core/security/WipeStage; + public static final field DATABASE Lcom/pasich/encly/core/security/WipeStage; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/WipeStage; + public static fun values ()[Lcom/pasich/encly/core/security/WipeStage; +} + diff --git a/core/security/api/security.klib.api b/core/security/api/security.klib.api new file mode 100644 index 00000000..c0edcae0 --- /dev/null +++ b/core/security/api/security.klib.api @@ -0,0 +1,1186 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final enum class com.pasich.encly.core.backup/BackupError : kotlin/Enum { // com.pasich.encly.core.backup/BackupError|null[0] + enum entry CORRUPTED // com.pasich.encly.core.backup/BackupError.CORRUPTED|null[0] + enum entry INVALID_PAYLOAD // com.pasich.encly.core.backup/BackupError.INVALID_PAYLOAD|null[0] + enum entry INVALID_PHRASE // com.pasich.encly.core.backup/BackupError.INVALID_PHRASE|null[0] + enum entry IO // com.pasich.encly.core.backup/BackupError.IO|null[0] + enum entry NOT_A_BACKUP // com.pasich.encly.core.backup/BackupError.NOT_A_BACKUP|null[0] + enum entry TARGET_EXISTS // com.pasich.encly.core.backup/BackupError.TARGET_EXISTS|null[0] + enum entry TOO_LARGE // com.pasich.encly.core.backup/BackupError.TOO_LARGE|null[0] + enum entry TRUNCATED // com.pasich.encly.core.backup/BackupError.TRUNCATED|null[0] + enum entry UNSUPPORTED_VERSION // com.pasich.encly.core.backup/BackupError.UNSUPPORTED_VERSION|null[0] + enum entry VAULT_UNAVAILABLE // com.pasich.encly.core.backup/BackupError.VAULT_UNAVAILABLE|null[0] + enum entry WRONG_SECRET // com.pasich.encly.core.backup/BackupError.WRONG_SECRET|null[0] + + final val entries // com.pasich.encly.core.backup/BackupError.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.backup/BackupError.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.backup/BackupError // com.pasich.encly.core.backup/BackupError.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.backup/BackupError.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.backup/BackupKdf : kotlin/Enum { // com.pasich.encly.core.backup/BackupKdf|null[0] + enum entry RECOVERY_PHRASE_HKDF_SHA256 // com.pasich.encly.core.backup/BackupKdf.RECOVERY_PHRASE_HKDF_SHA256|null[0] + + final val entries // com.pasich.encly.core.backup/BackupKdf.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.backup/BackupKdf.entries.|#static(){}[0] + final val id // com.pasich.encly.core.backup/BackupKdf.id|{}id[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupKdf.id.|(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.backup/BackupKdf // com.pasich.encly.core.backup/BackupKdf.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.backup/BackupKdf.values|values#static(){}[0] + + final object Companion { // com.pasich.encly.core.backup/BackupKdf.Companion|null[0] + final fun fromId(kotlin/Int): com.pasich.encly.core.backup/BackupKdf? // com.pasich.encly.core.backup/BackupKdf.Companion.fromId|fromId(kotlin.Int){}[0] + } +} + +final enum class com.pasich.encly.core.security/AuthStrategy : kotlin/Enum { // com.pasich.encly.core.security/AuthStrategy|null[0] + enum entry NONE // com.pasich.encly.core.security/AuthStrategy.NONE|null[0] + enum entry PIN // com.pasich.encly.core.security/AuthStrategy.PIN|null[0] + enum entry PIN_BIOMETRIC // com.pasich.encly.core.security/AuthStrategy.PIN_BIOMETRIC|null[0] + enum entry RECOVERY_DATA // com.pasich.encly.core.security/AuthStrategy.RECOVERY_DATA|null[0] + enum entry SEED_PHRASE // com.pasich.encly.core.security/AuthStrategy.SEED_PHRASE|null[0] + enum entry SEED_PHRASE_BIOMETRIC // com.pasich.encly.core.security/AuthStrategy.SEED_PHRASE_BIOMETRIC|null[0] + + final val entries // com.pasich.encly.core.security/AuthStrategy.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/AuthStrategy.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/AuthStrategy // com.pasich.encly.core.security/AuthStrategy.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/AuthStrategy.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/AuthType : kotlin/Enum { // com.pasich.encly.core.security/AuthType|null[0] + enum entry NONE // com.pasich.encly.core.security/AuthType.NONE|null[0] + enum entry PIN // com.pasich.encly.core.security/AuthType.PIN|null[0] + enum entry SEED_PHRASE // com.pasich.encly.core.security/AuthType.SEED_PHRASE|null[0] + + final val entries // com.pasich.encly.core.security/AuthType.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/AuthType.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/AuthType // com.pasich.encly.core.security/AuthType.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/AuthType.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/AutoLockDelay : kotlin/Enum { // com.pasich.encly.core.security/AutoLockDelay|null[0] + enum entry IMMEDIATELY // com.pasich.encly.core.security/AutoLockDelay.IMMEDIATELY|null[0] + enum entry MINUTES_10 // com.pasich.encly.core.security/AutoLockDelay.MINUTES_10|null[0] + enum entry MINUTES_15 // com.pasich.encly.core.security/AutoLockDelay.MINUTES_15|null[0] + enum entry MINUTES_2 // com.pasich.encly.core.security/AutoLockDelay.MINUTES_2|null[0] + enum entry MINUTES_30 // com.pasich.encly.core.security/AutoLockDelay.MINUTES_30|null[0] + enum entry MINUTES_5 // com.pasich.encly.core.security/AutoLockDelay.MINUTES_5|null[0] + enum entry MINUTE_1 // com.pasich.encly.core.security/AutoLockDelay.MINUTE_1|null[0] + enum entry SECONDS_15 // com.pasich.encly.core.security/AutoLockDelay.SECONDS_15|null[0] + enum entry SECONDS_30 // com.pasich.encly.core.security/AutoLockDelay.SECONDS_30|null[0] + + final val entries // com.pasich.encly.core.security/AutoLockDelay.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/AutoLockDelay.entries.|#static(){}[0] + final val millis // com.pasich.encly.core.security/AutoLockDelay.millis|{}millis[0] + final fun (): kotlin/Long // com.pasich.encly.core.security/AutoLockDelay.millis.|(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/AutoLockDelay // com.pasich.encly.core.security/AutoLockDelay.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/AutoLockDelay.values|values#static(){}[0] + + final object Companion { // com.pasich.encly.core.security/AutoLockDelay.Companion|null[0] + final val DEFAULT // com.pasich.encly.core.security/AutoLockDelay.Companion.DEFAULT|{}DEFAULT[0] + final fun (): com.pasich.encly.core.security/AutoLockDelay // com.pasich.encly.core.security/AutoLockDelay.Companion.DEFAULT.|(){}[0] + } +} + +final enum class com.pasich.encly.core.security/BiometricStatus : kotlin/Enum { // com.pasich.encly.core.security/BiometricStatus|null[0] + enum entry AVAILABLE // com.pasich.encly.core.security/BiometricStatus.AVAILABLE|null[0] + enum entry NOT_ENROLLED // com.pasich.encly.core.security/BiometricStatus.NOT_ENROLLED|null[0] + enum entry UNAVAILABLE // com.pasich.encly.core.security/BiometricStatus.UNAVAILABLE|null[0] + + final val entries // com.pasich.encly.core.security/BiometricStatus.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/BiometricStatus.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/BiometricStatus // com.pasich.encly.core.security/BiometricStatus.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/BiometricStatus.values|values#static(){}[0] + + final object Companion // com.pasich.encly.core.security/BiometricStatus.Companion|null[0] +} + +final enum class com.pasich.encly.core.security/DuressPinChange : kotlin/Enum { // com.pasich.encly.core.security/DuressPinChange|null[0] + enum entry FAILED // com.pasich.encly.core.security/DuressPinChange.FAILED|null[0] + enum entry IN_USE // com.pasich.encly.core.security/DuressPinChange.IN_USE|null[0] + enum entry SAME_AS_PIN // com.pasich.encly.core.security/DuressPinChange.SAME_AS_PIN|null[0] + enum entry SET // com.pasich.encly.core.security/DuressPinChange.SET|null[0] + enum entry SET_NEW_VAULT // com.pasich.encly.core.security/DuressPinChange.SET_NEW_VAULT|null[0] + + final val entries // com.pasich.encly.core.security/DuressPinChange.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/DuressPinChange.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/DuressPinChange // com.pasich.encly.core.security/DuressPinChange.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/DuressPinChange.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/InitialStatus : kotlin/Enum { // com.pasich.encly.core.security/InitialStatus|null[0] + enum entry AUTH // com.pasich.encly.core.security/InitialStatus.AUTH|null[0] + enum entry LEGACY_VAULT // com.pasich.encly.core.security/InitialStatus.LEGACY_VAULT|null[0] + enum entry LOSS_CRYPTO // com.pasich.encly.core.security/InitialStatus.LOSS_CRYPTO|null[0] + enum entry LOSS_DATABASE // com.pasich.encly.core.security/InitialStatus.LOSS_DATABASE|null[0] + enum entry MAIN // com.pasich.encly.core.security/InitialStatus.MAIN|null[0] + enum entry NO // com.pasich.encly.core.security/InitialStatus.NO|null[0] + enum entry ONBOARDING // com.pasich.encly.core.security/InitialStatus.ONBOARDING|null[0] + enum entry SETUP_AUTH // com.pasich.encly.core.security/InitialStatus.SETUP_AUTH|null[0] + enum entry STORAGE_UNREADABLE // com.pasich.encly.core.security/InitialStatus.STORAGE_UNREADABLE|null[0] + + final val entries // com.pasich.encly.core.security/InitialStatus.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/InitialStatus.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/InitialStatus // com.pasich.encly.core.security/InitialStatus.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/InitialStatus.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/PinChange : kotlin/Enum { // com.pasich.encly.core.security/PinChange|null[0] + enum entry FAILED // com.pasich.encly.core.security/PinChange.FAILED|null[0] + enum entry IN_USE // com.pasich.encly.core.security/PinChange.IN_USE|null[0] + enum entry SET // com.pasich.encly.core.security/PinChange.SET|null[0] + + final val entries // com.pasich.encly.core.security/PinChange.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/PinChange.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/PinChange // com.pasich.encly.core.security/PinChange.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/PinChange.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/PinKeySlot : kotlin/Enum { // com.pasich.encly.core.security/PinKeySlot|null[0] + enum entry A // com.pasich.encly.core.security/PinKeySlot.A|null[0] + enum entry B // com.pasich.encly.core.security/PinKeySlot.B|null[0] + + final val entries // com.pasich.encly.core.security/PinKeySlot.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/PinKeySlot.entries.|#static(){}[0] + final val other // com.pasich.encly.core.security/PinKeySlot.other|{}other[0] + final fun (): com.pasich.encly.core.security/PinKeySlot // com.pasich.encly.core.security/PinKeySlot.other.|(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/PinKeySlot // com.pasich.encly.core.security/PinKeySlot.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/PinKeySlot.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/RecoveryFollowUp : kotlin/Enum { // com.pasich.encly.core.security/RecoveryFollowUp|null[0] + enum entry KEEP_OR_CHANGE // com.pasich.encly.core.security/RecoveryFollowUp.KEEP_OR_CHANGE|null[0] + enum entry MUST_SET_PIN // com.pasich.encly.core.security/RecoveryFollowUp.MUST_SET_PIN|null[0] + + final val entries // com.pasich.encly.core.security/RecoveryFollowUp.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/RecoveryFollowUp.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/RecoveryFollowUp // com.pasich.encly.core.security/RecoveryFollowUp.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/RecoveryFollowUp.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/RecoveryStage : kotlin/Enum { // com.pasich.encly.core.security/RecoveryStage|null[0] + enum entry CHOOSE // com.pasich.encly.core.security/RecoveryStage.CHOOSE|null[0] + enum entry MUST_SET_PIN // com.pasich.encly.core.security/RecoveryStage.MUST_SET_PIN|null[0] + enum entry NONE // com.pasich.encly.core.security/RecoveryStage.NONE|null[0] + + final val entries // com.pasich.encly.core.security/RecoveryStage.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/RecoveryStage.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/RecoveryStage // com.pasich.encly.core.security/RecoveryStage.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/RecoveryStage.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/VaultKind : kotlin/Enum { // com.pasich.encly.core.security/VaultKind|null[0] + enum entry DECOY // com.pasich.encly.core.security/VaultKind.DECOY|null[0] + enum entry PRIMARY // com.pasich.encly.core.security/VaultKind.PRIMARY|null[0] + + final val entries // com.pasich.encly.core.security/VaultKind.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/VaultKind.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/VaultKind // com.pasich.encly.core.security/VaultKind.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/VaultKind.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/VaultUnlockResult : kotlin/Enum { // com.pasich.encly.core.security/VaultUnlockResult|null[0] + enum entry DB_ERROR // com.pasich.encly.core.security/VaultUnlockResult.DB_ERROR|null[0] + enum entry INVALID_CREDENTIAL // com.pasich.encly.core.security/VaultUnlockResult.INVALID_CREDENTIAL|null[0] + enum entry LOCKED_OUT // com.pasich.encly.core.security/VaultUnlockResult.LOCKED_OUT|null[0] + enum entry PIN_KEY_LOST // com.pasich.encly.core.security/VaultUnlockResult.PIN_KEY_LOST|null[0] + enum entry SUCCESS // com.pasich.encly.core.security/VaultUnlockResult.SUCCESS|null[0] + + final val entries // com.pasich.encly.core.security/VaultUnlockResult.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/VaultUnlockResult.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/VaultUnlockResult // com.pasich.encly.core.security/VaultUnlockResult.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/VaultUnlockResult.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/WipePinChange : kotlin/Enum { // com.pasich.encly.core.security/WipePinChange|null[0] + enum entry FAILED // com.pasich.encly.core.security/WipePinChange.FAILED|null[0] + enum entry IN_USE // com.pasich.encly.core.security/WipePinChange.IN_USE|null[0] + enum entry SAME_AS_PIN // com.pasich.encly.core.security/WipePinChange.SAME_AS_PIN|null[0] + enum entry SET // com.pasich.encly.core.security/WipePinChange.SET|null[0] + + final val entries // com.pasich.encly.core.security/WipePinChange.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/WipePinChange.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/WipePinChange // com.pasich.encly.core.security/WipePinChange.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/WipePinChange.values|values#static(){}[0] +} + +final enum class com.pasich.encly.core.security/WipeStage : kotlin/Enum { // com.pasich.encly.core.security/WipeStage|null[0] + enum entry CLEANUP // com.pasich.encly.core.security/WipeStage.CLEANUP|null[0] + enum entry DATABASE // com.pasich.encly.core.security/WipeStage.DATABASE|null[0] + + final val entries // com.pasich.encly.core.security/WipeStage.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/WipeStage.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/WipeStage // com.pasich.encly.core.security/WipeStage.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/WipeStage.values|values#static(){}[0] +} + +abstract fun interface com.pasich.encly.core.security/HandoffStagingSweeper { // com.pasich.encly.core.security/HandoffStagingSweeper|null[0] + abstract fun sweep() // com.pasich.encly.core.security/HandoffStagingSweeper.sweep|sweep(){}[0] +} + +abstract interface com.pasich.encly.core.security/AutoLockPolicy { // com.pasich.encly.core.security/AutoLockPolicy|null[0] + abstract val delayMillis // com.pasich.encly.core.security/AutoLockPolicy.delayMillis|{}delayMillis[0] + abstract fun (): kotlin/Long // com.pasich.encly.core.security/AutoLockPolicy.delayMillis.|(){}[0] + + final object Immediately : com.pasich.encly.core.security/AutoLockPolicy { // com.pasich.encly.core.security/AutoLockPolicy.Immediately|null[0] + final val delayMillis // com.pasich.encly.core.security/AutoLockPolicy.Immediately.delayMillis|{}delayMillis[0] + final fun (): kotlin/Long // com.pasich.encly.core.security/AutoLockPolicy.Immediately.delayMillis.|(){}[0] + } +} + +abstract interface com.pasich.encly.core.security/BiometricHost // com.pasich.encly.core.security/BiometricHost|null[0] + +abstract interface com.pasich.encly.core.security/BiometricManager { // com.pasich.encly.core.security/BiometricManager|null[0] + abstract fun authenticate(com.pasich.encly.core.security/BiometricHost, com.pasich.encly.core.security/BiometricManager.BiometricType, com.pasich.encly.core.security/BiometricManager.BiometricCallback, com.pasich.encly.core.security/BiometricManager.PromptConfig? = ...) // com.pasich.encly.core.security/BiometricManager.authenticate|authenticate(com.pasich.encly.core.security.BiometricHost;com.pasich.encly.core.security.BiometricManager.BiometricType;com.pasich.encly.core.security.BiometricManager.BiometricCallback;com.pasich.encly.core.security.BiometricManager.PromptConfig?){}[0] + abstract fun disable() // com.pasich.encly.core.security/BiometricManager.disable|disable(){}[0] + abstract fun enroll(com.pasich.encly.core.security/BiometricHost, kotlin/ByteArray, kotlin/Function1) // com.pasich.encly.core.security/BiometricManager.enroll|enroll(com.pasich.encly.core.security.BiometricHost;kotlin.ByteArray;kotlin.Function1){}[0] + abstract fun hasSlot(): kotlin/Boolean // com.pasich.encly.core.security/BiometricManager.hasSlot|hasSlot(){}[0] + abstract fun isStrongBiometricAvailable(): kotlin/Boolean // com.pasich.encly.core.security/BiometricManager.isStrongBiometricAvailable|isStrongBiometricAvailable(){}[0] + abstract fun strongBiometricStatus(): com.pasich.encly.core.security/BiometricStatus // com.pasich.encly.core.security/BiometricManager.strongBiometricStatus|strongBiometricStatus(){}[0] + abstract fun unlock(com.pasich.encly.core.security/BiometricHost, kotlin/Function1) // com.pasich.encly.core.security/BiometricManager.unlock|unlock(com.pasich.encly.core.security.BiometricHost;kotlin.Function1){}[0] + + final enum class BiometricType : kotlin/Enum { // com.pasich.encly.core.security/BiometricManager.BiometricType|null[0] + enum entry APP_UNLOCK // com.pasich.encly.core.security/BiometricManager.BiometricType.APP_UNLOCK|null[0] + enum entry GENERAL // com.pasich.encly.core.security/BiometricManager.BiometricType.GENERAL|null[0] + enum entry MASTER_KEY_ACCESS // com.pasich.encly.core.security/BiometricManager.BiometricType.MASTER_KEY_ACCESS|null[0] + enum entry SETTINGS_TOGGLE // com.pasich.encly.core.security/BiometricManager.BiometricType.SETTINGS_TOGGLE|null[0] + + final val entries // com.pasich.encly.core.security/BiometricManager.BiometricType.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/BiometricManager.BiometricType.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/BiometricManager.BiometricType // com.pasich.encly.core.security/BiometricManager.BiometricType.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/BiometricManager.BiometricType.values|values#static(){}[0] + } + + abstract interface BiometricCallback { // com.pasich.encly.core.security/BiometricManager.BiometricCallback|null[0] + abstract fun onError(kotlin/Int, kotlin/String) // com.pasich.encly.core.security/BiometricManager.BiometricCallback.onError|onError(kotlin.Int;kotlin.String){}[0] + abstract fun onFailed() // com.pasich.encly.core.security/BiometricManager.BiometricCallback.onFailed|onFailed(){}[0] + abstract fun onSuccess() // com.pasich.encly.core.security/BiometricManager.BiometricCallback.onSuccess|onSuccess(){}[0] + open fun onCancelled() // com.pasich.encly.core.security/BiometricManager.BiometricCallback.onCancelled|onCancelled(){}[0] + } + + final class PromptConfig { // com.pasich.encly.core.security/BiometricManager.PromptConfig|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String? = ..., kotlin/String? = ...) // com.pasich.encly.core.security/BiometricManager.PromptConfig.|(kotlin.String;kotlin.String;kotlin.String?;kotlin.String?){}[0] + + final val description // com.pasich.encly.core.security/BiometricManager.PromptConfig.description|{}description[0] + final fun (): kotlin/String? // com.pasich.encly.core.security/BiometricManager.PromptConfig.description.|(){}[0] + final val negativeButtonText // com.pasich.encly.core.security/BiometricManager.PromptConfig.negativeButtonText|{}negativeButtonText[0] + final fun (): kotlin/String? // com.pasich.encly.core.security/BiometricManager.PromptConfig.negativeButtonText.|(){}[0] + final val subtitle // com.pasich.encly.core.security/BiometricManager.PromptConfig.subtitle|{}subtitle[0] + final fun (): kotlin/String // com.pasich.encly.core.security/BiometricManager.PromptConfig.subtitle.|(){}[0] + final val title // com.pasich.encly.core.security/BiometricManager.PromptConfig.title|{}title[0] + final fun (): kotlin/String // com.pasich.encly.core.security/BiometricManager.PromptConfig.title.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.core.security/BiometricManager.PromptConfig.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.core.security/BiometricManager.PromptConfig.component2|component2(){}[0] + final fun component3(): kotlin/String? // com.pasich.encly.core.security/BiometricManager.PromptConfig.component3|component3(){}[0] + final fun component4(): kotlin/String? // com.pasich.encly.core.security/BiometricManager.PromptConfig.component4|component4(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String? = ..., kotlin/String? = ...): com.pasich.encly.core.security/BiometricManager.PromptConfig // com.pasich.encly.core.security/BiometricManager.PromptConfig.copy|copy(kotlin.String;kotlin.String;kotlin.String?;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.security/BiometricManager.PromptConfig.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.security/BiometricManager.PromptConfig.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.security/BiometricManager.PromptConfig.toString|toString(){}[0] + } + + final object Companion { // com.pasich.encly.core.security/BiometricManager.Companion|null[0] + final const val SLOT_PREFIX // com.pasich.encly.core.security/BiometricManager.Companion.SLOT_PREFIX|{}SLOT_PREFIX[0] + final fun (): kotlin/String // com.pasich.encly.core.security/BiometricManager.Companion.SLOT_PREFIX.|(){}[0] + } +} + +abstract interface com.pasich.encly.core.security/DeviceLockWatcher { // com.pasich.encly.core.security/DeviceLockWatcher|null[0] + abstract fun isDeviceLocked(): kotlin/Boolean // com.pasich.encly.core.security/DeviceLockWatcher.isDeviceLocked|isDeviceLocked(){}[0] + abstract fun watchScreenOff(kotlin/Function0): kotlin/Function0 // com.pasich.encly.core.security/DeviceLockWatcher.watchScreenOff|watchScreenOff(kotlin.Function0){}[0] + + final object None : com.pasich.encly.core.security/DeviceLockWatcher { // com.pasich.encly.core.security/DeviceLockWatcher.None|null[0] + final fun isDeviceLocked(): kotlin/Boolean // com.pasich.encly.core.security/DeviceLockWatcher.None.isDeviceLocked|isDeviceLocked(){}[0] + final fun watchScreenOff(kotlin/Function0): kotlin/Function0 // com.pasich.encly.core.security/DeviceLockWatcher.None.watchScreenOff|watchScreenOff(kotlin.Function0){}[0] + } +} + +abstract interface com.pasich.encly.core.security/LockoutClock { // com.pasich.encly.core.security/LockoutClock|null[0] + abstract fun bootCount(): kotlin/Int // com.pasich.encly.core.security/LockoutClock.bootCount|bootCount(){}[0] + abstract fun elapsedRealtime(): kotlin/Long // com.pasich.encly.core.security/LockoutClock.elapsedRealtime|elapsedRealtime(){}[0] +} + +abstract interface com.pasich.encly.core.security/PinHardwareFactor { // com.pasich.encly.core.security/PinHardwareFactor|null[0] + abstract fun delete(com.pasich.encly.core.security/PinKeySlot) // com.pasich.encly.core.security/PinHardwareFactor.delete|delete(com.pasich.encly.core.security.PinKeySlot){}[0] + abstract fun ensureKey(com.pasich.encly.core.security/PinKeySlot): kotlin/Boolean // com.pasich.encly.core.security/PinHardwareFactor.ensureKey|ensureKey(com.pasich.encly.core.security.PinKeySlot){}[0] + abstract fun mac(com.pasich.encly.core.security/PinKeySlot, kotlin/ByteArray): kotlin/ByteArray // com.pasich.encly.core.security/PinHardwareFactor.mac|mac(com.pasich.encly.core.security.PinKeySlot;kotlin.ByteArray){}[0] + abstract fun reset(com.pasich.encly.core.security/PinKeySlot) // com.pasich.encly.core.security/PinHardwareFactor.reset|reset(com.pasich.encly.core.security.PinKeySlot){}[0] +} + +abstract interface com.pasich.encly.core.security/VaultDatabase { // com.pasich.encly.core.security/VaultDatabase|null[0] + abstract fun hasEncryptedDatabase(com.pasich.encly.core.security/VaultKind = ...): kotlin/Boolean // com.pasich.encly.core.security/VaultDatabase.hasEncryptedDatabase|hasEncryptedDatabase(com.pasich.encly.core.security.VaultKind){}[0] + abstract fun isDatabaseUnlocked(): kotlin/Boolean // com.pasich.encly.core.security/VaultDatabase.isDatabaseUnlocked|isDatabaseUnlocked(){}[0] + abstract fun reset() // com.pasich.encly.core.security/VaultDatabase.reset|reset(){}[0] + abstract fun unlockDatabase(kotlin/ByteArray, kotlin/Boolean = ...): kotlin/Boolean // com.pasich.encly.core.security/VaultDatabase.unlockDatabase|unlockDatabase(kotlin.ByteArray;kotlin.Boolean){}[0] + abstract fun unlockDatabase(kotlin/ByteArray, kotlin/Boolean, com.pasich.encly.core.security/VaultKind): kotlin/Boolean // com.pasich.encly.core.security/VaultDatabase.unlockDatabase|unlockDatabase(kotlin.ByteArray;kotlin.Boolean;com.pasich.encly.core.security.VaultKind){}[0] + abstract fun wipe() // com.pasich.encly.core.security/VaultDatabase.wipe|wipe(){}[0] + abstract fun wipeIfClosed(com.pasich.encly.core.security/VaultKind? = ...): kotlin/Boolean // com.pasich.encly.core.security/VaultDatabase.wipeIfClosed|wipeIfClosed(com.pasich.encly.core.security.VaultKind?){}[0] +} + +abstract interface com.pasich.encly.core.security/VaultFileIo { // com.pasich.encly.core.security/VaultFileIo|null[0] + abstract fun delete(): kotlin/Boolean // com.pasich.encly.core.security/VaultFileIo.delete|delete(){}[0] + abstract fun deleteTemp() // com.pasich.encly.core.security/VaultFileIo.deleteTemp|deleteTemp(){}[0] + abstract fun exists(): kotlin/Boolean // com.pasich.encly.core.security/VaultFileIo.exists|exists(){}[0] + abstract fun read(): kotlin/ByteArray // com.pasich.encly.core.security/VaultFileIo.read|read(){}[0] + abstract fun replace(kotlin/ByteArray, kotlin/Function0?) // com.pasich.encly.core.security/VaultFileIo.replace|replace(kotlin.ByteArray;kotlin.Function0?){}[0] +} + +abstract interface com.pasich.encly.core.security/VaultLockEvents { // com.pasich.encly.core.security/VaultLockEvents|null[0] + abstract val locked // com.pasich.encly.core.security/VaultLockEvents.locked|{}locked[0] + abstract fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.core.security/VaultLockEvents.locked.|(){}[0] + + open fun registerPreLockFlush(kotlin.coroutines/SuspendFunction0): kotlin/Function0 // com.pasich.encly.core.security/VaultLockEvents.registerPreLockFlush|registerPreLockFlush(kotlin.coroutines.SuspendFunction0){}[0] +} + +sealed interface com.pasich.encly.core.security/PinUnlock { // com.pasich.encly.core.security/PinUnlock|null[0] + final class Decoy : com.pasich.encly.core.security/PinUnlock { // com.pasich.encly.core.security/PinUnlock.Decoy|null[0] + constructor (kotlin/ByteArray) // com.pasich.encly.core.security/PinUnlock.Decoy.|(kotlin.ByteArray){}[0] + + final val dek // com.pasich.encly.core.security/PinUnlock.Decoy.dek|{}dek[0] + final fun (): kotlin/ByteArray // com.pasich.encly.core.security/PinUnlock.Decoy.dek.|(){}[0] + } + + final class Erased : com.pasich.encly.core.security/PinUnlock { // com.pasich.encly.core.security/PinUnlock.Erased|null[0] + constructor (kotlin/ByteArray) // com.pasich.encly.core.security/PinUnlock.Erased.|(kotlin.ByteArray){}[0] + + final val dek // com.pasich.encly.core.security/PinUnlock.Erased.dek|{}dek[0] + final fun (): kotlin/ByteArray // com.pasich.encly.core.security/PinUnlock.Erased.dek.|(){}[0] + } + + final class Success : com.pasich.encly.core.security/PinUnlock { // com.pasich.encly.core.security/PinUnlock.Success|null[0] + constructor (kotlin/ByteArray) // com.pasich.encly.core.security/PinUnlock.Success.|(kotlin.ByteArray){}[0] + + final val dek // com.pasich.encly.core.security/PinUnlock.Success.dek|{}dek[0] + final fun (): kotlin/ByteArray // com.pasich.encly.core.security/PinUnlock.Success.dek.|(){}[0] + } + + final object Failed : com.pasich.encly.core.security/PinUnlock { // com.pasich.encly.core.security/PinUnlock.Failed|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.security/PinUnlock.Failed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.security/PinUnlock.Failed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.security/PinUnlock.Failed.toString|toString(){}[0] + } + + final object KeyLost : com.pasich.encly.core.security/PinUnlock { // com.pasich.encly.core.security/PinUnlock.KeyLost|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.security/PinUnlock.KeyLost.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.security/PinUnlock.KeyLost.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.security/PinUnlock.KeyLost.toString|toString(){}[0] + } + + final object LockedOut : com.pasich.encly.core.security/PinUnlock { // com.pasich.encly.core.security/PinUnlock.LockedOut|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.security/PinUnlock.LockedOut.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.security/PinUnlock.LockedOut.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.security/PinUnlock.LockedOut.toString|toString(){}[0] + } + + final object WrongPin : com.pasich.encly.core.security/PinUnlock { // com.pasich.encly.core.security/PinUnlock.WrongPin|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.security/PinUnlock.WrongPin.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.security/PinUnlock.WrongPin.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.security/PinUnlock.WrongPin.toString|toString(){}[0] + } +} + +final class com.pasich.encly.core.backup/BackupException : kotlin/Exception { // com.pasich.encly.core.backup/BackupException|null[0] + constructor (com.pasich.encly.core.backup/BackupError, kotlin/Throwable? = ...) // com.pasich.encly.core.backup/BackupException.|(com.pasich.encly.core.backup.BackupError;kotlin.Throwable?){}[0] + + final val error // com.pasich.encly.core.backup/BackupException.error|{}error[0] + final fun (): com.pasich.encly.core.backup/BackupError // com.pasich.encly.core.backup/BackupException.error.|(){}[0] +} + +final class com.pasich.encly.core.backup/BackupHeader { // com.pasich.encly.core.backup/BackupHeader|null[0] + constructor (kotlin/Int, com.pasich.encly.core.backup/BackupKdf, kotlin/ByteArray, kotlin/ByteArray, kotlin/Int) // com.pasich.encly.core.backup/BackupHeader.|(kotlin.Int;com.pasich.encly.core.backup.BackupKdf;kotlin.ByteArray;kotlin.ByteArray;kotlin.Int){}[0] + + final val ciphertextLength // com.pasich.encly.core.backup/BackupHeader.ciphertextLength|{}ciphertextLength[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupHeader.ciphertextLength.|(){}[0] + final val formatVersion // com.pasich.encly.core.backup/BackupHeader.formatVersion|{}formatVersion[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupHeader.formatVersion.|(){}[0] + final val kdf // com.pasich.encly.core.backup/BackupHeader.kdf|{}kdf[0] + final fun (): com.pasich.encly.core.backup/BackupKdf // com.pasich.encly.core.backup/BackupHeader.kdf.|(){}[0] + final val nonce // com.pasich.encly.core.backup/BackupHeader.nonce|{}nonce[0] + final fun (): kotlin/ByteArray // com.pasich.encly.core.backup/BackupHeader.nonce.|(){}[0] + final val salt // com.pasich.encly.core.backup/BackupHeader.salt|{}salt[0] + final fun (): kotlin/ByteArray // com.pasich.encly.core.backup/BackupHeader.salt.|(){}[0] +} + +final class com.pasich.encly.core.backup/BackupNote { // com.pasich.encly.core.backup/BackupNote|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/Long, kotlin/Long, kotlin.collections/List = ..., kotlin/Boolean, kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ...) // com.pasich.encly.core.backup/BackupNote.|(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Long;kotlin.Long;kotlin.collections.List;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?){}[0] + + final val color // com.pasich.encly.core.backup/BackupNote.color|{}color[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupNote.color.|(){}[0] + final val date // com.pasich.encly.core.backup/BackupNote.date|{}date[0] + final fun (): kotlin/Long // com.pasich.encly.core.backup/BackupNote.date.|(){}[0] + final val dateCreate // com.pasich.encly.core.backup/BackupNote.dateCreate|{}dateCreate[0] + final fun (): kotlin/Long // com.pasich.encly.core.backup/BackupNote.dateCreate.|(){}[0] + final val description // com.pasich.encly.core.backup/BackupNote.description|{}description[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupNote.description.|(){}[0] + final val isArchived // com.pasich.encly.core.backup/BackupNote.isArchived|{}isArchived[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.backup/BackupNote.isArchived.|(){}[0] + final val isPinned // com.pasich.encly.core.backup/BackupNote.isPinned|{}isPinned[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.backup/BackupNote.isPinned.|(){}[0] + final val isTrash // com.pasich.encly.core.backup/BackupNote.isTrash|{}isTrash[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.backup/BackupNote.isTrash.|(){}[0] + final val lineSpacing // com.pasich.encly.core.backup/BackupNote.lineSpacing|{}lineSpacing[0] + final fun (): kotlin/Int? // com.pasich.encly.core.backup/BackupNote.lineSpacing.|(){}[0] + final val tagUids // com.pasich.encly.core.backup/BackupNote.tagUids|{}tagUids[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupNote.tagUids.|(){}[0] + final val textSize // com.pasich.encly.core.backup/BackupNote.textSize|{}textSize[0] + final fun (): kotlin/Int? // com.pasich.encly.core.backup/BackupNote.textSize.|(){}[0] + final val title // com.pasich.encly.core.backup/BackupNote.title|{}title[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupNote.title.|(){}[0] + final val uid // com.pasich.encly.core.backup/BackupNote.uid|{}uid[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupNote.uid.|(){}[0] + final val value // com.pasich.encly.core.backup/BackupNote.value|{}value[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupNote.value.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.core.backup/BackupNote.component1|component1(){}[0] + final fun component10(): kotlin/Boolean // com.pasich.encly.core.backup/BackupNote.component10|component10(){}[0] + final fun component11(): kotlin/String // com.pasich.encly.core.backup/BackupNote.component11|component11(){}[0] + final fun component12(): kotlin/Int? // com.pasich.encly.core.backup/BackupNote.component12|component12(){}[0] + final fun component13(): kotlin/Int? // com.pasich.encly.core.backup/BackupNote.component13|component13(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.core.backup/BackupNote.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.core.backup/BackupNote.component3|component3(){}[0] + final fun component4(): kotlin/String // com.pasich.encly.core.backup/BackupNote.component4|component4(){}[0] + final fun component5(): kotlin/Long // com.pasich.encly.core.backup/BackupNote.component5|component5(){}[0] + final fun component6(): kotlin/Long // com.pasich.encly.core.backup/BackupNote.component6|component6(){}[0] + final fun component7(): kotlin.collections/List // com.pasich.encly.core.backup/BackupNote.component7|component7(){}[0] + final fun component8(): kotlin/Boolean // com.pasich.encly.core.backup/BackupNote.component8|component8(){}[0] + final fun component9(): kotlin/Boolean // com.pasich.encly.core.backup/BackupNote.component9|component9(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Long = ..., kotlin.collections/List = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ...): com.pasich.encly.core.backup/BackupNote // com.pasich.encly.core.backup/BackupNote.copy|copy(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Long;kotlin.Long;kotlin.collections.List;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.backup/BackupNote.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.backup/BackupNote.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.backup/BackupNote.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // com.pasich.encly.core.backup/BackupNote.$serializer|null[0] + final val descriptor // com.pasich.encly.core.backup/BackupNote.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.pasich.encly.core.backup/BackupNote.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // com.pasich.encly.core.backup/BackupNote.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): com.pasich.encly.core.backup/BackupNote // com.pasich.encly.core.backup/BackupNote.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, com.pasich.encly.core.backup/BackupNote) // com.pasich.encly.core.backup/BackupNote.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;com.pasich.encly.core.backup.BackupNote){}[0] + } + + final object Companion { // com.pasich.encly.core.backup/BackupNote.Companion|null[0] + final val $childSerializers // com.pasich.encly.core.backup/BackupNote.Companion.$childSerializers|{}$childSerializers[0] + + final fun serializer(): kotlinx.serialization/KSerializer // com.pasich.encly.core.backup/BackupNote.Companion.serializer|serializer(){}[0] + } +} + +final class com.pasich.encly.core.backup/BackupPayload { // com.pasich.encly.core.backup/BackupPayload|null[0] + constructor (kotlin/Int = ..., kotlin/Long, kotlin.collections/List, kotlin.collections/List, kotlin.collections/List, kotlin.collections/Map = ...) // com.pasich.encly.core.backup/BackupPayload.|(kotlin.Int;kotlin.Long;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List;kotlin.collections.Map){}[0] + + final val exportedAt // com.pasich.encly.core.backup/BackupPayload.exportedAt|{}exportedAt[0] + final fun (): kotlin/Long // com.pasich.encly.core.backup/BackupPayload.exportedAt.|(){}[0] + final val notes // com.pasich.encly.core.backup/BackupPayload.notes|{}notes[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupPayload.notes.|(){}[0] + final val schema // com.pasich.encly.core.backup/BackupPayload.schema|{}schema[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.schema.|(){}[0] + final val settings // com.pasich.encly.core.backup/BackupPayload.settings|{}settings[0] + final fun (): kotlin.collections/Map // com.pasich.encly.core.backup/BackupPayload.settings.|(){}[0] + final val tags // com.pasich.encly.core.backup/BackupPayload.tags|{}tags[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupPayload.tags.|(){}[0] + final val tasks // com.pasich.encly.core.backup/BackupPayload.tasks|{}tasks[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupPayload.tasks.|(){}[0] + + final fun component1(): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.component1|component1(){}[0] + final fun component2(): kotlin/Long // com.pasich.encly.core.backup/BackupPayload.component2|component2(){}[0] + final fun component3(): kotlin.collections/List // com.pasich.encly.core.backup/BackupPayload.component3|component3(){}[0] + final fun component4(): kotlin.collections/List // com.pasich.encly.core.backup/BackupPayload.component4|component4(){}[0] + final fun component5(): kotlin.collections/List // com.pasich.encly.core.backup/BackupPayload.component5|component5(){}[0] + final fun component6(): kotlin.collections/Map // com.pasich.encly.core.backup/BackupPayload.component6|component6(){}[0] + final fun copy(kotlin/Int = ..., kotlin/Long = ..., kotlin.collections/List = ..., kotlin.collections/List = ..., kotlin.collections/List = ..., kotlin.collections/Map = ...): com.pasich.encly.core.backup/BackupPayload // com.pasich.encly.core.backup/BackupPayload.copy|copy(kotlin.Int;kotlin.Long;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List;kotlin.collections.Map){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.backup/BackupPayload.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.backup/BackupPayload.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // com.pasich.encly.core.backup/BackupPayload.$serializer|null[0] + final val descriptor // com.pasich.encly.core.backup/BackupPayload.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.pasich.encly.core.backup/BackupPayload.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // com.pasich.encly.core.backup/BackupPayload.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): com.pasich.encly.core.backup/BackupPayload // com.pasich.encly.core.backup/BackupPayload.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, com.pasich.encly.core.backup/BackupPayload) // com.pasich.encly.core.backup/BackupPayload.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;com.pasich.encly.core.backup.BackupPayload){}[0] + } + + final object Companion { // com.pasich.encly.core.backup/BackupPayload.Companion|null[0] + final const val SCHEMA_V1 // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V1|{}SCHEMA_V1[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V1.|(){}[0] + final const val SCHEMA_V2 // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V2|{}SCHEMA_V2[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V2.|(){}[0] + final const val SCHEMA_V3 // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V3|{}SCHEMA_V3[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V3.|(){}[0] + final const val SCHEMA_V4 // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V4|{}SCHEMA_V4[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V4.|(){}[0] + final const val SCHEMA_V5 // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V5|{}SCHEMA_V5[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_V5.|(){}[0] + final const val SCHEMA_VERSION // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_VERSION|{}SCHEMA_VERSION[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayload.Companion.SCHEMA_VERSION.|(){}[0] + + final val $childSerializers // com.pasich.encly.core.backup/BackupPayload.Companion.$childSerializers|{}$childSerializers[0] + + final fun serializer(): kotlinx.serialization/KSerializer // com.pasich.encly.core.backup/BackupPayload.Companion.serializer|serializer(){}[0] + } +} + +final class com.pasich.encly.core.backup/BackupSubtask { // com.pasich.encly.core.backup/BackupSubtask|null[0] + constructor (kotlin/String, kotlin/String, kotlin/Boolean, kotlin/Int) // com.pasich.encly.core.backup/BackupSubtask.|(kotlin.String;kotlin.String;kotlin.Boolean;kotlin.Int){}[0] + + final val isCompleted // com.pasich.encly.core.backup/BackupSubtask.isCompleted|{}isCompleted[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.backup/BackupSubtask.isCompleted.|(){}[0] + final val position // com.pasich.encly.core.backup/BackupSubtask.position|{}position[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupSubtask.position.|(){}[0] + final val title // com.pasich.encly.core.backup/BackupSubtask.title|{}title[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSubtask.title.|(){}[0] + final val uid // com.pasich.encly.core.backup/BackupSubtask.uid|{}uid[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSubtask.uid.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.core.backup/BackupSubtask.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.core.backup/BackupSubtask.component2|component2(){}[0] + final fun component3(): kotlin/Boolean // com.pasich.encly.core.backup/BackupSubtask.component3|component3(){}[0] + final fun component4(): kotlin/Int // com.pasich.encly.core.backup/BackupSubtask.component4|component4(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Int = ...): com.pasich.encly.core.backup/BackupSubtask // com.pasich.encly.core.backup/BackupSubtask.copy|copy(kotlin.String;kotlin.String;kotlin.Boolean;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.backup/BackupSubtask.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.backup/BackupSubtask.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.backup/BackupSubtask.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // com.pasich.encly.core.backup/BackupSubtask.$serializer|null[0] + final val descriptor // com.pasich.encly.core.backup/BackupSubtask.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.pasich.encly.core.backup/BackupSubtask.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // com.pasich.encly.core.backup/BackupSubtask.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): com.pasich.encly.core.backup/BackupSubtask // com.pasich.encly.core.backup/BackupSubtask.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, com.pasich.encly.core.backup/BackupSubtask) // com.pasich.encly.core.backup/BackupSubtask.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;com.pasich.encly.core.backup.BackupSubtask){}[0] + } + + final object Companion { // com.pasich.encly.core.backup/BackupSubtask.Companion|null[0] + final fun serializer(): kotlinx.serialization/KSerializer // com.pasich.encly.core.backup/BackupSubtask.Companion.serializer|serializer(){}[0] + } +} + +final class com.pasich.encly.core.backup/BackupTag { // com.pasich.encly.core.backup/BackupTag|null[0] + constructor (kotlin/String, kotlin/String, kotlin/Boolean, kotlin/Int) // com.pasich.encly.core.backup/BackupTag.|(kotlin.String;kotlin.String;kotlin.Boolean;kotlin.Int){}[0] + + final val name // com.pasich.encly.core.backup/BackupTag.name|{}name[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupTag.name.|(){}[0] + final val position // com.pasich.encly.core.backup/BackupTag.position|{}position[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupTag.position.|(){}[0] + final val uid // com.pasich.encly.core.backup/BackupTag.uid|{}uid[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupTag.uid.|(){}[0] + final val visible // com.pasich.encly.core.backup/BackupTag.visible|{}visible[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.backup/BackupTag.visible.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.core.backup/BackupTag.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.core.backup/BackupTag.component2|component2(){}[0] + final fun component3(): kotlin/Boolean // com.pasich.encly.core.backup/BackupTag.component3|component3(){}[0] + final fun component4(): kotlin/Int // com.pasich.encly.core.backup/BackupTag.component4|component4(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Int = ...): com.pasich.encly.core.backup/BackupTag // com.pasich.encly.core.backup/BackupTag.copy|copy(kotlin.String;kotlin.String;kotlin.Boolean;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.backup/BackupTag.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.backup/BackupTag.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.backup/BackupTag.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // com.pasich.encly.core.backup/BackupTag.$serializer|null[0] + final val descriptor // com.pasich.encly.core.backup/BackupTag.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.pasich.encly.core.backup/BackupTag.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // com.pasich.encly.core.backup/BackupTag.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): com.pasich.encly.core.backup/BackupTag // com.pasich.encly.core.backup/BackupTag.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, com.pasich.encly.core.backup/BackupTag) // com.pasich.encly.core.backup/BackupTag.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;com.pasich.encly.core.backup.BackupTag){}[0] + } + + final object Companion { // com.pasich.encly.core.backup/BackupTag.Companion|null[0] + final fun serializer(): kotlinx.serialization/KSerializer // com.pasich.encly.core.backup/BackupTag.Companion.serializer|serializer(){}[0] + } +} + +final class com.pasich.encly.core.backup/BackupTask { // com.pasich.encly.core.backup/BackupTask|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String?, kotlin/Boolean, kotlin/Long, kotlin/Long?, kotlin/Int, kotlin/String?, kotlin/Int, kotlin.collections/List) // com.pasich.encly.core.backup/BackupTask.|(kotlin.String;kotlin.String;kotlin.String?;kotlin.Boolean;kotlin.Long;kotlin.Long?;kotlin.Int;kotlin.String?;kotlin.Int;kotlin.collections.List){}[0] + + final val categoryTagUid // com.pasich.encly.core.backup/BackupTask.categoryTagUid|{}categoryTagUid[0] + final fun (): kotlin/String? // com.pasich.encly.core.backup/BackupTask.categoryTagUid.|(){}[0] + final val completedDate // com.pasich.encly.core.backup/BackupTask.completedDate|{}completedDate[0] + final fun (): kotlin/Long? // com.pasich.encly.core.backup/BackupTask.completedDate.|(){}[0] + final val createdDate // com.pasich.encly.core.backup/BackupTask.createdDate|{}createdDate[0] + final fun (): kotlin/Long // com.pasich.encly.core.backup/BackupTask.createdDate.|(){}[0] + final val description // com.pasich.encly.core.backup/BackupTask.description|{}description[0] + final fun (): kotlin/String? // com.pasich.encly.core.backup/BackupTask.description.|(){}[0] + final val isCompleted // com.pasich.encly.core.backup/BackupTask.isCompleted|{}isCompleted[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.backup/BackupTask.isCompleted.|(){}[0] + final val position // com.pasich.encly.core.backup/BackupTask.position|{}position[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupTask.position.|(){}[0] + final val priority // com.pasich.encly.core.backup/BackupTask.priority|{}priority[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupTask.priority.|(){}[0] + final val subtasks // com.pasich.encly.core.backup/BackupTask.subtasks|{}subtasks[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupTask.subtasks.|(){}[0] + final val title // com.pasich.encly.core.backup/BackupTask.title|{}title[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupTask.title.|(){}[0] + final val uid // com.pasich.encly.core.backup/BackupTask.uid|{}uid[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupTask.uid.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.core.backup/BackupTask.component1|component1(){}[0] + final fun component10(): kotlin.collections/List // com.pasich.encly.core.backup/BackupTask.component10|component10(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.core.backup/BackupTask.component2|component2(){}[0] + final fun component3(): kotlin/String? // com.pasich.encly.core.backup/BackupTask.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // com.pasich.encly.core.backup/BackupTask.component4|component4(){}[0] + final fun component5(): kotlin/Long // com.pasich.encly.core.backup/BackupTask.component5|component5(){}[0] + final fun component6(): kotlin/Long? // com.pasich.encly.core.backup/BackupTask.component6|component6(){}[0] + final fun component7(): kotlin/Int // com.pasich.encly.core.backup/BackupTask.component7|component7(){}[0] + final fun component8(): kotlin/String? // com.pasich.encly.core.backup/BackupTask.component8|component8(){}[0] + final fun component9(): kotlin/Int // com.pasich.encly.core.backup/BackupTask.component9|component9(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String? = ..., kotlin/Boolean = ..., kotlin/Long = ..., kotlin/Long? = ..., kotlin/Int = ..., kotlin/String? = ..., kotlin/Int = ..., kotlin.collections/List = ...): com.pasich.encly.core.backup/BackupTask // com.pasich.encly.core.backup/BackupTask.copy|copy(kotlin.String;kotlin.String;kotlin.String?;kotlin.Boolean;kotlin.Long;kotlin.Long?;kotlin.Int;kotlin.String?;kotlin.Int;kotlin.collections.List){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.backup/BackupTask.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.backup/BackupTask.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.backup/BackupTask.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // com.pasich.encly.core.backup/BackupTask.$serializer|null[0] + final val descriptor // com.pasich.encly.core.backup/BackupTask.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.pasich.encly.core.backup/BackupTask.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // com.pasich.encly.core.backup/BackupTask.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): com.pasich.encly.core.backup/BackupTask // com.pasich.encly.core.backup/BackupTask.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, com.pasich.encly.core.backup/BackupTask) // com.pasich.encly.core.backup/BackupTask.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;com.pasich.encly.core.backup.BackupTask){}[0] + } + + final object Companion { // com.pasich.encly.core.backup/BackupTask.Companion|null[0] + final val $childSerializers // com.pasich.encly.core.backup/BackupTask.Companion.$childSerializers|{}$childSerializers[0] + + final fun serializer(): kotlinx.serialization/KSerializer // com.pasich.encly.core.backup/BackupTask.Companion.serializer|serializer(){}[0] + } +} + +final class com.pasich.encly.core.backup/ParsedBackup { // com.pasich.encly.core.backup/ParsedBackup|null[0] + constructor (com.pasich.encly.core.backup/BackupHeader, kotlin/ByteArray) // com.pasich.encly.core.backup/ParsedBackup.|(com.pasich.encly.core.backup.BackupHeader;kotlin.ByteArray){}[0] + + final val ciphertextOffset // com.pasich.encly.core.backup/ParsedBackup.ciphertextOffset|{}ciphertextOffset[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/ParsedBackup.ciphertextOffset.|(){}[0] + final val file // com.pasich.encly.core.backup/ParsedBackup.file|{}file[0] + final fun (): kotlin/ByteArray // com.pasich.encly.core.backup/ParsedBackup.file.|(){}[0] + final val header // com.pasich.encly.core.backup/ParsedBackup.header|{}header[0] + final fun (): com.pasich.encly.core.backup/BackupHeader // com.pasich.encly.core.backup/ParsedBackup.header.|(){}[0] +} + +final class com.pasich.encly.core.security/AuthSettings { // com.pasich.encly.core.security/AuthSettings|null[0] + constructor (com.pasich.encly.core.security/AuthType, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // com.pasich.encly.core.security/AuthSettings.|(com.pasich.encly.core.security.AuthType;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0] + + final val authType // com.pasich.encly.core.security/AuthSettings.authType|{}authType[0] + final fun (): com.pasich.encly.core.security/AuthType // com.pasich.encly.core.security/AuthSettings.authType.|(){}[0] + final val duressPinTurnedOff // com.pasich.encly.core.security/AuthSettings.duressPinTurnedOff|{}duressPinTurnedOff[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.duressPinTurnedOff.|(){}[0] + final val hasDuressPin // com.pasich.encly.core.security/AuthSettings.hasDuressPin|{}hasDuressPin[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.hasDuressPin.|(){}[0] + final val hasPinSlot // com.pasich.encly.core.security/AuthSettings.hasPinSlot|{}hasPinSlot[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.hasPinSlot.|(){}[0] + final val isBiometricEnabled // com.pasich.encly.core.security/AuthSettings.isBiometricEnabled|{}isBiometricEnabled[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.isBiometricEnabled.|(){}[0] + final val isUserCreatedSeedKey // com.pasich.encly.core.security/AuthSettings.isUserCreatedSeedKey|{}isUserCreatedSeedKey[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.isUserCreatedSeedKey.|(){}[0] + final val wipePinTurnedOff // com.pasich.encly.core.security/AuthSettings.wipePinTurnedOff|{}wipePinTurnedOff[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.wipePinTurnedOff.|(){}[0] + + final fun component1(): com.pasich.encly.core.security/AuthType // com.pasich.encly.core.security/AuthSettings.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component2|component2(){}[0] + final fun component3(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component4|component4(){}[0] + final fun component5(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component5|component5(){}[0] + final fun component6(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component6|component6(){}[0] + final fun component7(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component7|component7(){}[0] + final fun copy(com.pasich.encly.core.security/AuthType = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...): com.pasich.encly.core.security/AuthSettings // com.pasich.encly.core.security/AuthSettings.copy|copy(com.pasich.encly.core.security.AuthType;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.security/AuthSettings.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.security/AuthSettings.toString|toString(){}[0] +} + +final class com.pasich.encly.core.security/AuthenticationManager { // com.pasich.encly.core.security/AuthenticationManager|null[0] + constructor (com.pasich.encly.core.security/VaultStore, com.pasich.encly.core.security/PinHardwareFactor, com.pasich.encly.core.security/LockoutClock) // com.pasich.encly.core.security/AuthenticationManager.|(com.pasich.encly.core.security.VaultStore;com.pasich.encly.core.security.PinHardwareFactor;com.pasich.encly.core.security.LockoutClock){}[0] + + final fun changePin(kotlin/CharArray, kotlin/ByteArray): com.pasich.encly.core.security/PinChange // com.pasich.encly.core.security/AuthenticationManager.changePin|changePin(kotlin.CharArray;kotlin.ByteArray){}[0] + final fun clearPendingWipe(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.clearPendingWipe|clearPendingWipe(){}[0] + final fun configureDuressPin(kotlin/CharArray, kotlin/ByteArray, kotlin/Function0): com.pasich.encly.core.security/DuressPinChange // com.pasich.encly.core.security/AuthenticationManager.configureDuressPin|configureDuressPin(kotlin.CharArray;kotlin.ByteArray;kotlin.Function0){}[0] + final fun configurePin(kotlin/CharArray, kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.configurePin|configurePin(kotlin.CharArray;kotlin.ByteArray){}[0] + final fun configureWipePin(kotlin/CharArray): com.pasich.encly.core.security/WipePinChange // com.pasich.encly.core.security/AuthenticationManager.configureWipePin|configureWipePin(kotlin.CharArray){}[0] + final fun deactivateBiometricAuth() // com.pasich.encly.core.security/AuthenticationManager.deactivateBiometricAuth|deactivateBiometricAuth(){}[0] + final fun deleteRetiredPinKey() // com.pasich.encly.core.security/AuthenticationManager.deleteRetiredPinKey|deleteRetiredPinKey(){}[0] + final fun deleteStrayPinKey() // com.pasich.encly.core.security/AuthenticationManager.deleteStrayPinKey|deleteStrayPinKey(){}[0] + final fun duressPinTurnedOff(kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.duressPinTurnedOff|duressPinTurnedOff(kotlin.ByteArray){}[0] + final fun ensureWipeSlot(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.ensureWipeSlot|ensureWipeSlot(){}[0] + final fun getAuthType(): com.pasich.encly.core.security/AuthType // com.pasich.encly.core.security/AuthenticationManager.getAuthType|getAuthType(){}[0] + final fun hasDecoyVault(kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.hasDecoyVault|hasDecoyVault(kotlin.ByteArray){}[0] + final fun hasPinSlot(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.hasPinSlot|hasPinSlot(){}[0] + final fun isAuthStrategy(): com.pasich.encly.core.security/AuthStrategy // com.pasich.encly.core.security/AuthenticationManager.isAuthStrategy|isAuthStrategy(){}[0] + final fun isBiometricEnabled(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.isBiometricEnabled|isBiometricEnabled(){}[0] + final fun markBiometricEnabled(kotlin/Boolean) // com.pasich.encly.core.security/AuthenticationManager.markBiometricEnabled|markBiometricEnabled(kotlin.Boolean){}[0] + final fun markWipeDatabaseCreated(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.markWipeDatabaseCreated|markWipeDatabaseCreated(){}[0] + final fun pendingWipe(): com.pasich.encly.core.security/WipeStage? // com.pasich.encly.core.security/AuthenticationManager.pendingWipe|pendingWipe(){}[0] + final fun recoveryFollowUp(): com.pasich.encly.core.security/RecoveryFollowUp // com.pasich.encly.core.security/AuthenticationManager.recoveryFollowUp|recoveryFollowUp(){}[0] + final fun remainingLockoutMillis(): kotlin/Long // com.pasich.encly.core.security/AuthenticationManager.remainingLockoutMillis|remainingLockoutMillis(){}[0] + final fun removeDuressPin(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.removeDuressPin|removeDuressPin(){}[0] + final fun removeWipePin(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.removeWipePin|removeWipePin(){}[0] + final fun unlockWithPin(kotlin/CharArray): com.pasich.encly.core.security/PinUnlock // com.pasich.encly.core.security/AuthenticationManager.unlockWithPin|unlockWithPin(kotlin.CharArray){}[0] + final fun verifyPinAuth(kotlin/CharArray, kotlin/Boolean = ...): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.verifyPinAuth|verifyPinAuth(kotlin.CharArray;kotlin.Boolean){}[0] + final fun wipe() // com.pasich.encly.core.security/AuthenticationManager.wipe|wipe(){}[0] + final fun wipePinTurnedOff(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.wipePinTurnedOff|wipePinTurnedOff(){}[0] + + final class MetroFactory : dev.zacsweers.metro.internal/Factory { // com.pasich.encly.core.security/AuthenticationManager.MetroFactory|null[0] + final fun declarationMirror(com.pasich.encly.core.security/VaultStore, com.pasich.encly.core.security/PinHardwareFactor, com.pasich.encly.core.security/LockoutClock): com.pasich.encly.core.security/AuthenticationManager // com.pasich.encly.core.security/AuthenticationManager.MetroFactory.declarationMirror|declarationMirror(com.pasich.encly.core.security.VaultStore;com.pasich.encly.core.security.PinHardwareFactor;com.pasich.encly.core.security.LockoutClock){}[0] + final fun invoke(): com.pasich.encly.core.security/AuthenticationManager // com.pasich.encly.core.security/AuthenticationManager.MetroFactory.invoke|invoke(){}[0] + + final object Companion { // com.pasich.encly.core.security/AuthenticationManager.MetroFactory.Companion|null[0] + final fun create(dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider): com.pasich.encly.core.security/AuthenticationManager.MetroFactory // com.pasich.encly.core.security/AuthenticationManager.MetroFactory.Companion.create|create(dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider){}[0] + final fun newInstance(com.pasich.encly.core.security/VaultStore, com.pasich.encly.core.security/PinHardwareFactor, com.pasich.encly.core.security/LockoutClock): com.pasich.encly.core.security/AuthenticationManager // com.pasich.encly.core.security/AuthenticationManager.MetroFactory.Companion.newInstance|newInstance(com.pasich.encly.core.security.VaultStore;com.pasich.encly.core.security.PinHardwareFactor;com.pasich.encly.core.security.LockoutClock){}[0] + } + } + + final object Companion { // com.pasich.encly.core.security/AuthenticationManager.Companion|null[0] + final const val FIRST_LOCKOUT_MS // com.pasich.encly.core.security/AuthenticationManager.Companion.FIRST_LOCKOUT_MS|{}FIRST_LOCKOUT_MS[0] + final fun (): kotlin/Long // com.pasich.encly.core.security/AuthenticationManager.Companion.FIRST_LOCKOUT_MS.|(){}[0] + final const val MAX_ATTEMPTS // com.pasich.encly.core.security/AuthenticationManager.Companion.MAX_ATTEMPTS|{}MAX_ATTEMPTS[0] + final fun (): kotlin/Int // com.pasich.encly.core.security/AuthenticationManager.Companion.MAX_ATTEMPTS.|(){}[0] + final const val MAX_LOCKOUT_MS // com.pasich.encly.core.security/AuthenticationManager.Companion.MAX_LOCKOUT_MS|{}MAX_LOCKOUT_MS[0] + final fun (): kotlin/Long // com.pasich.encly.core.security/AuthenticationManager.Companion.MAX_LOCKOUT_MS.|(){}[0] + final const val STRIKE_GRACE // com.pasich.encly.core.security/AuthenticationManager.Companion.STRIKE_GRACE|{}STRIKE_GRACE[0] + final fun (): kotlin/Int // com.pasich.encly.core.security/AuthenticationManager.Companion.STRIKE_GRACE.|(){}[0] + + final fun penaltyFor(kotlin/Int, kotlin/Int = ...): kotlin/Long // com.pasich.encly.core.security/AuthenticationManager.Companion.penaltyFor|penaltyFor(kotlin.Int;kotlin.Int){}[0] + } +} + +final class com.pasich.encly.core.security/AutoLock : com.pasich.encly.core.security/AutoLockPolicy { // com.pasich.encly.core.security/AutoLock|null[0] + constructor (com.pasich.encly.core.platform/KeyValueStore) // com.pasich.encly.core.security/AutoLock.|(com.pasich.encly.core.platform.KeyValueStore){}[0] + + final val delay // com.pasich.encly.core.security/AutoLock.delay|{}delay[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.core.security/AutoLock.delay.|(){}[0] + final val delayMillis // com.pasich.encly.core.security/AutoLock.delayMillis|{}delayMillis[0] + final fun (): kotlin/Long // com.pasich.encly.core.security/AutoLock.delayMillis.|(){}[0] + + final fun setDelay(com.pasich.encly.core.security/AutoLockDelay) // com.pasich.encly.core.security/AutoLock.setDelay|setDelay(com.pasich.encly.core.security.AutoLockDelay){}[0] + + final class MetroFactory : dev.zacsweers.metro.internal/Factory { // com.pasich.encly.core.security/AutoLock.MetroFactory|null[0] + final fun declarationMirror(com.pasich.encly.core.platform/KeyValueStore): com.pasich.encly.core.security/AutoLock // com.pasich.encly.core.security/AutoLock.MetroFactory.declarationMirror|declarationMirror(com.pasich.encly.core.platform.KeyValueStore){}[0] + final fun invoke(): com.pasich.encly.core.security/AutoLock // com.pasich.encly.core.security/AutoLock.MetroFactory.invoke|invoke(){}[0] + + final object Companion { // com.pasich.encly.core.security/AutoLock.MetroFactory.Companion|null[0] + final fun create(dev.zacsweers.metro/Provider): com.pasich.encly.core.security/AutoLock.MetroFactory // com.pasich.encly.core.security/AutoLock.MetroFactory.Companion.create|create(dev.zacsweers.metro.Provider){}[0] + final fun newInstance(com.pasich.encly.core.platform/KeyValueStore): com.pasich.encly.core.security/AutoLock // com.pasich.encly.core.security/AutoLock.MetroFactory.Companion.newInstance|newInstance(com.pasich.encly.core.platform.KeyValueStore){}[0] + } + } +} + +final class com.pasich.encly.core.security/IosVaultFileIo : com.pasich.encly.core.security/VaultFileIo { // com.pasich.encly.core.security/IosVaultFileIo|null[0] + constructor (kotlin/String) // com.pasich.encly.core.security/IosVaultFileIo.|(kotlin.String){}[0] + + final fun delete(): kotlin/Boolean // com.pasich.encly.core.security/IosVaultFileIo.delete|delete(){}[0] + final fun deleteTemp() // com.pasich.encly.core.security/IosVaultFileIo.deleteTemp|deleteTemp(){}[0] + final fun exists(): kotlin/Boolean // com.pasich.encly.core.security/IosVaultFileIo.exists|exists(){}[0] + final fun read(): kotlin/ByteArray // com.pasich.encly.core.security/IosVaultFileIo.read|read(){}[0] + final fun replace(kotlin/ByteArray, kotlin/Function0?) // com.pasich.encly.core.security/IosVaultFileIo.replace|replace(kotlin.ByteArray;kotlin.Function0?){}[0] +} + +final class com.pasich.encly.core.security/KeyboardPrivacy { // com.pasich.encly.core.security/KeyboardPrivacy|null[0] + constructor (com.pasich.encly.core.platform/KeyValueStore) // com.pasich.encly.core.security/KeyboardPrivacy.|(com.pasich.encly.core.platform.KeyValueStore){}[0] + + final val strict // com.pasich.encly.core.security/KeyboardPrivacy.strict|{}strict[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.core.security/KeyboardPrivacy.strict.|(){}[0] + + final fun setStrict(kotlin/Boolean) // com.pasich.encly.core.security/KeyboardPrivacy.setStrict|setStrict(kotlin.Boolean){}[0] + + final class MetroFactory : dev.zacsweers.metro.internal/Factory { // com.pasich.encly.core.security/KeyboardPrivacy.MetroFactory|null[0] + final fun declarationMirror(com.pasich.encly.core.platform/KeyValueStore): com.pasich.encly.core.security/KeyboardPrivacy // com.pasich.encly.core.security/KeyboardPrivacy.MetroFactory.declarationMirror|declarationMirror(com.pasich.encly.core.platform.KeyValueStore){}[0] + final fun invoke(): com.pasich.encly.core.security/KeyboardPrivacy // com.pasich.encly.core.security/KeyboardPrivacy.MetroFactory.invoke|invoke(){}[0] + + final object Companion { // com.pasich.encly.core.security/KeyboardPrivacy.MetroFactory.Companion|null[0] + final fun create(dev.zacsweers.metro/Provider): com.pasich.encly.core.security/KeyboardPrivacy.MetroFactory // com.pasich.encly.core.security/KeyboardPrivacy.MetroFactory.Companion.create|create(dev.zacsweers.metro.Provider){}[0] + final fun newInstance(com.pasich.encly.core.platform/KeyValueStore): com.pasich.encly.core.security/KeyboardPrivacy // com.pasich.encly.core.security/KeyboardPrivacy.MetroFactory.Companion.newInstance|newInstance(com.pasich.encly.core.platform.KeyValueStore){}[0] + } + } +} + +final class com.pasich.encly.core.security/PinFactorException : kotlin/Exception { // com.pasich.encly.core.security/PinFactorException|null[0] + constructor (kotlin/Boolean, kotlin/Throwable? = ...) // com.pasich.encly.core.security/PinFactorException.|(kotlin.Boolean;kotlin.Throwable?){}[0] + + final val lost // com.pasich.encly.core.security/PinFactorException.lost|{}lost[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/PinFactorException.lost.|(){}[0] +} + +final class com.pasich.encly.core.security/ScreenshotAllowance { // com.pasich.encly.core.security/ScreenshotAllowance|null[0] + constructor () // com.pasich.encly.core.security/ScreenshotAllowance.|(){}[0] + + final val allowed // com.pasich.encly.core.security/ScreenshotAllowance.allowed|{}allowed[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.core.security/ScreenshotAllowance.allowed.|(){}[0] + final val capturable // com.pasich.encly.core.security/ScreenshotAllowance.capturable|{}capturable[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/ScreenshotAllowance.capturable.|(){}[0] + + final fun allow() // com.pasich.encly.core.security/ScreenshotAllowance.allow|allow(){}[0] + final fun observe(kotlin/Function1): kotlin/Function0 // com.pasich.encly.core.security/ScreenshotAllowance.observe|observe(kotlin.Function1){}[0] + final fun protect(): kotlin/Function0 // com.pasich.encly.core.security/ScreenshotAllowance.protect|protect(){}[0] + final fun revoke() // com.pasich.encly.core.security/ScreenshotAllowance.revoke|revoke(){}[0] + + final class MetroFactory : dev.zacsweers.metro.internal/Factory { // com.pasich.encly.core.security/ScreenshotAllowance.MetroFactory|null[0] + final fun declarationMirror(): com.pasich.encly.core.security/ScreenshotAllowance // com.pasich.encly.core.security/ScreenshotAllowance.MetroFactory.declarationMirror|declarationMirror(){}[0] + final fun invoke(): com.pasich.encly.core.security/ScreenshotAllowance // com.pasich.encly.core.security/ScreenshotAllowance.MetroFactory.invoke|invoke(){}[0] + + final object Companion { // com.pasich.encly.core.security/ScreenshotAllowance.MetroFactory.Companion|null[0] + final fun create(): com.pasich.encly.core.security/ScreenshotAllowance.MetroFactory // com.pasich.encly.core.security/ScreenshotAllowance.MetroFactory.Companion.create|create(){}[0] + final fun newInstance(): com.pasich.encly.core.security/ScreenshotAllowance // com.pasich.encly.core.security/ScreenshotAllowance.MetroFactory.Companion.newInstance|newInstance(){}[0] + } + } +} + +final class com.pasich.encly.core.security/SecurityManager { // com.pasich.encly.core.security/SecurityManager|null[0] + constructor (com.pasich.encly.core.platform/KeyValueStore, com.pasich.encly.core.security/VaultStore, com.pasich.encly.core.security/SeedPhraseManager, com.pasich.encly.core.security/VaultDatabase, com.pasich.encly.core.security/AuthenticationManager, com.pasich.encly.core.security/BiometricManager, com.pasich.encly.core.security/HandoffStagingSweeper = ...) // com.pasich.encly.core.security/SecurityManager.|(com.pasich.encly.core.platform.KeyValueStore;com.pasich.encly.core.security.VaultStore;com.pasich.encly.core.security.SeedPhraseManager;com.pasich.encly.core.security.VaultDatabase;com.pasich.encly.core.security.AuthenticationManager;com.pasich.encly.core.security.BiometricManager;com.pasich.encly.core.security.HandoffStagingSweeper){}[0] + + final var securityStatus // com.pasich.encly.core.security/SecurityManager.securityStatus|{}securityStatus[0] + final fun (): com.pasich.encly.core.security/InitialStatus // com.pasich.encly.core.security/SecurityManager.securityStatus.|(){}[0] + final fun (com.pasich.encly.core.security/InitialStatus) // com.pasich.encly.core.security/SecurityManager.securityStatus.|(com.pasich.encly.core.security.InitialStatus){}[0] + + final fun addRecoverySeed(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.addRecoverySeed|addRecoverySeed(kotlin.CharArray){}[0] + final fun authStrategy(): com.pasich.encly.core.security/AuthStrategy // com.pasich.encly.core.security/SecurityManager.authStrategy|authStrategy(){}[0] + final fun biometricAvailable(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.biometricAvailable|biometricAvailable(){}[0] + final fun biometricStatus(): com.pasich.encly.core.security/BiometricStatus // com.pasich.encly.core.security/SecurityManager.biometricStatus|biometricStatus(){}[0] + final fun canReauthWithBiometric(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.canReauthWithBiometric|canReauthWithBiometric(){}[0] + final fun canResetPinWithoutCurrent(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.canResetPinWithoutCurrent|canResetPinWithoutCurrent(){}[0] + final fun changePin(kotlin/CharArray): com.pasich.encly.core.security/PinChange // com.pasich.encly.core.security/SecurityManager.changePin|changePin(kotlin.CharArray){}[0] + final fun commitInitialSetup(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.commitInitialSetup|commitInitialSetup(){}[0] + final fun completePendingWipe() // com.pasich.encly.core.security/SecurityManager.completePendingWipe|completePendingWipe(){}[0] + final fun configureDuressPin(kotlin/CharArray): com.pasich.encly.core.security/DuressPinChange // com.pasich.encly.core.security/SecurityManager.configureDuressPin|configureDuressPin(kotlin.CharArray){}[0] + final fun configurePin(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.configurePin|configurePin(kotlin.CharArray){}[0] + final fun configureWipePin(kotlin/CharArray): com.pasich.encly.core.security/WipePinChange // com.pasich.encly.core.security/SecurityManager.configureWipePin|configureWipePin(kotlin.CharArray){}[0] + final fun confirmBiometric(com.pasich.encly.core.security/BiometricHost, kotlin/Function1) // com.pasich.encly.core.security/SecurityManager.confirmBiometric|confirmBiometric(com.pasich.encly.core.security.BiometricHost;kotlin.Function1){}[0] + final fun copyBackupRootKey(): kotlin/ByteArray? // com.pasich.encly.core.security/SecurityManager.copyBackupRootKey|copyBackupRootKey(){}[0] + final fun createBackupKey(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.createBackupKey|createBackupKey(kotlin.CharArray){}[0] + final fun disableBiometric() // com.pasich.encly.core.security/SecurityManager.disableBiometric|disableBiometric(){}[0] + final fun duressPinTurnedOff(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.duressPinTurnedOff|duressPinTurnedOff(){}[0] + final fun enrollBiometric(com.pasich.encly.core.security/BiometricHost, kotlin/Function1) // com.pasich.encly.core.security/SecurityManager.enrollBiometric|enrollBiometric(com.pasich.encly.core.security.BiometricHost;kotlin.Function1){}[0] + final fun eraseEpoch(): kotlin/Long // com.pasich.encly.core.security/SecurityManager.eraseEpoch|eraseEpoch(){}[0] + final fun finishInitialSetup(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.finishInitialSetup|finishInitialSetup(){}[0] + final fun generateMnemonicCode(): kotlin/CharArray // com.pasich.encly.core.security/SecurityManager.generateMnemonicCode|generateMnemonicCode(){}[0] + final fun getSettingsAuth(): com.pasich.encly.core.security/AuthSettings // com.pasich.encly.core.security/SecurityManager.getSettingsAuth|getSettingsAuth(){}[0] + final fun hasBackupKey(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.hasBackupKey|hasBackupKey(){}[0] + final fun hasDuressPin(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.hasDuressPin|hasDuressPin(){}[0] + final fun hasRecoverySeed(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.hasRecoverySeed|hasRecoverySeed(){}[0] + final fun initializeNewVault(kotlin/CharArray?): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.initializeNewVault|initializeNewVault(kotlin.CharArray?){}[0] + final fun isBiometricEnabled(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.isBiometricEnabled|isBiometricEnabled(){}[0] + final fun isDatabaseUnlocked(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.isDatabaseUnlocked|isDatabaseUnlocked(){}[0] + final fun isDecoySession(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.isDecoySession|isDecoySession(){}[0] + final fun isLockable(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.isLockable|isLockable(){}[0] + final fun isOnboardingShow(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.isOnboardingShow|isOnboardingShow(){}[0] + final fun isValidRecoveryPhrase(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.isValidRecoveryPhrase|isValidRecoveryPhrase(kotlin.CharArray){}[0] + final fun keepUnlockMethodsAfterRecovery(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.keepUnlockMethodsAfterRecovery|keepUnlockMethodsAfterRecovery(){}[0] + final fun lock() // com.pasich.encly.core.security/SecurityManager.lock|lock(){}[0] + final fun openInitialVault(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.openInitialVault|openInitialVault(){}[0] + final fun pinLockoutRemainingMillis(): kotlin/Long // com.pasich.encly.core.security/SecurityManager.pinLockoutRemainingMillis|pinLockoutRemainingMillis(){}[0] + final fun recoveryStage(): com.pasich.encly.core.security/RecoveryStage // com.pasich.encly.core.security/SecurityManager.recoveryStage|recoveryStage(){}[0] + final fun removeDuressPin(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.removeDuressPin|removeDuressPin(){}[0] + final fun removeWipePin(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.removeWipePin|removeWipePin(){}[0] + final fun replaceRecoverySeed(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.replaceRecoverySeed|replaceRecoverySeed(kotlin.CharArray){}[0] + final fun requestBiometricKey(com.pasich.encly.core.security/BiometricHost, kotlin/Function1) // com.pasich.encly.core.security/SecurityManager.requestBiometricKey|requestBiometricKey(com.pasich.encly.core.security.BiometricHost;kotlin.Function1){}[0] + final fun resolveInitialStatus(): com.pasich.encly.core.security/InitialStatus // com.pasich.encly.core.security/SecurityManager.resolveInitialStatus|resolveInitialStatus(){}[0] + final fun setOnboardingShown(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.setOnboardingShown|setOnboardingShown(){}[0] + final fun unlockWithPin(kotlin/CharArray): com.pasich.encly.core.security/VaultUnlockResult // com.pasich.encly.core.security/SecurityManager.unlockWithPin|unlockWithPin(kotlin.CharArray){}[0] + final fun unlockWithRawKey(kotlin/ByteArray, kotlin/Boolean = ...): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.unlockWithRawKey|unlockWithRawKey(kotlin.ByteArray;kotlin.Boolean){}[0] + final fun unlockWithSeed(kotlin/CharArray): com.pasich.encly.core.security/VaultUnlockResult // com.pasich.encly.core.security/SecurityManager.unlockWithSeed|unlockWithSeed(kotlin.CharArray){}[0] + final fun verifyPin(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.verifyPin|verifyPin(kotlin.CharArray){}[0] + final fun verifySeed(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.verifySeed|verifySeed(kotlin.CharArray){}[0] + final fun wipeAndReset(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.wipeAndReset|wipeAndReset(){}[0] + + final class MetroFactory : dev.zacsweers.metro.internal/Factory { // com.pasich.encly.core.security/SecurityManager.MetroFactory|null[0] + final fun declarationMirror(com.pasich.encly.core.platform/KeyValueStore, com.pasich.encly.core.security/VaultStore, com.pasich.encly.core.security/SeedPhraseManager, com.pasich.encly.core.security/VaultDatabase, com.pasich.encly.core.security/AuthenticationManager, com.pasich.encly.core.security/BiometricManager, com.pasich.encly.core.security/HandoffStagingSweeper): com.pasich.encly.core.security/SecurityManager // com.pasich.encly.core.security/SecurityManager.MetroFactory.declarationMirror|declarationMirror(com.pasich.encly.core.platform.KeyValueStore;com.pasich.encly.core.security.VaultStore;com.pasich.encly.core.security.SeedPhraseManager;com.pasich.encly.core.security.VaultDatabase;com.pasich.encly.core.security.AuthenticationManager;com.pasich.encly.core.security.BiometricManager;com.pasich.encly.core.security.HandoffStagingSweeper){}[0] + final fun invoke(): com.pasich.encly.core.security/SecurityManager // com.pasich.encly.core.security/SecurityManager.MetroFactory.invoke|invoke(){}[0] + + final object Companion { // com.pasich.encly.core.security/SecurityManager.MetroFactory.Companion|null[0] + final fun create(dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider): com.pasich.encly.core.security/SecurityManager.MetroFactory // com.pasich.encly.core.security/SecurityManager.MetroFactory.Companion.create|create(dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider){}[0] + final fun newInstance(com.pasich.encly.core.platform/KeyValueStore, com.pasich.encly.core.security/VaultStore, com.pasich.encly.core.security/SeedPhraseManager, com.pasich.encly.core.security/VaultDatabase, com.pasich.encly.core.security/AuthenticationManager, com.pasich.encly.core.security/BiometricManager, com.pasich.encly.core.security/HandoffStagingSweeper): com.pasich.encly.core.security/SecurityManager // com.pasich.encly.core.security/SecurityManager.MetroFactory.Companion.newInstance|newInstance(com.pasich.encly.core.platform.KeyValueStore;com.pasich.encly.core.security.VaultStore;com.pasich.encly.core.security.SeedPhraseManager;com.pasich.encly.core.security.VaultDatabase;com.pasich.encly.core.security.AuthenticationManager;com.pasich.encly.core.security.BiometricManager;com.pasich.encly.core.security.HandoffStagingSweeper){}[0] + } + } + + final object Companion // com.pasich.encly.core.security/SecurityManager.Companion|null[0] +} + +final class com.pasich.encly.core.security/SeedPhraseManager { // com.pasich.encly.core.security/SeedPhraseManager|null[0] + constructor (com.pasich.encly.core.security/VaultStore) // com.pasich.encly.core.security/SeedPhraseManager.|(com.pasich.encly.core.security.VaultStore){}[0] + + final fun addRecoverySeed(kotlin/CharArray, kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.addRecoverySeed|addRecoverySeed(kotlin.CharArray;kotlin.ByteArray){}[0] + final fun clearBootstrapKey() // com.pasich.encly.core.security/SeedPhraseManager.clearBootstrapKey|clearBootstrapKey(){}[0] + final fun copyBootstrapKey(): kotlin/ByteArray? // com.pasich.encly.core.security/SeedPhraseManager.copyBootstrapKey|copyBootstrapKey(){}[0] + final fun createBackupKey(kotlin/CharArray, kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.createBackupKey|createBackupKey(kotlin.CharArray;kotlin.ByteArray){}[0] + final fun generateMnemonic(): kotlin/CharArray // com.pasich.encly.core.security/SeedPhraseManager.generateMnemonic|generateMnemonic(){}[0] + final fun hasBackupKey(): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.hasBackupKey|hasBackupKey(){}[0] + final fun hasBootstrapKey(): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.hasBootstrapKey|hasBootstrapKey(){}[0] + final fun hasRecoverySeed(): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.hasRecoverySeed|hasRecoverySeed(){}[0] + final fun hasStoredSeed(): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.hasStoredSeed|hasStoredSeed(){}[0] + final fun initializeVault(kotlin/CharArray?): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.initializeVault|initializeVault(kotlin.CharArray?){}[0] + final fun isValidMnemonic(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.isValidMnemonic|isValidMnemonic(kotlin.CharArray){}[0] + final fun replaceRecoverySeed(kotlin/CharArray, kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.replaceRecoverySeed|replaceRecoverySeed(kotlin.CharArray;kotlin.ByteArray){}[0] + final fun unlockWithSeed(kotlin/CharArray): kotlin/ByteArray? // com.pasich.encly.core.security/SeedPhraseManager.unlockWithSeed|unlockWithSeed(kotlin.CharArray){}[0] + final fun unwrapBackupRoot(kotlin/ByteArray): kotlin/ByteArray? // com.pasich.encly.core.security/SeedPhraseManager.unwrapBackupRoot|unwrapBackupRoot(kotlin.ByteArray){}[0] + final fun verificationKeyData(): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.verificationKeyData|verificationKeyData(){}[0] + final fun verifyMnemonic(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SeedPhraseManager.verifyMnemonic|verifyMnemonic(kotlin.CharArray){}[0] + final fun wipe() // com.pasich.encly.core.security/SeedPhraseManager.wipe|wipe(){}[0] + + final class MetroFactory : dev.zacsweers.metro.internal/Factory { // com.pasich.encly.core.security/SeedPhraseManager.MetroFactory|null[0] + final fun declarationMirror(com.pasich.encly.core.security/VaultStore): com.pasich.encly.core.security/SeedPhraseManager // com.pasich.encly.core.security/SeedPhraseManager.MetroFactory.declarationMirror|declarationMirror(com.pasich.encly.core.security.VaultStore){}[0] + final fun invoke(): com.pasich.encly.core.security/SeedPhraseManager // com.pasich.encly.core.security/SeedPhraseManager.MetroFactory.invoke|invoke(){}[0] + + final object Companion { // com.pasich.encly.core.security/SeedPhraseManager.MetroFactory.Companion|null[0] + final fun create(dev.zacsweers.metro/Provider): com.pasich.encly.core.security/SeedPhraseManager.MetroFactory // com.pasich.encly.core.security/SeedPhraseManager.MetroFactory.Companion.create|create(dev.zacsweers.metro.Provider){}[0] + final fun newInstance(com.pasich.encly.core.security/VaultStore): com.pasich.encly.core.security/SeedPhraseManager // com.pasich.encly.core.security/SeedPhraseManager.MetroFactory.Companion.newInstance|newInstance(com.pasich.encly.core.security.VaultStore){}[0] + } + } + + final object Companion // com.pasich.encly.core.security/SeedPhraseManager.Companion|null[0] +} + +final class com.pasich.encly.core.security/SessionLockManager : androidx.lifecycle/DefaultLifecycleObserver, com.pasich.encly.core.security/VaultLockEvents { // com.pasich.encly.core.security/SessionLockManager|null[0] + constructor (com.pasich.encly.core.security/SecurityManager, com.pasich.encly.core.security/DeviceLockWatcher = ..., com.pasich.encly.core.security/LockoutClock = ..., com.pasich.encly.core.security/AutoLockPolicy = ..., com.pasich.encly.core.platform/MainThreadTimer = ..., com.pasich.encly.core.security/ScreenshotAllowance = ...) // com.pasich.encly.core.security/SessionLockManager.|(com.pasich.encly.core.security.SecurityManager;com.pasich.encly.core.security.DeviceLockWatcher;com.pasich.encly.core.security.LockoutClock;com.pasich.encly.core.security.AutoLockPolicy;com.pasich.encly.core.platform.MainThreadTimer;com.pasich.encly.core.security.ScreenshotAllowance){}[0] + + final val locked // com.pasich.encly.core.security/SessionLockManager.locked|{}locked[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.core.security/SessionLockManager.locked.|(){}[0] + + final var sessionGeneration // com.pasich.encly.core.security/SessionLockManager.sessionGeneration|{}sessionGeneration[0] + final fun (): kotlin/Long // com.pasich.encly.core.security/SessionLockManager.sessionGeneration.|(){}[0] + + final fun allowSystemPicker() // com.pasich.encly.core.security/SessionLockManager.allowSystemPicker|allowSystemPicker(){}[0] + final fun endSystemPicker() // com.pasich.encly.core.security/SessionLockManager.endSystemPicker|endSystemPicker(){}[0] + final fun isDeviceLocked(): kotlin/Boolean // com.pasich.encly.core.security/SessionLockManager.isDeviceLocked|isDeviceLocked(){}[0] + final fun lockNow() // com.pasich.encly.core.security/SessionLockManager.lockNow|lockNow(){}[0] + final fun lockRequests(): kotlin/Long // com.pasich.encly.core.security/SessionLockManager.lockRequests|lockRequests(){}[0] + final fun onStart(androidx.lifecycle/LifecycleOwner) // com.pasich.encly.core.security/SessionLockManager.onStart|onStart(androidx.lifecycle.LifecycleOwner){}[0] + final fun onStop(androidx.lifecycle/LifecycleOwner) // com.pasich.encly.core.security/SessionLockManager.onStop|onStop(androidx.lifecycle.LifecycleOwner){}[0] + final fun onUnlockAbandoned() // com.pasich.encly.core.security/SessionLockManager.onUnlockAbandoned|onUnlockAbandoned(){}[0] + final fun onUnlocked(kotlin/Long = ...): kotlin/Boolean // com.pasich.encly.core.security/SessionLockManager.onUnlocked|onUnlocked(kotlin.Long){}[0] + final fun registerPreLockFlush(kotlin.coroutines/SuspendFunction0): kotlin/Function0 // com.pasich.encly.core.security/SessionLockManager.registerPreLockFlush|registerPreLockFlush(kotlin.coroutines.SuspendFunction0){}[0] + final fun requireUnlock() // com.pasich.encly.core.security/SessionLockManager.requireUnlock|requireUnlock(){}[0] + + final class MetroFactory : dev.zacsweers.metro.internal/Factory { // com.pasich.encly.core.security/SessionLockManager.MetroFactory|null[0] + final fun declarationMirror(com.pasich.encly.core.security/SecurityManager, com.pasich.encly.core.security/DeviceLockWatcher, com.pasich.encly.core.security/LockoutClock, com.pasich.encly.core.security/AutoLockPolicy, com.pasich.encly.core.platform/MainThreadTimer, com.pasich.encly.core.security/ScreenshotAllowance): com.pasich.encly.core.security/SessionLockManager // com.pasich.encly.core.security/SessionLockManager.MetroFactory.declarationMirror|declarationMirror(com.pasich.encly.core.security.SecurityManager;com.pasich.encly.core.security.DeviceLockWatcher;com.pasich.encly.core.security.LockoutClock;com.pasich.encly.core.security.AutoLockPolicy;com.pasich.encly.core.platform.MainThreadTimer;com.pasich.encly.core.security.ScreenshotAllowance){}[0] + final fun invoke(): com.pasich.encly.core.security/SessionLockManager // com.pasich.encly.core.security/SessionLockManager.MetroFactory.invoke|invoke(){}[0] + + final object Companion { // com.pasich.encly.core.security/SessionLockManager.MetroFactory.Companion|null[0] + final fun create(dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider, dev.zacsweers.metro/Provider): com.pasich.encly.core.security/SessionLockManager.MetroFactory // com.pasich.encly.core.security/SessionLockManager.MetroFactory.Companion.create|create(dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider;dev.zacsweers.metro.Provider){}[0] + final fun newInstance(com.pasich.encly.core.security/SecurityManager, com.pasich.encly.core.security/DeviceLockWatcher, com.pasich.encly.core.security/LockoutClock, com.pasich.encly.core.security/AutoLockPolicy, com.pasich.encly.core.platform/MainThreadTimer, com.pasich.encly.core.security/ScreenshotAllowance): com.pasich.encly.core.security/SessionLockManager // com.pasich.encly.core.security/SessionLockManager.MetroFactory.Companion.newInstance|newInstance(com.pasich.encly.core.security.SecurityManager;com.pasich.encly.core.security.DeviceLockWatcher;com.pasich.encly.core.security.LockoutClock;com.pasich.encly.core.security.AutoLockPolicy;com.pasich.encly.core.platform.MainThreadTimer;com.pasich.encly.core.security.ScreenshotAllowance){}[0] + } + } +} + +final class com.pasich.encly.core.security/VaultStore { // com.pasich.encly.core.security/VaultStore|null[0] + constructor (com.pasich.encly.core.security/VaultFileIo) // com.pasich.encly.core.security/VaultStore.|(com.pasich.encly.core.security.VaultFileIo){}[0] + + final fun clear(): kotlin/Boolean // com.pasich.encly.core.security/VaultStore.clear|clear(){}[0] + final fun contains(kotlin/String): kotlin/Boolean // com.pasich.encly.core.security/VaultStore.contains|contains(kotlin.String){}[0] + final fun edit(kotlin/Function1): kotlin/Boolean // com.pasich.encly.core.security/VaultStore.edit|edit(kotlin.Function1){}[0] + final fun getBoolean(kotlin/String, kotlin/Boolean): kotlin/Boolean // com.pasich.encly.core.security/VaultStore.getBoolean|getBoolean(kotlin.String;kotlin.Boolean){}[0] + final fun getBytes(kotlin/String): kotlin/ByteArray? // com.pasich.encly.core.security/VaultStore.getBytes|getBytes(kotlin.String){}[0] + final fun getInt(kotlin/String, kotlin/Int): kotlin/Int // com.pasich.encly.core.security/VaultStore.getInt|getInt(kotlin.String;kotlin.Int){}[0] + final fun getLong(kotlin/String, kotlin/Long): kotlin/Long // com.pasich.encly.core.security/VaultStore.getLong|getLong(kotlin.String;kotlin.Long){}[0] + final fun isCorrupt(): kotlin/Boolean // com.pasich.encly.core.security/VaultStore.isCorrupt|isCorrupt(){}[0] + final fun isUnreadable(): kotlin/Boolean // com.pasich.encly.core.security/VaultStore.isUnreadable|isUnreadable(){}[0] + + final class Editor { // com.pasich.encly.core.security/VaultStore.Editor|null[0] + final fun putBoolean(kotlin/String, kotlin/Boolean) // com.pasich.encly.core.security/VaultStore.Editor.putBoolean|putBoolean(kotlin.String;kotlin.Boolean){}[0] + final fun putBytes(kotlin/String, kotlin/ByteArray) // com.pasich.encly.core.security/VaultStore.Editor.putBytes|putBytes(kotlin.String;kotlin.ByteArray){}[0] + final fun putInt(kotlin/String, kotlin/Int) // com.pasich.encly.core.security/VaultStore.Editor.putInt|putInt(kotlin.String;kotlin.Int){}[0] + final fun putLong(kotlin/String, kotlin/Long) // com.pasich.encly.core.security/VaultStore.Editor.putLong|putLong(kotlin.String;kotlin.Long){}[0] + final fun remove(kotlin/String) // com.pasich.encly.core.security/VaultStore.Editor.remove|remove(kotlin.String){}[0] + final fun removePrefix(kotlin/String) // com.pasich.encly.core.security/VaultStore.Editor.removePrefix|removePrefix(kotlin.String){}[0] + } +} + +sealed class com.pasich.encly.core.backup/BackupSecret { // com.pasich.encly.core.backup/BackupSecret|null[0] + final class RecoveryPhrase : com.pasich.encly.core.backup/BackupSecret { // com.pasich.encly.core.backup/BackupSecret.RecoveryPhrase|null[0] + constructor (kotlin/CharArray) // com.pasich.encly.core.backup/BackupSecret.RecoveryPhrase.|(kotlin.CharArray){}[0] + + final val words // com.pasich.encly.core.backup/BackupSecret.RecoveryPhrase.words|{}words[0] + final fun (): kotlin/CharArray // com.pasich.encly.core.backup/BackupSecret.RecoveryPhrase.words.|(){}[0] + } + + final class RecoveryRoot : com.pasich.encly.core.backup/BackupSecret { // com.pasich.encly.core.backup/BackupSecret.RecoveryRoot|null[0] + constructor (kotlin/ByteArray) // com.pasich.encly.core.backup/BackupSecret.RecoveryRoot.|(kotlin.ByteArray){}[0] + + final val root // com.pasich.encly.core.backup/BackupSecret.RecoveryRoot.root|{}root[0] + final fun (): kotlin/ByteArray // com.pasich.encly.core.backup/BackupSecret.RecoveryRoot.root.|(){}[0] + } +} + +final object com.pasich.encly.core.backup/BackupCipher { // com.pasich.encly.core.backup/BackupCipher|null[0] + final fun inspect(kotlin/ByteArray): com.pasich.encly.core.backup/BackupHeader // com.pasich.encly.core.backup/BackupCipher.inspect|inspect(kotlin.ByteArray){}[0] + final fun open(kotlin/ByteArray, com.pasich.encly.core.backup/BackupSecret): kotlin/ByteArray // com.pasich.encly.core.backup/BackupCipher.open|open(kotlin.ByteArray;com.pasich.encly.core.backup.BackupSecret){}[0] + final fun seal(kotlin/ByteArray, com.pasich.encly.core.backup/BackupSecret): kotlin/ByteArray // com.pasich.encly.core.backup/BackupCipher.seal|seal(kotlin.ByteArray;com.pasich.encly.core.backup.BackupSecret){}[0] +} + +final object com.pasich.encly.core.backup/BackupFlags { // com.pasich.encly.core.backup/BackupFlags|null[0] + final const val LAST_EXPORT_KEY // com.pasich.encly.core.backup/BackupFlags.LAST_EXPORT_KEY|{}LAST_EXPORT_KEY[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupFlags.LAST_EXPORT_KEY.|(){}[0] +} + +final object com.pasich.encly.core.backup/BackupFormat { // com.pasich.encly.core.backup/BackupFormat|null[0] + final const val FILE_EXTENSION // com.pasich.encly.core.backup/BackupFormat.FILE_EXTENSION|{}FILE_EXTENSION[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupFormat.FILE_EXTENSION.|(){}[0] + final const val HEADER_LENGTH // com.pasich.encly.core.backup/BackupFormat.HEADER_LENGTH|{}HEADER_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupFormat.HEADER_LENGTH.|(){}[0] + final const val MAX_FILE_BYTES // com.pasich.encly.core.backup/BackupFormat.MAX_FILE_BYTES|{}MAX_FILE_BYTES[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupFormat.MAX_FILE_BYTES.|(){}[0] + final const val MIME_TYPE // com.pasich.encly.core.backup/BackupFormat.MIME_TYPE|{}MIME_TYPE[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupFormat.MIME_TYPE.|(){}[0] + final const val NONCE_LENGTH // com.pasich.encly.core.backup/BackupFormat.NONCE_LENGTH|{}NONCE_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupFormat.NONCE_LENGTH.|(){}[0] + final const val SALT_LENGTH // com.pasich.encly.core.backup/BackupFormat.SALT_LENGTH|{}SALT_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupFormat.SALT_LENGTH.|(){}[0] + final const val TAG_LENGTH // com.pasich.encly.core.backup/BackupFormat.TAG_LENGTH|{}TAG_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupFormat.TAG_LENGTH.|(){}[0] + final const val VERSION // com.pasich.encly.core.backup/BackupFormat.VERSION|{}VERSION[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupFormat.VERSION.|(){}[0] + + final val MAGIC // com.pasich.encly.core.backup/BackupFormat.MAGIC|{}MAGIC[0] + final fun (): kotlin/ByteArray // com.pasich.encly.core.backup/BackupFormat.MAGIC.|(){}[0] + + final fun encodeHeader(com.pasich.encly.core.backup/BackupKdf, kotlin/ByteArray, kotlin/ByteArray, kotlin/Int): kotlin/ByteArray // com.pasich.encly.core.backup/BackupFormat.encodeHeader|encodeHeader(com.pasich.encly.core.backup.BackupKdf;kotlin.ByteArray;kotlin.ByteArray;kotlin.Int){}[0] + final fun fileSizeFor(kotlin/Int): kotlin/Long // com.pasich.encly.core.backup/BackupFormat.fileSizeFor|fileSizeFor(kotlin.Int){}[0] + final fun parse(kotlin/ByteArray): com.pasich.encly.core.backup/ParsedBackup // com.pasich.encly.core.backup/BackupFormat.parse|parse(kotlin.ByteArray){}[0] + final fun parseHeader(kotlin/ByteArray, kotlin/Long = ...): com.pasich.encly.core.backup/BackupHeader // com.pasich.encly.core.backup/BackupFormat.parseHeader|parseHeader(kotlin.ByteArray;kotlin.Long){}[0] +} + +final object com.pasich.encly.core.backup/BackupKeys { // com.pasich.encly.core.backup/BackupKeys|null[0] + final const val KEY_LENGTH // com.pasich.encly.core.backup/BackupKeys.KEY_LENGTH|{}KEY_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupKeys.KEY_LENGTH.|(){}[0] + + final fun fileKeyFromRoot(kotlin/ByteArray, kotlin/ByteArray): kotlin/ByteArray // com.pasich.encly.core.backup/BackupKeys.fileKeyFromRoot|fileKeyFromRoot(kotlin.ByteArray;kotlin.ByteArray){}[0] + final fun normalizeMnemonic(kotlin/CharArray): kotlin/CharArray // com.pasich.encly.core.backup/BackupKeys.normalizeMnemonic|normalizeMnemonic(kotlin.CharArray){}[0] + final fun rootFromMnemonic(kotlin/CharArray): kotlin/ByteArray // com.pasich.encly.core.backup/BackupKeys.rootFromMnemonic|rootFromMnemonic(kotlin.CharArray){}[0] +} + +final object com.pasich.encly.core.backup/BackupPayloadCodec { // com.pasich.encly.core.backup/BackupPayloadCodec|null[0] + final const val MAX_COLOR_LENGTH // com.pasich.encly.core.backup/BackupPayloadCodec.MAX_COLOR_LENGTH|{}MAX_COLOR_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayloadCodec.MAX_COLOR_LENGTH.|(){}[0] + final const val MAX_UID_LENGTH // com.pasich.encly.core.backup/BackupPayloadCodec.MAX_UID_LENGTH|{}MAX_UID_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupPayloadCodec.MAX_UID_LENGTH.|(){}[0] + + final fun decode(kotlin/ByteArray): com.pasich.encly.core.backup/BackupPayload // com.pasich.encly.core.backup/BackupPayloadCodec.decode|decode(kotlin.ByteArray){}[0] + final fun encode(com.pasich.encly.core.backup/BackupPayload): kotlin/ByteArray // com.pasich.encly.core.backup/BackupPayloadCodec.encode|encode(com.pasich.encly.core.backup.BackupPayload){}[0] + final fun validate(com.pasich.encly.core.backup/BackupPayload) // com.pasich.encly.core.backup/BackupPayloadCodec.validate|validate(com.pasich.encly.core.backup.BackupPayload){}[0] +} + +final object com.pasich.encly.core.backup/BackupSettings { // com.pasich.encly.core.backup/BackupSettings|null[0] + final const val APP_LANGUAGE // com.pasich.encly.core.backup/BackupSettings.APP_LANGUAGE|{}APP_LANGUAGE[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.APP_LANGUAGE.|(){}[0] + final const val AUTO_LOCK_DELAY // com.pasich.encly.core.backup/BackupSettings.AUTO_LOCK_DELAY|{}AUTO_LOCK_DELAY[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.AUTO_LOCK_DELAY.|(){}[0] + final const val EDITOR_TOOLBAR // com.pasich.encly.core.backup/BackupSettings.EDITOR_TOOLBAR|{}EDITOR_TOOLBAR[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.EDITOR_TOOLBAR.|(){}[0] + final const val FONT_SIZE // com.pasich.encly.core.backup/BackupSettings.FONT_SIZE|{}FONT_SIZE[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.FONT_SIZE.|(){}[0] + final const val FONT_STYLE // com.pasich.encly.core.backup/BackupSettings.FONT_STYLE|{}FONT_STYLE[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.FONT_STYLE.|(){}[0] + final const val IS_DYNAMIC_THEME // com.pasich.encly.core.backup/BackupSettings.IS_DYNAMIC_THEME|{}IS_DYNAMIC_THEME[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.IS_DYNAMIC_THEME.|(){}[0] + final const val IS_GRID_NOTE_LIST // com.pasich.encly.core.backup/BackupSettings.IS_GRID_NOTE_LIST|{}IS_GRID_NOTE_LIST[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.IS_GRID_NOTE_LIST.|(){}[0] + final const val KEYBOARD_STRICT_PRIVACY // com.pasich.encly.core.backup/BackupSettings.KEYBOARD_STRICT_PRIVACY|{}KEYBOARD_STRICT_PRIVACY[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.KEYBOARD_STRICT_PRIVACY.|(){}[0] + final const val MAX_ENTRIES // com.pasich.encly.core.backup/BackupSettings.MAX_ENTRIES|{}MAX_ENTRIES[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupSettings.MAX_ENTRIES.|(){}[0] + final const val MAX_FONT_SIZE // com.pasich.encly.core.backup/BackupSettings.MAX_FONT_SIZE|{}MAX_FONT_SIZE[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupSettings.MAX_FONT_SIZE.|(){}[0] + final const val MIN_FONT_SIZE // com.pasich.encly.core.backup/BackupSettings.MIN_FONT_SIZE|{}MIN_FONT_SIZE[0] + final fun (): kotlin/Int // com.pasich.encly.core.backup/BackupSettings.MIN_FONT_SIZE.|(){}[0] + final const val NOTE_LINE_SPACING // com.pasich.encly.core.backup/BackupSettings.NOTE_LINE_SPACING|{}NOTE_LINE_SPACING[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.NOTE_LINE_SPACING.|(){}[0] + final const val SHOW_NOTE_PREVIEWS // com.pasich.encly.core.backup/BackupSettings.SHOW_NOTE_PREVIEWS|{}SHOW_NOTE_PREVIEWS[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.SHOW_NOTE_PREVIEWS.|(){}[0] + final const val SHOW_TASKS // com.pasich.encly.core.backup/BackupSettings.SHOW_TASKS|{}SHOW_TASKS[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.SHOW_TASKS.|(){}[0] + final const val SIMPLE_EDIT // com.pasich.encly.core.backup/BackupSettings.SIMPLE_EDIT|{}SIMPLE_EDIT[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.SIMPLE_EDIT.|(){}[0] + final const val SORT // com.pasich.encly.core.backup/BackupSettings.SORT|{}SORT[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.SORT.|(){}[0] + final const val SWIPE_ENABLED // com.pasich.encly.core.backup/BackupSettings.SWIPE_ENABLED|{}SWIPE_ENABLED[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.SWIPE_ENABLED.|(){}[0] + final const val SWIPE_LEFT // com.pasich.encly.core.backup/BackupSettings.SWIPE_LEFT|{}SWIPE_LEFT[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.SWIPE_LEFT.|(){}[0] + final const val SWIPE_RIGHT // com.pasich.encly.core.backup/BackupSettings.SWIPE_RIGHT|{}SWIPE_RIGHT[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.SWIPE_RIGHT.|(){}[0] + final const val THEME_PALETTE // com.pasich.encly.core.backup/BackupSettings.THEME_PALETTE|{}THEME_PALETTE[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.THEME_PALETTE.|(){}[0] + final const val THEME_TYPE // com.pasich.encly.core.backup/BackupSettings.THEME_TYPE|{}THEME_TYPE[0] + final fun (): kotlin/String // com.pasich.encly.core.backup/BackupSettings.THEME_TYPE.|(){}[0] + + final val FONT_STYLES // com.pasich.encly.core.backup/BackupSettings.FONT_STYLES|{}FONT_STYLES[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.FONT_STYLES.|(){}[0] + final val LANGUAGES // com.pasich.encly.core.backup/BackupSettings.LANGUAGES|{}LANGUAGES[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.LANGUAGES.|(){}[0] + final val LINE_SPACINGS // com.pasich.encly.core.backup/BackupSettings.LINE_SPACINGS|{}LINE_SPACINGS[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.LINE_SPACINGS.|(){}[0] + final val SORT_OPTIONS // com.pasich.encly.core.backup/BackupSettings.SORT_OPTIONS|{}SORT_OPTIONS[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.SORT_OPTIONS.|(){}[0] + final val SWIPE_ACTIONS // com.pasich.encly.core.backup/BackupSettings.SWIPE_ACTIONS|{}SWIPE_ACTIONS[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.SWIPE_ACTIONS.|(){}[0] + final val THEME_PALETTES // com.pasich.encly.core.backup/BackupSettings.THEME_PALETTES|{}THEME_PALETTES[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.THEME_PALETTES.|(){}[0] + final val THEME_TYPES // com.pasich.encly.core.backup/BackupSettings.THEME_TYPES|{}THEME_TYPES[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.THEME_TYPES.|(){}[0] + final val TOOLBAR_SECTIONS // com.pasich.encly.core.backup/BackupSettings.TOOLBAR_SECTIONS|{}TOOLBAR_SECTIONS[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.TOOLBAR_SECTIONS.|(){}[0] + final val TOOLBAR_TOOLS // com.pasich.encly.core.backup/BackupSettings.TOOLBAR_TOOLS|{}TOOLBAR_TOOLS[0] + final fun (): kotlin.collections/List // com.pasich.encly.core.backup/BackupSettings.TOOLBAR_TOOLS.|(){}[0] + final val excluded // com.pasich.encly.core.backup/BackupSettings.excluded|{}excluded[0] + final fun (): kotlin.collections/Map // com.pasich.encly.core.backup/BackupSettings.excluded.|(){}[0] + final val included // com.pasich.encly.core.backup/BackupSettings.included|{}included[0] + final fun (): kotlin.collections/Map // com.pasich.encly.core.backup/BackupSettings.included.|(){}[0] + + final fun accepted(kotlin.collections/Map): kotlin.collections/Map // com.pasich.encly.core.backup/BackupSettings.accepted|accepted(kotlin.collections.Map){}[0] + + final class SettingKind { // com.pasich.encly.core.backup/BackupSettings.SettingKind|null[0] + final fun accepts(kotlin/String): kotlin/Boolean // com.pasich.encly.core.backup/BackupSettings.SettingKind.accepts|accepts(kotlin.String){}[0] + } +} + +final object com.pasich.encly.core.security/JvmMonotonicClock : com.pasich.encly.core.security/LockoutClock { // com.pasich.encly.core.security/JvmMonotonicClock|null[0] + final fun bootCount(): kotlin/Int // com.pasich.encly.core.security/JvmMonotonicClock.bootCount|bootCount(){}[0] + final fun elapsedRealtime(): kotlin/Long // com.pasich.encly.core.security/JvmMonotonicClock.elapsedRealtime|elapsedRealtime(){}[0] +} + +final object com.pasich.encly.core.security/Mnemonic { // com.pasich.encly.core.security/Mnemonic|null[0] + final fun generate(): kotlin/CharArray // com.pasich.encly.core.security/Mnemonic.generate|generate(){}[0] +} + +final object com.pasich.encly.core.security/NeverLocked : com.pasich.encly.core.security/VaultLockEvents { // com.pasich.encly.core.security/NeverLocked|null[0] + final val locked // com.pasich.encly.core.security/NeverLocked.locked|{}locked[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.core.security/NeverLocked.locked.|(){}[0] +} + +final object com.pasich.encly.core.security/RecoveryWords { // com.pasich.encly.core.security/RecoveryWords|null[0] + final const val COUNT // com.pasich.encly.core.security/RecoveryWords.COUNT|{}COUNT[0] + final fun (): kotlin/Int // com.pasich.encly.core.security/RecoveryWords.COUNT.|(){}[0] + + final fun isPrefix(kotlin/String): kotlin/Boolean // com.pasich.encly.core.security/RecoveryWords.isPrefix|isPrefix(kotlin.String){}[0] + final fun isValidPhrase(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/RecoveryWords.isValidPhrase|isValidPhrase(kotlin.CharArray){}[0] + final fun isWord(kotlin/String): kotlin/Boolean // com.pasich.encly.core.security/RecoveryWords.isWord|isWord(kotlin.String){}[0] +} + +final const val com.pasich.encly.core.security/LOCK_FLUSH_TIMEOUT_MS // com.pasich.encly.core.security/LOCK_FLUSH_TIMEOUT_MS|{}LOCK_FLUSH_TIMEOUT_MS[0] + final fun (): kotlin/Long // com.pasich.encly.core.security/LOCK_FLUSH_TIMEOUT_MS.|(){}[0] +final const val com.pasich.encly.core.security/MAX_PIN_LENGTH // com.pasich.encly.core.security/MAX_PIN_LENGTH|{}MAX_PIN_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.security/MAX_PIN_LENGTH.|(){}[0] +final const val com.pasich.encly.core.security/MIN_PIN_LENGTH // com.pasich.encly.core.security/MIN_PIN_LENGTH|{}MIN_PIN_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.security/MIN_PIN_LENGTH.|(){}[0] diff --git a/core/security/build.gradle.kts b/core/security/build.gradle.kts new file mode 100644 index 00000000..09da64a7 --- /dev/null +++ b/core/security/build.gradle.kts @@ -0,0 +1,96 @@ +import dev.zacsweers.metro.gradle.OptionalBindingBehavior +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit + +// The platform-free security core: the key hierarchy (vault store, PIN, wipe-PIN, duress and recovery +// slots), the unlock and lockout logic, the session lock, and the backup file format and cipher. It +// is written against interfaces for everything a platform provides (the flag store, the hardware PIN +// factor, the biometric prompt, the lockout clock, the device lock watcher, the vault file's fsync, the +// encrypted database); :app implements them with Android types and binds them in its graph. +// +// iOS (device and simulator): the same common code over :core:crypto's Apple implementation; iosMain adds the +// POSIX vault file (IosVaultFileIo) and the backup JSON as wiped bytes. The golden tests of every persisted +// format are in commonTest, so they run on the simulator too. +plugins { + id("encly.kmp-library") + id("encly.kmp-ios") + id("encly.quality") + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.metro) +} + +// Findings that predate the move out of the app are captured in detekt-baseline.xml, which should only +// ever shrink. +detekt { + baseline = file("detekt-baseline.xml") +} + +// Minimum line coverage (%) of the whole module, enforced by koverVerify. +val securityMinCoverage = 85 + +// The same as in :app. A Kotlin default on an injected constructor parameter stays a plain default: the +// parameter is always taken from the graph, and a missing binding is an error instead of silently falling +// back to the default (e.g. a lock event source that never locks). +metro { + optionalBindingBehavior.set(OptionalBindingBehavior.DISABLED) +} + +kotlin { + android { + namespace = "com.pasich.encly.core.security" + } + + sourceSets { + commonMain.dependencies { + api(project(":core:common")) + api(project(":core:crypto")) + // IOException is part of the contract of VaultFileIo. + api(libs.kotlinx.io.core) + // SessionLockManager is a lifecycle observer. + api(libs.androidx.lifecycle.common) + // The backup payload is @Serializable. + api(libs.kotlinx.serialization.json) + // SynchronizedObject and synchronized {}: the locks of the stores and managers. + implementation(libs.kotlinx.atomicfu) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + getByName("iosMain").dependencies { + // The backup payload goes through a kotlinx-io Buffer on iOS (see IosJsonBytes). + implementation(libs.kotlinx.serialization.json.io) + } + // JUnit and Mockito run on both JVM targets: the tests that need java.io, threads or mocks. + getByName("jvmAndAndroidTest").dependencies { + implementation(libs.junit) + implementation(libs.mockito.core) + } + } +} + +kover { + reports { + total { + filters { + // Code the Metro compiler plugin writes, not this project. + excludes { + classes( + "*MetroFactory*", + "*MetroContributionTo*", + "*MetroMembersInjector*", + ) + } + } + xml { onCheck.set(false) } + html { onCheck.set(false) } + verify { + rule("Security line coverage") { + bound { + minValue = securityMinCoverage + coverageUnits = CoverageUnit.LINE + } + } + } + } + } +} diff --git a/core/security/detekt-baseline.xml b/core/security/detekt-baseline.xml new file mode 100644 index 00000000..4ea5c010 --- /dev/null +++ b/core/security/detekt-baseline.xml @@ -0,0 +1,15 @@ + + + + + MagicNumber:AuthenticationManager.kt$AuthenticationManager$15 + MagicNumber:AuthenticationManager.kt$AuthenticationManager$256 + MagicNumber:AuthenticationManager.kt$AuthenticationManager$30_000L + MagicNumber:AuthenticationManager.kt$AuthenticationManager$5 + MagicNumber:AuthenticationManager.kt$AuthenticationManager$60_000L + ReturnCount:SecurityManager.kt$SecurityManager$private fun initializeSecurity(): InitialStatus + TooManyFunctions:AuthenticationManager.kt$AuthenticationManager + TooManyFunctions:SecurityManager.kt$SecurityManager + TooManyFunctions:SeedPhraseManager.kt$SeedPhraseManager + + diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupCipher.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupCipher.kt new file mode 100644 index 00000000..b8182c94 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupCipher.kt @@ -0,0 +1,78 @@ +package com.pasich.encly.core.backup + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.security.SensitiveDataCleaner + +/** + * What the per-file key is derived from. Both forms describe the same recovery phrase; the + * caller keeps ownership of the wrapped arrays and wipes them. [BackupCipher] never retains them. + */ +public sealed class BackupSecret { + /** The 12 BIP39 words, as typed (import on a fresh install or in Settings). */ + public class RecoveryPhrase(public val words: CharArray) : BackupSecret() + + /** The vault's stored `backupRoot` (export from an unlocked vault). See [BackupKeys]. */ + public class RecoveryRoot(public val root: ByteArray) : BackupSecret() +} + +/** + * Seals a plaintext payload into the [BackupFormat] container and opens it again. + * Derived keys are zeroized before returning, on success and on failure. + */ +public object BackupCipher { + public fun seal(plaintext: ByteArray, secret: BackupSecret): ByteArray { + val salt = Crypto.randomBytes(BackupFormat.SALT_LENGTH) + val nonce = Crypto.randomBytes(BackupFormat.NONCE_LENGTH) + val header = BackupFormat.encodeHeader( + kdf = BackupKdf.RECOVERY_PHRASE_HKDF_SHA256, + salt = salt, + nonce = nonce, + ciphertextLength = plaintext.size + BackupFormat.TAG_LENGTH, + ) + val key = deriveKey(secret, salt) + return try { + header + Crypto.aesGcmSeal(key, nonce, header, plaintext) + } finally { + SensitiveDataCleaner.clear(key) + } + } + + /** Parses the header only: rejects non-backups and unsupported versions before any typing. */ + public fun inspect(file: ByteArray): BackupHeader = BackupFormat.parse(file).header + + /** + * Authenticates and decrypts [file]. Returns the plaintext payload, which the caller owns + * and must wipe. Throws [BackupException] on any structural or authentication failure. + */ + public fun open(file: ByteArray, secret: BackupSecret): ByteArray { + val parsed = BackupFormat.parse(file) + val key = deriveKey(secret, parsed.header.salt) + return try { + // The library takes whole arrays, so the header and the ciphertext are copied out of the + // file (neither is secret): a large backup's ciphertext is held in memory twice. + val header = parsed.file.copyOf(BackupFormat.HEADER_LENGTH) + val ciphertext = parsed.file.copyOfRange( + parsed.ciphertextOffset, + parsed.ciphertextOffset + parsed.header.ciphertextLength, + ) + // Null: wrong recovery phrase, or a modified header/ciphertext. + Crypto.aesGcmOpen(key, parsed.header.nonce, header, ciphertext) + ?: throw BackupException(BackupError.WRONG_SECRET) + } finally { + SensitiveDataCleaner.clear(key) + } + } + + private fun deriveKey(secret: BackupSecret, salt: ByteArray): ByteArray = when (secret) { + is BackupSecret.RecoveryRoot -> BackupKeys.fileKeyFromRoot(secret.root, salt) + + is BackupSecret.RecoveryPhrase -> { + val root = BackupKeys.rootFromMnemonic(secret.words) + try { + BackupKeys.fileKeyFromRoot(root, salt) + } finally { + SensitiveDataCleaner.clear(root) + } + } + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupFlags.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupFlags.kt new file mode 100644 index 00000000..48eee4e0 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupFlags.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.core.backup + +/** Keys of the backup flags in the app's non-secret flag store. */ +public object BackupFlags { + /** When the last backup was exported (epoch milliseconds); forgotten when the vault is erased. */ + public const val LAST_EXPORT_KEY: String = "backup_last_export_at" +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupFormat.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupFormat.kt new file mode 100644 index 00000000..b5fcd820 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupFormat.kt @@ -0,0 +1,177 @@ +package com.pasich.encly.core.backup + +import kotlinx.io.Buffer +import kotlinx.io.readByteArray + +/** Why a backup could not be written or read. Never carries secrets or plaintext. */ +public enum class BackupError { + /** Not an Encly backup (wrong magic). */ + NOT_A_BACKUP, + + /** An Encly backup written by a newer (or unknown) format version. */ + UNSUPPORTED_VERSION, + + /** The file ends before the header or ciphertext does. */ + TRUNCATED, + + /** Malformed header fields or trailing bytes after the ciphertext. */ + CORRUPTED, + + /** + * Larger than [BackupFormat.MAX_FILE_BYTES]: on import the file, on export the vault, which + * is refused rather than written as a file no install could read back. + */ + TOO_LARGE, + + /** Export target already holds data; it is never overwritten (see BackupDocuments.write). */ + TARGET_EXISTS, + + /** AES-GCM authentication failed: wrong recovery phrase, or a modified header/ciphertext. */ + WRONG_SECRET, + + /** Authenticated, but the payload is not a valid Encly backup payload. */ + INVALID_PAYLOAD, + + /** The words are not a valid 12-word BIP39 recovery phrase. */ + INVALID_PHRASE, + + /** The vault could not provide the backup key (e.g. it was locked meanwhile). */ + VAULT_UNAVAILABLE, + + /** Reading, writing or importing failed for a reason other than the above. */ + IO, +} + +public class BackupException(public val error: BackupError, cause: Throwable? = null) : Exception(error.name, cause) + +/** + * How the per-file key is derived; stored in (and authenticated with) the header so a future + * format can add another derivation without guessing. + */ +public enum class BackupKdf(public val id: Int) { + /** HKDF-SHA256 from the BIP39 recovery phrase. See [BackupKeys]. */ + RECOVERY_PHRASE_HKDF_SHA256(1), + ; + + public companion object { + public fun fromId(id: Int): BackupKdf? = entries.firstOrNull { it.id == id } + } +} + +public class BackupHeader( + public val formatVersion: Int, + public val kdf: BackupKdf, + public val salt: ByteArray, + public val nonce: ByteArray, + public val ciphertextLength: Int, +) + +/** + * A parsed file: the header plus the whole [file], which it does not copy. The first + * [BackupFormat.HEADER_LENGTH] bytes are the GCM AAD; the ciphertext with tag follows them. + */ +public class ParsedBackup(public val header: BackupHeader, public val file: ByteArray) { + public val ciphertextOffset: Int get() = BackupFormat.HEADER_LENGTH +} + +/** + * Encly backup container, format version 1. All integers are unsigned big-endian. + * + * | offset | size | field | + * |-------:|-----:|--------------------------------------------------------| + * | 0 | 8 | magic `ENCLYBAK` (ASCII) | + * | 8 | 2 | format version (= 1) | + * | 10 | 1 | KDF id (1 = HKDF-SHA256 from the recovery phrase) | + * | 11 | 1 | salt length (= 32) | + * | 12 | 32 | HKDF salt (random per file) | + * | 44 | 1 | nonce length (= 12) | + * | 45 | 12 | AES-256-GCM nonce (random per file) | + * | 57 | 4 | ciphertext length N, including the 16-byte GCM tag | + * | 61 | N | AES-256-GCM ciphertext ‖ tag of the UTF-8 JSON payload | + * + * The 61 header bytes are the GCM additional authenticated data, so changing any header + * field (version, KDF, salt, nonce, length) fails authentication. Nothing may + * follow the ciphertext. + */ +public object BackupFormat { + public val MAGIC: ByteArray = "ENCLYBAK".encodeToByteArray() + public const val VERSION: Int = 1 + public const val SALT_LENGTH: Int = 32 + public const val NONCE_LENGTH: Int = 12 + public const val TAG_LENGTH: Int = 16 + public const val HEADER_LENGTH: Int = 61 + public const val MAX_FILE_BYTES: Int = 64 * 1024 * 1024 + public const val FILE_EXTENSION: String = "enclybak" + public const val MIME_TYPE: String = "application/octet-stream" + + private const val VERSION_END = 10 + private const val U8_MASK = 0xFF + private const val U16_MASK = 0xFFFF + private const val U32_MASK = 0xFFFF_FFFFL + + public fun encodeHeader(kdf: BackupKdf, salt: ByteArray, nonce: ByteArray, ciphertextLength: Int): ByteArray { + require(salt.size == SALT_LENGTH && nonce.size == NONCE_LENGTH) + val header = Buffer() + header.write(MAGIC) + header.writeShort(VERSION.toShort()) + header.writeByte(kdf.id.toByte()) + header.writeByte(SALT_LENGTH.toByte()) + header.write(salt) + header.writeByte(NONCE_LENGTH.toByte()) + header.write(nonce) + header.writeInt(ciphertextLength) + return header.readByteArray() + } + + /** + * The size of a file sealing [plaintextBytes] of payload. Export refuses anything above + * [MAX_FILE_BYTES], so every file this app writes can be read back by [parse]. + */ + public fun fileSizeFor(plaintextBytes: Int): Long = HEADER_LENGTH.toLong() + plaintextBytes + TAG_LENGTH + + /** Validates the container structure. Does not touch keys or decrypt anything. */ + public fun parse(file: ByteArray): ParsedBackup { + ensure(file.size <= MAX_FILE_BYTES, BackupError.TOO_LARGE) + // The whole file is here: a length field that overruns it is a truncation, not a size. + val header = parseHeader(file, limit = Long.MAX_VALUE) + val available = file.size - HEADER_LENGTH + ensure(available >= header.ciphertextLength, BackupError.TRUNCATED) + ensure(available == header.ciphertextLength, BackupError.CORRUPTED) + return ParsedBackup(header, file) + } + + /** + * Validates the header at the start of [bytes], which may hold just the header, and + * rejects one whose declared file size exceeds [limit]. Lets a reader size its buffer from + * the header instead of growing one. + */ + public fun parseHeader(bytes: ByteArray, limit: Long = MAX_FILE_BYTES.toLong()): BackupHeader { + ensure( + bytes.size >= MAGIC.size && bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC), + BackupError.NOT_A_BACKUP, + ) + ensure(bytes.size >= VERSION_END, BackupError.TRUNCATED) + // Only the header is copied: [bytes] may be the whole file. + val buffer = Buffer() + buffer.write(bytes, 0, minOf(bytes.size, HEADER_LENGTH)) + buffer.skip(MAGIC.size.toLong()) + val version = buffer.readShort().toInt() and U16_MASK + ensure(version == VERSION, BackupError.UNSUPPORTED_VERSION) + ensure(bytes.size >= HEADER_LENGTH, BackupError.TRUNCATED) + + val kdf = BackupKdf.fromId(buffer.readByte().toInt() and U8_MASK) + ensure(kdf != null && (buffer.readByte().toInt() and U8_MASK) == SALT_LENGTH, BackupError.CORRUPTED) + val salt = buffer.readByteArray(SALT_LENGTH) + ensure((buffer.readByte().toInt() and U8_MASK) == NONCE_LENGTH, BackupError.CORRUPTED) + val nonce = buffer.readByteArray(NONCE_LENGTH) + // Unsigned: a length above Int.MAX_VALUE reads as negative and is simply too large. + val ciphertextLength = buffer.readInt().toLong() and U32_MASK + ensure(HEADER_LENGTH + ciphertextLength <= limit, BackupError.TOO_LARGE) + ensure(ciphertextLength >= TAG_LENGTH, BackupError.CORRUPTED) + return BackupHeader(version, checkNotNull(kdf), salt, nonce, ciphertextLength.toInt()) + } + + private fun ensure(condition: Boolean, error: BackupError) { + if (!condition) throw BackupException(error) + } +} diff --git a/app/src/main/java/com/pasich/encly/core/backup/BackupKeys.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupKeys.kt similarity index 75% rename from app/src/main/java/com/pasich/encly/core/backup/BackupKeys.kt rename to core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupKeys.kt index 5e73e762..4f83a034 100644 --- a/app/src/main/java/com/pasich/encly/core/backup/BackupKeys.kt +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupKeys.kt @@ -22,17 +22,17 @@ import com.pasich.encly.core.security.SensitiveDataCleaner * The phrase carries 128 bits of entropy, so a fast KDF is sufficient; there is deliberately * no user-chosen backup passphrase (see SECURITY.md). */ -internal object BackupKeys { - const val KEY_LENGTH = 32 +public object BackupKeys { + public const val KEY_LENGTH: Int = 32 - private val ROOT_SALT = "encly/backup/salt/v1".toByteArray(Charsets.UTF_8) - private val ROOT_INFO = "encly/backup/root/v1".toByteArray(Charsets.UTF_8) - private val FILE_INFO = "encly/backup/file/v1".toByteArray(Charsets.UTF_8) + private val ROOT_SALT = "encly/backup/salt/v1".encodeToByteArray() + private val ROOT_INFO = "encly/backup/root/v1".encodeToByteArray() + private val FILE_INFO = "encly/backup/file/v1".encodeToByteArray() /** Lower-cases and collapses any whitespace between words to a single space (see [Mnemonic]). */ - fun normalizeMnemonic(words: CharArray): CharArray = Mnemonic.normalize(words) + public fun normalizeMnemonic(words: CharArray): CharArray = Mnemonic.normalize(words) - fun rootFromMnemonic(words: CharArray): ByteArray { + public fun rootFromMnemonic(words: CharArray): ByteArray { val seedHash = Mnemonic.seedHash(words) return try { Hkdf.sha256(seedHash, ROOT_SALT, ROOT_INFO, KEY_LENGTH) @@ -41,7 +41,7 @@ internal object BackupKeys { } } - fun fileKeyFromRoot(root: ByteArray, salt: ByteArray): ByteArray { + public fun fileKeyFromRoot(root: ByteArray, salt: ByteArray): ByteArray { require(root.size == KEY_LENGTH) return Hkdf.sha256(root, salt, FILE_INFO, KEY_LENGTH) } diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupPayload.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupPayload.kt new file mode 100644 index 00000000..1bb84b92 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupPayload.kt @@ -0,0 +1,313 @@ +package com.pasich.encly.core.backup + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +/** + * The decrypted backup content. Records reference each other by their stable `uid`, never by + * the per-device autoincrement ids. It deliberately holds no vault material: no DEK, PIN or + * biometric slot, recovery phrase or backup key. + * + * Decoding is strict: an unknown key is rejected as [BackupError.INVALID_PAYLOAD]. So **every** + * change to these classes (a new field, even an optional one) must bump [SCHEMA_VERSION]; an + * older app then reports such a file as [BackupError.UNSUPPORTED_VERSION] ("update the app") + * instead of "damaged". Keep reading every older schema: see [BackupPayloadCodec.decode]. + * + * Schema history: + * - 1: first format; tasks carried a `reminderDate`. + * - 2: task reminders removed from the app, so tasks no longer carry `reminderDate`. + * - 3: each task carries its `subtasks` (a list, empty for a task without any). + * - 4: each note carries `isPinned`, `isArchived`, a `color` key and its own `textSize` and + * `lineSpacing` (null: the default). An app that reads only schema 3 reports such a file as + * [BackupError.UNSUPPORTED_VERSION], it does not fail on the keys. + * - 5: the file carries the user's preferences as `settings` (see [BackupSettings]). A schema-4 + * file has none and restores exactly as before. An app that reads only schema 4 reports a + * schema-5 file as [BackupError.UNSUPPORTED_VERSION] ("update the app"): its strict reader + * cannot skip a section, and it never half-restores. + * - 6: a note carries the uids of all its tags as `tagUids` (a list, empty for a note without any) instead of one + * `tagUid`. A file of schema 1 to 5 is read with its note's one tag (or none) as the list. An app that reads only + * schema 5 reports a schema-6 file as [BackupError.UNSUPPORTED_VERSION]. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +public data class BackupPayload( + val schema: Int = SCHEMA_VERSION, + val exportedAt: Long, + val tags: List, + val notes: List, + val tasks: List, + /** + * Preferences by storage key, values as strings (see [BackupSettings]). Not part of the + * vault, so it is never validated into a failure: a restore keeps what it understands. + * Empty (and left out of the file) when there are none. + */ + @EncodeDefault(EncodeDefault.Mode.NEVER) + val settings: Map = emptyMap(), +) { + public companion object { + public const val SCHEMA_VERSION: Int = 6 + + /** Tasks carried a `reminderDate`, dropped on import. */ + public const val SCHEMA_V1: Int = 1 + + /** Tasks had no sub-tasks; they are imported with none. */ + public const val SCHEMA_V2: Int = 2 + + /** Notes had no pin, archive or colour; they are imported unpinned, not archived, uncoloured. */ + public const val SCHEMA_V3: Int = 3 + + /** The file carried no preferences; it is imported with none. */ + public const val SCHEMA_V4: Int = 4 + + /** A note carried one `tagUid` (or null), not the list `tagUids`. */ + public const val SCHEMA_V5: Int = 5 + } +} + +@Serializable +public data class BackupTag(val uid: String, val name: String, val visible: Boolean, val position: Int) + +/** + * [value] is the note's serialized block list, carried verbatim so every block type survives. + * [color] is a stable palette key (`none`, `red`, ...), never a colour value. [tagUids] are the uids of the note's + * tags, each at most once, in the order of the tags. + */ +@Serializable +public data class BackupNote( + val uid: String, + val title: String, + val value: String, + val description: String, + val date: Long, + val dateCreate: Long, + val tagUids: List = emptyList(), + val isTrash: Boolean, + val isPinned: Boolean = false, + val isArchived: Boolean = false, + val color: String = "none", + /** The note's own text size, or null for the default. */ + val textSize: Int? = null, + /** The note's own line spacing (an ordinal of NoteLineSpacing), or null for the default. */ + val lineSpacing: Int? = null, +) + +@Serializable +public data class BackupTask( + val uid: String, + val title: String, + val description: String?, + val isCompleted: Boolean, + val createdDate: Long, + val completedDate: Long?, + val priority: Int, + val categoryTagUid: String?, + val position: Int, + val subtasks: List, +) + +/** A task's checklist item. Its task is the [BackupTask] that holds it; [uid]s are vault-wide unique. */ +@Serializable +public data class BackupSubtask(val uid: String, val title: String, val isCompleted: Boolean, val position: Int) + +/** + * A file of schema 1 to 5 as it is read: the shape of the current schema, but a note carries one [LegacyNote.tagUid]. + * Decoded as strictly as the current one, then turned into it ([toCurrent]). + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +private class LegacyPayload( + val schema: Int, + val exportedAt: Long, + val tags: List, + val notes: List, + val tasks: List, + @EncodeDefault(EncodeDefault.Mode.NEVER) + val settings: Map = emptyMap(), +) { + fun toCurrent() = BackupPayload( + schema = BackupPayload.SCHEMA_VERSION, + exportedAt = exportedAt, + tags = tags, + notes = notes.map { it.toCurrent() }, + tasks = tasks, + settings = settings, + ) +} + +/** A note of schema 1 to 5: [tagUid] its one tag, or null. */ +@Suppress("LongParameterList") // One parameter per key of the file's note. +@Serializable +private class LegacyNote( + val uid: String, + val title: String, + val value: String, + val description: String, + val date: Long, + val dateCreate: Long, + val tagUid: String?, + val isTrash: Boolean, + val isPinned: Boolean = false, + val isArchived: Boolean = false, + val color: String = "none", + val textSize: Int? = null, + val lineSpacing: Int? = null, +) { + fun toCurrent() = BackupNote( + uid = uid, + title = title, + value = value, + description = description, + date = date, + dateCreate = dateCreate, + tagUids = listOfNotNull(tagUid), + isTrash = isTrash, + isPinned = isPinned, + isArchived = isArchived, + color = color, + textSize = textSize, + lineSpacing = lineSpacing, + ) +} + +/** UTF-8 JSON encoding of [BackupPayload] with strict validation on the way in. */ +@OptIn(ExperimentalSerializationApi::class) +public object BackupPayloadCodec { + public const val MAX_UID_LENGTH: Int = 64 + public const val MAX_COLOR_LENGTH: Int = 32 + private const val REMINDER_DATE_KEY = "reminderDate" + private const val TASKS_KEY = "tasks" + private const val SUBTASKS_KEY = "subtasks" + private const val NOTES_KEY = "notes" + private const val SETTINGS_KEY = "settings" + private val NOTE_ORGANIZING_KEYS = listOf("isPinned", "isArchived", "color", "textSize", "lineSpacing") + private val PRIORITIES = 0..2 + private const val MIN_TEXT_SIZE = 10 + private const val MAX_TEXT_SIZE = 32 + private const val LINE_SPACING_COUNT = 3 + private val TEXT_SIZES = MIN_TEXT_SIZE..MAX_TEXT_SIZE + private val LINE_SPACINGS = 0 until LINE_SPACING_COUNT + + private val json = Json { + encodeDefaults = true + explicitNulls = true + ignoreUnknownKeys = false + } + + /** Reads only `schema`, skipping everything else, before the strict decode. */ + private val schemaJson = Json { ignoreUnknownKeys = true } + + @Serializable + private class SchemaProbe(val schema: Int = BackupPayload.SCHEMA_VERSION) + + /** The returned buffer is plaintext: the caller must wipe it once it has been sealed. */ + public fun encode(payload: BackupPayload): ByteArray = json.encodeToWipedBytes(BackupPayload.serializer(), payload) + + /** + * A payload from a newer schema is [BackupError.UNSUPPORTED_VERSION]: it authenticated, so + * it is intact, and this app merely cannot read it. Anything else malformed is + * [BackupError.INVALID_PAYLOAD]. An older schema is upgraded to the current one before the + * strict decode, so it is validated exactly like a current file. + */ + public fun decode(plaintext: ByteArray): BackupPayload { + val schema = invalidPayloadOn { decodeStream(schemaJson, SchemaProbe.serializer(), plaintext).schema } + if (schema > BackupPayload.SCHEMA_VERSION) throw BackupException(BackupError.UNSUPPORTED_VERSION) + val payload = invalidPayloadOn { + when (schema) { + in BackupPayload.SCHEMA_V1..BackupPayload.SCHEMA_V3 -> { + val tree = decodeStream(json, JsonElement.serializer(), plaintext) + val upgraded = upgrade(tree.jsonObject, schema) + json.decodeFromJsonElement(LegacyPayload.serializer(), upgraded).toCurrent() + } + + // Schema 4 and 5 are the shape of [LegacyPayload] (4 without the section): decoded strictly in one + // pass (the commonest files, up to the size limit), not through a JSON tree. + BackupPayload.SCHEMA_V4 -> decodeStream(json, LegacyPayload.serializer(), plaintext).also { + require(it.settings.isEmpty()) { "Unexpected settings in schema $schema" } + }.toCurrent() + + BackupPayload.SCHEMA_V5 -> decodeStream(json, LegacyPayload.serializer(), plaintext).toCurrent() + + else -> decodeStream(json, BackupPayload.serializer(), plaintext) + } + } + validate(payload) + return payload + } + + /** + * Schema 1 to 3 -> the shape of schema 5, [LegacyPayload] (schema 4 needs no tree: see [decode]). 1 -> 2 drops + * each task's `reminderDate`; 2 -> 3 gives each task an empty `subtasks` list; 3 -> 4 leaves notes to the + * defaults of the new fields (unpinned, not archived, no colour); 4 -> 5 leaves `settings` + * empty. Everything else is unchanged; 5 -> 6 (the note's tag as a list) is [LegacyPayload.toCurrent]. A + * key an older schema did not have (`subtasks` in 1 or 2, a note's pin, archive or colour in + * 1 to 3, `settings` in 1 to 4) fails like any other unknown key. + */ + private fun upgrade(old: JsonObject, schema: Int): JsonObject { + val tasks = old[TASKS_KEY]?.jsonArray?.map { element -> + val task = element.jsonObject.let { if (schema == BackupPayload.SCHEMA_V1) it - REMINDER_DATE_KEY else it } + if (schema < BackupPayload.SCHEMA_V3) { + require(SUBTASKS_KEY !in task) { "Unexpected sub-tasks in schema $schema" } + JsonObject(task + (SUBTASKS_KEY to JsonArray(emptyList()))) + } else { + element + } + } + old[NOTES_KEY]?.jsonArray?.forEach { note -> + require(NOTE_ORGANIZING_KEYS.none { it in note.jsonObject }) { "Unexpected note field in schema $schema" } + } + require(SETTINGS_KEY !in old) { "Unexpected settings in schema $schema" } + return JsonObject( + old + ("schema" to JsonPrimitive(BackupPayload.SCHEMA_V5)) + + (tasks?.let { mapOf(TASKS_KEY to JsonArray(it)) }.orEmpty()), + ) + } + + private fun decodeStream(format: Json, deserializer: DeserializationStrategy, bytes: ByteArray): T = + format.decodeFromBytes(deserializer, bytes) + + private inline fun invalidPayloadOn(block: () -> T): T = try { + block() + } catch (e: SerializationException) { + throw BackupException(BackupError.INVALID_PAYLOAD, e) + } catch (e: IllegalArgumentException) { + throw BackupException(BackupError.INVALID_PAYLOAD, e) + } + + public fun validate(payload: BackupPayload) { + val tagUids = payload.tags.map { it.uid }.toSet() + val valid = payload.schema == BackupPayload.SCHEMA_VERSION && + uniqueValidUids(payload.tags.map { it.uid }) && + uniqueValidUids(payload.notes.map { it.uid }) && + uniqueValidUids(payload.tasks.map { it.uid }) && + uniqueValidUids(payload.tasks.flatMap { task -> task.subtasks.map { it.uid } }) && + payload.notes.all { validNoteTags(it, tagUids) } && + payload.notes.all(::validNoteStyle) && + payload.tasks.all { + it.priority in PRIORITIES && (it.categoryTagUid == null || it.categoryTagUid in tagUids) + } + if (!valid) throw BackupException(BackupError.INVALID_PAYLOAD) + } + + /** Every tag of the note is a tag of the file, and none is listed twice. */ + private fun validNoteTags(note: BackupNote, tagUids: Set): Boolean = + note.tagUids.all { it in tagUids } && note.tagUids.toSet().size == note.tagUids.size + + /** A note's colour key and its own text size and line spacing are within what the app can show. */ + private fun validNoteStyle(note: BackupNote): Boolean = + note.color.isNotBlank() && note.color.length <= MAX_COLOR_LENGTH && + (note.textSize == null || note.textSize in TEXT_SIZES) && + (note.lineSpacing == null || note.lineSpacing in LINE_SPACINGS) + + private fun uniqueValidUids(uids: List): Boolean = + uids.all { it.isNotBlank() && it.length <= MAX_UID_LENGTH } && uids.toSet().size == uids.size +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupSettings.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupSettings.kt new file mode 100644 index 00000000..9712b150 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupSettings.kt @@ -0,0 +1,171 @@ +package com.pasich.encly.core.backup + +import com.pasich.encly.core.security.AutoLockDelay + +/** + * Which user preferences travel in a backup, and what each may hold. + * + * This is an **allow-list**. A preference is only exported if it is in [included], and only + * restored if its key is there and its value passes the key's [SettingKind]. Everything that + * is not user taste is listed in [excluded] with the reason, so adding a preference key anywhere + * in the app forces a decision: a test scans the sources for every key the app reads or writes + * in its flag store and DataStore and fails for one that is in neither list. + * + * Never add security state here: no PIN, biometric, duress or wipe slot or flag, lockout + * counter, onboarding or vault-commit flag, screenshot allowance, device-bound key or pending + * wipe or restore marker. A restored backup must not be able to weaken the vault it lands in. + * Those live in the vault state file, the Keystore or memory, not in the preference stores; + * the keys below are only what the preference stores hold. + * + * Wire format (`settings` of [BackupPayload], schema 5): a map from the preference's storage + * key to a string. A boolean is `true` or `false`, a number its decimal digits, a choice the + * **name** of the enum entry (never its ordinal, which an app may reorder). + */ +public object BackupSettings { + /** What a value of one setting may look like. */ + public class SettingKind internal constructor(private val check: (String) -> Boolean) { + public fun accepts(value: String): Boolean = check(value) + } + + /** The ranges and names mirror the app's own enums; a test in `:shared` keeps them in step. */ + public const val MIN_FONT_SIZE: Int = 10 + public const val MAX_FONT_SIZE: Int = 32 + + public val THEME_TYPES: List = listOf("SYSTEM", "LIGHT", "DARK") + public val THEME_PALETTES: List = listOf("PAPER", "FOREST", "OCEAN", "GRAPHITE", "MIDNIGHT") + public val FONT_STYLES: List = listOf("MODERN_SIMPLE", "COZY_EDITOR", "TECH_MINIMAL") + public val LINE_SPACINGS: List = listOf("DEFAULT", "COMPACT", "COMFORTABLE") + public val SORT_OPTIONS: List = listOf( + "UPDATED_ASC", + "UPDATED_DESC", + "CREATED_ASC", + "CREATED_DESC", + "TITLE_ASC", + "TITLE_DESC", + ) + public val SWIPE_ACTIONS: List = listOf("PIN", "ARCHIVE", "NEXT_COLOR", "DELETE", "NONE") + public val LANGUAGES: List = listOf( + "SYSTEM", + "ENGLISH", + "UKRAINIAN", + "GERMAN", + "FRENCH", + "SPANISH", + "ITALIAN", + "POLISH", + "PORTUGUESE", + "DUTCH", + "ARABIC", + ) + + /** The block tools of the editor toolbar and the sections they sit in; a test in `:shared` keeps them in step. */ + public val TOOLBAR_TOOLS: List = listOf( + "HEADING", + "LIST_CHECK", + "LIST_BULLET", + "LIST_NUMBER", + "QUOTE", + "CODE", + "TABLE", + "TEXT", + "H1", + "H2", + "H3", + "H4", + "LINK", + "SEPARATOR", + ) + public val TOOLBAR_SECTIONS: List = listOf("main", "more", "hidden") + + /** The longest stored toolbar layout there can be: every tool named once, with the section words. */ + private const val MAX_TOOLBAR_LENGTH = 400 + + public const val IS_DYNAMIC_THEME: String = "is_dynamic_theme" + public const val THEME_TYPE: String = "theme_type" + public const val THEME_PALETTE: String = "theme_palette" + public const val IS_GRID_NOTE_LIST: String = "is_grid_note_list" + public const val SORT: String = "sort" + public const val SHOW_TASKS: String = "show_tasks" + public const val SIMPLE_EDIT: String = "simple_edit" + public const val FONT_SIZE: String = "font_size" + public const val FONT_STYLE: String = "font_style" + public const val NOTE_LINE_SPACING: String = "note_line_spacing" + public const val SWIPE_ENABLED: String = "swipe_enabled" + public const val SWIPE_RIGHT: String = "swipe_right" + public const val SWIPE_LEFT: String = "swipe_left" + public const val AUTO_LOCK_DELAY: String = "auto_lock_delay" + public const val KEYBOARD_STRICT_PRIVACY: String = "keyboard_strict_privacy" + public const val SHOW_NOTE_PREVIEWS: String = "show_note_previews" + public const val APP_LANGUAGE: String = "app_language" + public const val EDITOR_TOOLBAR: String = "editor_toolbar" + + private val bool = SettingKind { it == "true" || it == "false" } + + private fun choice(names: Collection) = SettingKind { it in names } + + /** `main:A,B;more:C;hidden:D`: sections of tool names, as the editor toolbar stores its layout. */ + private val toolbar = SettingKind { value -> + value.length <= MAX_TOOLBAR_LENGTH && value.split(';').all { part -> + part.substringBefore(':') in TOOLBAR_SECTIONS && ':' in part && + part.substringAfter(':').split(',').filter { it.isNotEmpty() }.all { it in TOOLBAR_TOOLS } + } + } + + private fun number(range: IntRange) = SettingKind { value -> value.toIntOrNull()?.let { it in range } == true } + + /** Every preference that is exported and restored, with the values it accepts. */ + public val included: Map = linkedMapOf( + // Appearance. + IS_DYNAMIC_THEME to bool, + THEME_TYPE to choice(THEME_TYPES), + THEME_PALETTE to choice(THEME_PALETTES), + FONT_STYLE to choice(FONT_STYLES), + FONT_SIZE to number(MIN_FONT_SIZE..MAX_FONT_SIZE), + NOTE_LINE_SPACING to choice(LINE_SPACINGS), + // Lists and editor. + EDITOR_TOOLBAR to toolbar, + IS_GRID_NOTE_LIST to bool, + SORT to choice(SORT_OPTIONS), + SHOW_TASKS to bool, + SIMPLE_EDIT to bool, + SWIPE_ENABLED to bool, + SWIPE_RIGHT to choice(SWIPE_ACTIONS), + SWIPE_LEFT to choice(SWIPE_ACTIONS), + // Privacy preferences the user chose. They describe how the app behaves, never a + // credential. Auto-lock is the one a backup could loosen; it is the user's own choice. + AUTO_LOCK_DELAY to choice(AutoLockDelay.entries.map { it.name }), + KEYBOARD_STRICT_PRIVACY to bool, + SHOW_NOTE_PREVIEWS to bool, + // Language. + APP_LANGUAGE to choice(LANGUAGES), + ) + + /** + * Keys the preference stores hold that are **not** backed up, with why. Window geometry is + * the desktop's, one install's own. The rest is state about this install or this vault. + */ + public val excluded: Map = linkedMapOf( + "onboarding_shown_v3" to "vault-commit flag: setup is finished on this install only", + "whats_new_last_seen_version" to "what's-new marker of this install", + "backup_last_export_at" to "describes an export made from this install", + "appearance_migrated_v1" to "one-time migration marker of this install", + "window_width" to "device-bound desktop window geometry", + "window_height" to "device-bound desktop window geometry", + "window_x" to "device-bound desktop window geometry", + "window_y" to "device-bound desktop window geometry", + "window_maximised" to "device-bound desktop window geometry", + ) + + /** Most entries a restore looks at; a file with more is a hand-made one and the rest is dropped. */ + public const val MAX_ENTRIES: Int = 64 + + /** + * The entries of [raw] a restore may apply: a known key whose value that key accepts. An + * unknown key (a newer app's), a bad value and anything past [MAX_ENTRIES] are dropped + * one by one; this never throws, so a bad setting can never fail a restore. + */ + public fun accepted(raw: Map): Map = raw.entries.asSequence() + .take(MAX_ENTRIES) + .filter { (key, value) -> included[key]?.accepts(value) == true } + .associate { it.key to it.value } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/JsonBytes.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/JsonBytes.kt new file mode 100644 index 00000000..f65be660 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/JsonBytes.kt @@ -0,0 +1,17 @@ +package com.pasich.encly.core.backup + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json + +/* + * The backup payload's JSON as bytes. The plaintext of the whole vault goes through a byte array that is + * zeroized afterwards, with no String copy. kotlinx-io buffers return their segments to a pool without + * clearing them, so each platform streams through a buffer it can wipe (the JVM one is JvmJsonBytes.kt). + */ + +/** UTF-8 JSON of [value]. The result is plaintext: the caller wipes it. The serializer's own buffer is wiped here. */ +internal expect fun Json.encodeToWipedBytes(serializer: SerializationStrategy, value: T): ByteArray + +/** The value in the UTF-8 JSON [bytes]; the caller keeps (and wipes) them. */ +internal expect fun Json.decodeFromBytes(deserializer: DeserializationStrategy, bytes: ByteArray): T diff --git a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt rename to core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt index 3aba81ae..91e788c6 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt @@ -1,5 +1,7 @@ package com.pasich.encly.core.security +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.crypto.CryptoException 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 @@ -16,22 +18,25 @@ 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.SecretKeyFactory -import javax.crypto.spec.PBEKeySpec -import javax.inject.Inject -import javax.inject.Singleton +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized -const val PIN_LENGTH = 6 +/** The shortest PIN: the lockout and the device-bound key make 6 digits the floor. */ +public const val MIN_PIN_LENGTH: Int = 6 -enum class AuthType { +/** The longest PIN. */ +public const val MAX_PIN_LENGTH: Int = 12 + +public enum class AuthType { NONE, PIN, SEED_PHRASE, } -enum class AuthStrategy { +public enum class AuthStrategy { NONE, PIN, PIN_BIOMETRIC, @@ -41,41 +46,41 @@ enum class AuthStrategy { } /** The outcome of one PIN attempt. */ -sealed interface PinUnlock { +public sealed interface PinUnlock { /** The PIN opened the slot. [dek] belongs to the caller, who must wipe it. */ - class Success(val dek: ByteArray) : PinUnlock + public class Success(public val dek: ByteArray) : PinUnlock /** * The wipe PIN was typed on the lock screen. The old vault's key slots are already gone * (phases 1 and 2, see [AuthenticationManager.unlockWithPin]); [dek] opens the new, empty * vault. The caller owns [dek], must wipe it, and finishes the erase (see [WipeStage]). */ - class Erased(val dek: ByteArray) : PinUnlock + public class Erased(public 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 + public class Decoy(public val dek: ByteArray) : PinUnlock /** A wrong (or malformed) PIN; the attempt was counted. */ - data object WrongPin : PinUnlock + public data object WrongPin : PinUnlock /** A lockout is running; nothing was checked or counted. */ - data object LockedOut : PinUnlock + public data object LockedOut : PinUnlock /** * The device-bound PIN key is gone for good (see [PinHardwareFactor]): this PIN slot can * never open again on this device. The recovery phrase (or fingerprint) still unlocks. */ - data object KeyLost : PinUnlock + public data object KeyLost : PinUnlock /** The attempt could not be made (Keystore or storage failure); nothing was counted. */ - data object Failed : PinUnlock + public data object Failed : PinUnlock } /** The outcome of setting a wipe PIN. */ -enum class WipePinChange { +public enum class WipePinChange { SET, /** The wipe PIN is the vault's PIN: it would never wipe anything. */ @@ -86,8 +91,19 @@ enum class WipePinChange { FAILED, } +/** + * What a recovery-phrase unlock asks of the user next (see [AuthenticationManager.recoveryFollowUp]). + */ +public enum class RecoveryFollowUp { + /** The PIN still opens the vault: the user may keep it or set a new PIN. */ + KEEP_OR_CHANGE, + + /** No PIN slot is left that can be used (missing, lost key, locked out): a new PIN is set now. */ + MUST_SET_PIN, +} + /** The outcome of setting the vault's PIN from Settings. */ -enum class PinChange { +public enum class PinChange { SET, /** The new PIN is the duress PIN: one PIN never opens two slots. */ @@ -96,7 +112,7 @@ enum class PinChange { } /** The outcome of setting the duress PIN. */ -enum class DuressPinChange { +public enum class DuressPinChange { /** Set; the decoy vault it opens keeps the notes it had. */ SET, @@ -115,7 +131,7 @@ enum class DuressPinChange { * The part of a wipe-PIN erase that is still to do, kept in the store so that a restart * finishes it (idempotently) whenever the process died. */ -enum class WipeStage { +public enum class WipeStage { /** The old database may still exist; the new, empty one is created at the next PIN unlock. */ DATABASE, @@ -148,14 +164,15 @@ enum class WipeStage { * app mid-check or racing two checks never gets a free guess. The lockout runs on * [LockoutClock.elapsedRealtime], not the wall clock, and escalates up to [MAX_LOCKOUT_MS]. */ -@Singleton -@Suppress("TooManyFunctions") // The complete PIN slot, wipe slot and lockout lifecycle in one place. -class AuthenticationManager @Inject constructor( +@SingleIn(AppScope::class) +@Suppress("TooManyFunctions", "LargeClass") // The complete PIN slot, wipe slot and lockout lifecycle in one place. +@Inject +public class AuthenticationManager( private val store: VaultStore, private val factor: PinHardwareFactor, private val clock: LockoutClock, ) { - private val attemptLock = Any() + private val attemptLock = SynchronizedObject() /** * Creates (or replaces) the PIN slot around [dek]. [pin] stays the caller's to wipe. @@ -165,7 +182,7 @@ 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. */ - fun configurePin(pin: CharArray, dek: ByteArray): Boolean = changePin(pin, dek) == PinChange.SET + public 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, @@ -173,11 +190,11 @@ class AuthenticationManager @Inject constructor( * 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) { + public 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) } + val salt = keptSalt ?: Crypto.randomBytes(PIN_SALT_SIZE) var keyReset = false val keys = try { // A key made here (deleted by the system, not just invalidated) is a reset too: the @@ -214,7 +231,7 @@ class AuthenticationManager @Inject constructor( } val written = writePinSlot(dek, keys.kek, salt, keySlot, SlotChanges(change, decoyChange)) if (written) PinChange.SET else PinChange.FAILED - } catch (_: GeneralSecurityException) { + } catch (_: CryptoException) { PinChange.FAILED } finally { SensitiveDataCleaner.clear(salt) @@ -240,7 +257,7 @@ 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, Purpose.UNLOCK) + public fun unlockWithPin(pin: CharArray): PinUnlock = attempt(pin, Purpose.UNLOCK) /** * Whether [pin] is the PIN of the open session's vault; counts like an unlock attempt. [pin] @@ -248,7 +265,7 @@ class AuthenticationManager @Inject constructor( * 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, decoy: Boolean = false): Boolean { + public 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 -> { @@ -265,6 +282,22 @@ class AuthenticationManager @Inject constructor( } } + /** What a recovery-phrase unlock asks of the user next; see [RecoveryFollowUp]. */ + public fun recoveryFollowUp(): RecoveryFollowUp = synchronized(attemptLock) { + val usable = hasPinSlot() && + remainingLockoutMillis() <= 0L && + !deviceKeyLost() + if (usable) RecoveryFollowUp.KEEP_OR_CHANGE else RecoveryFollowUp.MUST_SET_PIN + } + + /** Whether the device-bound key is gone for good; a transient failure of the hardware is not "gone". */ + private fun deviceKeyLost(): Boolean = try { + SensitiveDataCleaner.clear(factor.mac(activeKeySlot(), KEY_PROBE)) + false + } catch (e: PinFactorException) { + e.lost + } + /** One counted attempt; see [unlockWithPin] and [verifyPinAuth]. */ @Suppress("ReturnCount") // Fail-closed gates: lockout, missing slot, unrecorded attempt. private fun attempt(pin: CharArray, purpose: Purpose): PinUnlock = synchronized(attemptLock) { @@ -306,7 +339,7 @@ class AuthenticationManager @Inject constructor( * 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) { + public fun configureWipePin(wipePin: CharArray): WipePinChange = synchronized(attemptLock) { if (!isWellFormed(wipePin) || remainingLockoutMillis() > 0) return WipePinChange.FAILED val slot = store.getBytes(PIN_SLOT_KEY) ?: return WipePinChange.FAILED val parsed = try { @@ -341,7 +374,7 @@ class AuthenticationManager @Inject constructor( } /** Turns the wipe PIN off: the wipe slot becomes a fresh decoy, as on a vault that never had one. */ - fun removeWipePin(): Boolean { + public fun removeWipePin(): Boolean { val decoy = decoyWipeSlot() return try { store.edit { @@ -363,7 +396,11 @@ class AuthenticationManager @Inject constructor( * during a lockout like [configureWipePin]. */ @Suppress("ReturnCount") // Fail-closed gates before the slot is written. - fun configureDuressPin(duressPin: CharArray, primaryDek: ByteArray, onNewVault: () -> Boolean): DuressPinChange = + public fun configureDuressPin( + duressPin: CharArray, + primaryDek: ByteArray, + onNewVault: () -> Boolean, + ): DuressPinChange = synchronized(attemptLock) { if (!isWellFormed(duressPin) || primaryDek.size != DEK_LENGTH || remainingLockoutMillis() > 0) { return DuressPinChange.FAILED @@ -407,7 +444,7 @@ class AuthenticationManager @Inject constructor( * 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) { + public fun removeDuressPin(): Boolean = synchronized(attemptLock) { val slot = randomSlot() val key = randomSlot() try { @@ -425,7 +462,7 @@ 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 = decoyKeyState(primaryDek) != null + public fun hasDecoyVault(primaryDek: ByteArray): Boolean = decoyKeyState(primaryDek) != null /** * True when the vault whose DEK is [primaryDek] has a decoy vault whose duress PIN stopped @@ -433,7 +470,7 @@ class AuthenticationManager @Inject constructor( * Setting it again opens the same decoy notes. Kept inside `decoy.key` itself (its AAD), so * the store shows nothing. */ - fun duressPinTurnedOff(primaryDek: ByteArray): Boolean = decoyKeyState(primaryDek) == false + public 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? { @@ -446,14 +483,14 @@ class AuthenticationManager @Inject constructor( * 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. */ - fun wipePinTurnedOff(): Boolean = store.getBoolean(WIPE_NOTICE_KEY, false) + public fun wipePinTurnedOff(): Boolean = store.getBoolean(WIPE_NOTICE_KEY, false) /** * Startup migration for vaults made before the wipe PIN: adds the decoy wipe slot (random * bytes, so no PIN is needed) and records key slot A. `pin.slot` is left byte for byte as * it is; a store without a PIN slot, or with both entries already, is not written at all. */ - fun ensureWipeSlot(): Boolean { + public fun ensureWipeSlot(): Boolean { val hasPinSlot = hasPinSlot() val needsDecoy = hasPinSlot && !store.contains(WIPE_SLOT_KEY) val needsKeySlot = hasPinSlot && !store.contains(KEY_SLOT_KEY) @@ -480,7 +517,7 @@ class AuthenticationManager @Inject constructor( } /** The unfinished part of a wipe-PIN erase, or null when there is none. */ - fun pendingWipe(): WipeStage? = if (store.contains(WIPE_PENDING_KEY)) { + public fun pendingWipe(): WipeStage? = if (store.contains(WIPE_PENDING_KEY)) { // An unknown value redoes the most: the database step. WipeStage.entries.getOrNull(store.getInt(WIPE_PENDING_KEY, -1)) ?: WipeStage.DATABASE } else { @@ -488,10 +525,10 @@ class AuthenticationManager @Inject constructor( } /** The empty database of an erased vault exists; only the cleanup is left. */ - fun markWipeDatabaseCreated(): Boolean = store.edit { putInt(WIPE_PENDING_KEY, WipeStage.CLEANUP.ordinal) } + public fun markWipeDatabaseCreated(): Boolean = store.edit { putInt(WIPE_PENDING_KEY, WipeStage.CLEANUP.ordinal) } /** Deletes the PIN key the erase moved away from (nothing when there is none). */ - fun deleteRetiredPinKey() = synchronized(attemptLock) { factor.delete(activeKeySlot().other) } + public fun deleteRetiredPinKey(): Unit = synchronized(attemptLock) { factor.delete(activeKeySlot().other) } /** * Startup: deletes a key in slot B that no committed state uses, left by a wipe-PIN erase @@ -499,21 +536,21 @@ class AuthenticationManager @Inject constructor( * slot A active and no erase pending; slot A itself is never touched here, it is the one key * older builds know. Under the attempt lock, so it never races an erase in progress. */ - fun deleteStrayPinKey() = synchronized(attemptLock) { + public fun deleteStrayPinKey(): Unit = synchronized(attemptLock) { if (pendingWipe() == null && activeKeySlot() == PinKeySlot.A) factor.delete(PinKeySlot.B) } /** The wipe-PIN erase is complete. */ - fun clearPendingWipe(): Boolean = store.edit { remove(WIPE_PENDING_KEY) } + public fun clearPendingWipe(): Boolean = store.edit { remove(WIPE_PENDING_KEY) } - fun hasPinSlot(): Boolean = store.contains(PIN_SLOT_KEY) + public fun hasPinSlot(): Boolean = store.contains(PIN_SLOT_KEY) - fun getAuthType(): AuthType { + public fun getAuthType(): AuthType { val ordinal = store.getInt(AUTH_TYPE_KEY, AuthType.NONE.ordinal) return AuthType.entries.getOrNull(ordinal) ?: AuthType.NONE } - fun isAuthStrategy(): AuthStrategy { + public fun isAuthStrategy(): AuthStrategy { val biometric = isBiometricEnabled() return when (getAuthType()) { AuthType.NONE -> AuthStrategy.NONE @@ -529,13 +566,13 @@ class AuthenticationManager @Inject constructor( } } - fun markBiometricEnabled(enabled: Boolean) { + public fun markBiometricEnabled(enabled: Boolean) { store.edit { putBoolean(BIOMETRIC_ENABLED_KEY, enabled) } } - fun isBiometricEnabled(): Boolean = store.getBoolean(BIOMETRIC_ENABLED_KEY, false) + public fun isBiometricEnabled(): Boolean = store.getBoolean(BIOMETRIC_ENABLED_KEY, false) - fun deactivateBiometricAuth() = markBiometricEnabled(false) + public fun deactivateBiometricAuth(): Unit = markBiometricEnabled(false) /** * What is left of the running lockout. The store keeps the penalty that was left at an @@ -544,7 +581,7 @@ class AuthenticationManager @Inject constructor( * penalty starts again from now: a reboot never shortens a lockout. */ @Suppress("ReturnCount") - fun remainingLockoutMillis(): Long { + public fun remainingLockoutMillis(): Long { // Not under [attemptLock]: the UI polls this every second, also while an attempt runs. val penalty = store.getLong(PENALTY_KEY, 0L) if (penalty <= 0L) return 0L @@ -562,7 +599,7 @@ class AuthenticationManager @Inject constructor( } /** Deletes the PIN and wipe slots, the lockout and the device-bound PIN keys. */ - fun wipe() { + public fun wipe() { store.edit { removePrefix(PIN_PREFIX) removePrefix(LOCKOUT_PREFIX) @@ -652,7 +689,7 @@ class AuthenticationManager @Inject constructor( } catch (_: PinFactorException) { return PinUnlock.Failed } - val dek = ByteArray(DEK_LENGTH).also { SecureRandom().nextBytes(it) } + val dek = Crypto.randomBytes(DEK_LENGTH) 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). @@ -669,6 +706,9 @@ class AuthenticationManager @Inject constructor( putInt(KEY_SLOT_KEY, keySlot.ordinal) putInt(WIPE_PENDING_KEY, WipeStage.DATABASE.ordinal) remove(WIPE_NOTICE_KEY) + // An entry of a dev build's password slot opens the erased vault's DEK: it goes too. + remove(LEGACY_PASSWORD_SLOT_KEY) + remove(LEGACY_PASSWORD_NOTICE_KEY) remove(BIOMETRIC_ENABLED_KEY) removePrefix(LOCKOUT_PREFIX) remove(STRIKES_KEY) @@ -678,7 +718,7 @@ class AuthenticationManager @Inject constructor( SensitiveDataCleaner.clear(slot) } if (erased) PinUnlock.Erased(dek.copyOf()) else PinUnlock.Failed - } catch (_: GeneralSecurityException) { + } catch (_: CryptoException) { PinUnlock.Failed } finally { SensitiveDataCleaner.clear(dek) @@ -734,6 +774,9 @@ 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) + // The password of a dev build is gone for good; its leftover entries are dropped with this write. + remove(LEGACY_PASSWORD_SLOT_KEY) + remove(LEGACY_PASSWORD_NOTICE_KEY) removePrefix(LOCKOUT_PREFIX) remove(STRIKES_KEY) } @@ -759,7 +802,7 @@ class AuthenticationManager @Inject constructor( 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) } + val decoyDek = kept ?: Crypto.randomBytes(DEK_LENGTH) return try { val duressSlot = sealDecoy(decoyDek, decoyKek, decoySlotAad(salt)) val decoyKey = sealDecoyKey(decoyDek, primaryDek) @@ -777,7 +820,7 @@ class AuthenticationManager @Inject constructor( kept == null -> DuressPinChange.SET_NEW_VAULT else -> DuressPinChange.SET } - } catch (_: GeneralSecurityException) { + } catch (_: CryptoException) { DuressPinChange.FAILED } finally { SensitiveDataCleaner.clear(decoyDek) @@ -787,7 +830,7 @@ class AuthenticationManager @Inject constructor( private fun writeWipeSlot(wipeKek: ByteArray, salt: ByteArray): WipePinChange { val wipeSlot = try { sealWipeSlot(wipeKek, salt) - } catch (_: GeneralSecurityException) { + } catch (_: CryptoException) { return WipePinChange.FAILED } return try { @@ -849,7 +892,8 @@ class AuthenticationManager @Inject constructor( } } - private fun isWellFormed(pin: CharArray): Boolean = pin.size == PIN_LENGTH && pin.all { it in '0'..'9' } + private fun isWellFormed(pin: CharArray): Boolean = + pin.size in MIN_PIN_LENGTH..MAX_PIN_LENGTH && pin.all { it in '0'..'9' } /** * One KDF run for both slots. [PinFactorException] leaves nothing behind; the result is @@ -858,26 +902,34 @@ class AuthenticationManager @Inject constructor( private fun derivePinKeys(pin: CharArray, salt: ByteArray, keySlot: PinKeySlot): PinKeys { // The hardware half first: it is cheap, and a lost key fails before the PBKDF2 cost. val hardware = hardwareHalf(pin, salt, keySlot) - val stretched = pbkdf2(pin, salt) - val ikm = stretched + hardware - return try { - PinKeys( + var stretched: ByteArray? = null + var ikm: ByteArray? = null + var made = false + try { + // A throw from the KDF or the HKDF leaves nothing behind: the hardware half is wiped below, and the + // stretched key unless it went into the result. + val derived = pbkdf2(pin, salt).also { stretched = it } + val material = (derived + hardware).also { ikm = it } + val keys = PinKeys( keySlot = keySlot, - 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), + stretched = derived, + kek = Hkdf.sha256(ikm = material, salt = salt, info = KEK_INFO, length = DEK_LENGTH), + wipeKek = Hkdf.sha256(ikm = material, salt = salt, info = WIPE_INFO, length = DEK_LENGTH), + decoyKek = Hkdf.sha256(ikm = material, salt = salt, info = DECOY_INFO, length = DEK_LENGTH), ) + made = true + return keys } finally { SensitiveDataCleaner.clear(hardware) - SensitiveDataCleaner.clear(ikm) + ikm?.let(SensitiveDataCleaner::clear) + if (!made) stretched?.let(SensitiveDataCleaner::clear) } } /** HMAC of `salt ‖ pin` under the key in [keySlot]; the caller wipes the result. */ private fun hardwareHalf(pin: CharArray, salt: ByteArray, keySlot: PinKeySlot): ByteArray { val macInput = ByteArray(salt.size + pin.size) - System.arraycopy(salt, 0, macInput, 0, salt.size) + salt.copyInto(macInput) pin.forEachIndexed { index, c -> macInput[salt.size + index] = c.code.toByte() } return try { factor.mac(keySlot, macInput) @@ -895,14 +947,8 @@ class AuthenticationManager @Inject constructor( } } - private fun pbkdf2(pin: CharArray, salt: ByteArray): ByteArray { - val spec = PBEKeySpec(pin, salt, PBKDF2_ITERATIONS, DEK_LENGTH * Byte.SIZE_BITS) - return try { - SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded - } finally { - spec.clearPassword() - } - } + private fun pbkdf2(pin: CharArray, salt: ByteArray): ByteArray = + Crypto.pbkdf2HmacSha256(pin, salt, PBKDF2_ITERATIONS, DEK_LENGTH) /** Whether [decoyKek] opens the duress slot; the DEK it holds is wiped at once. */ private fun opensDecoySlot(decoyKek: ByteArray, salt: ByteArray): Boolean { @@ -1008,11 +1054,18 @@ class AuthenticationManager @Inject constructor( /** 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 { + public companion object { private const val PIN_PREFIX = "pin." private const val PIN_SLOT_KEY = "pin.slot" private const val WIPE_SLOT_KEY = "pin.wipe.slot" private const val WIPE_NOTICE_KEY = "pin.wipe.notice" + + /** + * Entries of a password slot that only dev builds wrote (under `pin.` so that an old wipe dropped it too). + * Nothing reads them: a vault that has them opens with its PIN as before, and the next PIN write drops them. + */ + private const val LEGACY_PASSWORD_SLOT_KEY = "pin.password.slot" + private const val LEGACY_PASSWORD_NOTICE_KEY = "pin.password.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" @@ -1030,23 +1083,26 @@ class AuthenticationManager @Inject constructor( /** 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 + public const val MAX_ATTEMPTS: Int = 5 + public const val FIRST_LOCKOUT_MS: Long = 30_000L + public const val MAX_LOCKOUT_MS: Long = 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 + public const val STRIKE_GRACE: Int = 5 private const val MAX_DOUBLINGS = 12 private const val PBKDF2_ITERATIONS = 600_000 - private val KEK_INFO = "encly/pin/kek/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) + /** What [deviceKeyLost] asks the hardware to sign: any bytes will do, the answer is thrown away. */ + private val KEY_PROBE = "encly/key/probe/v1".encodeToByteArray() + + private val KEK_INFO = "encly/pin/kek/v3".encodeToByteArray() + private val WIPE_INFO = "encly/pin/wipe/v3".encodeToByteArray() + private val DECOY_INFO = "encly/pin/decoy/v3".encodeToByteArray() /** Every other way back to an erased vault's DEK: recovery, backup-key and biometric slots. */ private val ERASED_PREFIXES = listOf( @@ -1060,7 +1116,7 @@ class AuthenticationManager @Inject constructor( * 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, strikes: Int = failures): Long { + public 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) diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AutoLock.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AutoLock.kt new file mode 100644 index 00000000..7cecb11a --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AutoLock.kt @@ -0,0 +1,64 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.platform.KeyValueStore +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** How long the vault may stay open after the user leaves the app (Settings → Security). */ +@Suppress("MagicNumber") // The offered delays themselves. +public enum class AutoLockDelay(public val millis: Long) { + IMMEDIATELY(0L), + SECONDS_15(15_000L), + SECONDS_30(30_000L), + MINUTE_1(60_000L), + MINUTES_2(120_000L), + MINUTES_5(300_000L), + MINUTES_10(600_000L), + MINUTES_15(900_000L), + MINUTES_30(1_800_000L), + ; + + public companion object { + public val DEFAULT: AutoLockDelay = SECONDS_15 + } +} + +/** What [SessionLockManager] reads when the app goes to the background. */ +public interface AutoLockPolicy { + public val delayMillis: Long + + /** Tests and previews: lock the moment the app leaves. */ + public object Immediately : AutoLockPolicy { + override val delayMillis: Long = 0L + } +} + +/** + * The stored auto-lock delay. The screen turning off or the device locking still closes the + * vault at once, whatever the delay (see [SessionLockManager]). + */ +@SingleIn(AppScope::class) +@Inject +public class AutoLock(private val flags: KeyValueStore) : AutoLockPolicy { + private val _delay = MutableStateFlow(read()) + public val delay: StateFlow = _delay.asStateFlow() + + override val delayMillis: Long get() = _delay.value.millis + + public fun setDelay(delay: AutoLockDelay) { + flags.edit { putString(DELAY_KEY, delay.name) } + _delay.value = delay + } + + private fun read(): AutoLockDelay = flags.getString(DELAY_KEY, null) + ?.let { name -> AutoLockDelay.entries.firstOrNull { it.name == name } } + ?: AutoLockDelay.DEFAULT + + private companion object { + const val DELAY_KEY = "auto_lock_delay" + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/BiometricHost.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/BiometricHost.kt new file mode 100644 index 00000000..f4874d85 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/BiometricHost.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.core.security + +/** + * What the platform shows its authentication prompt on (an Activity on Android). The security + * logic only passes it through to [BiometricManager]; it never looks inside. + */ +public interface BiometricHost diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/BiometricManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/BiometricManager.kt new file mode 100644 index 00000000..218525fa --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/BiometricManager.kt @@ -0,0 +1,76 @@ +package com.pasich.encly.core.security + +/** + * The biometric unlock slot, as the vault logic sees it. The Android implementation is + * [KeystoreBiometricManager]: the DEK is wrapped by a Keystore key that only a successful + * BIOMETRIC_STRONG prompt releases, so a plain "prompt succeeded" boolean never opens the database. + */ +public interface BiometricManager { + public enum class BiometricType { + APP_UNLOCK, + MASTER_KEY_ACCESS, + SETTINGS_TOGGLE, + GENERAL, + } + + public interface BiometricCallback { + public fun onSuccess() + public fun onError(errorCode: Int, errorMessage: String) + public fun onFailed() + public fun onCancelled() {} + } + + /** Prompt text. A null [negativeButtonText] falls back to the localized "Cancel". */ + public data class PromptConfig( + val title: String, + val subtitle: String, + val description: String? = null, + val negativeButtonText: String? = null, + ) + + public fun isStrongBiometricAvailable(): Boolean + + /** Whether strong biometrics can be used, or why not. */ + public fun strongBiometricStatus(): BiometricStatus + + public fun hasSlot(): Boolean + + /** Wraps [dek] under a new auth-bound key after a prompt on [host]; [onResult] says whether the slot was stored. */ + public fun enroll(host: BiometricHost, dek: ByteArray, onResult: (Boolean) -> Unit) + + /** + * Returns the DEK only after a successful auth-bound operation. The caller owns the returned + * bytes and must zeroize them. + */ + public fun unlock(host: BiometricHost, onResult: (ByteArray?) -> Unit) + + /** Generic re-auth prompt for settings actions that do not themselves release a key. */ + public fun authenticate( + host: BiometricHost, + type: BiometricType, + callback: BiometricCallback, + customConfig: PromptConfig? = null, + ) + + /** Removes the slot and the key behind it. */ + public fun disable() + + public companion object { + /** The [VaultStore] key prefix of the slot's entries. */ + public const val SLOT_PREFIX: String = "bio." + } +} + +/** Whether strong (class 3) biometrics can unlock the vault on this device. */ +public enum class BiometricStatus { + AVAILABLE, + + /** The hardware is there, but no fingerprint (or other strong biometric) is enrolled yet. */ + NOT_ENROLLED, + + /** No suitable hardware, or it cannot be used right now. */ + UNAVAILABLE, + ; + + public companion object +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/DeviceLockWatcher.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/DeviceLockWatcher.kt new file mode 100644 index 00000000..fcc65c9d --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/DeviceLockWatcher.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.core.security + +/** Screen-off and keyguard signals for [SessionLockManager]; an interface so tests can drive it. */ +public interface DeviceLockWatcher { + /** Whether the keyguard is up and needs the user's credential. */ + public fun isDeviceLocked(): Boolean + + /** Calls [onScreenOff] when the screen turns off, until the returned function is called. */ + public fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit + + /** No device signals (JVM tests). */ + public object None : DeviceLockWatcher { + override fun isDeviceLocked(): Boolean = false + override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit = {} + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/DuressSlots.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/DuressSlots.kt new file mode 100644 index 00000000..36489c74 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/DuressSlots.kt @@ -0,0 +1,56 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.crypto.Crypto +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.IV_LENGTH +import com.pasich.encly.core.security.PinSlots.SLOT_VERSION + +/** + * 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".encodeToByteArray() + val DECOY_KEY_AAD = "encly/decoy/key/v1".encodeToByteArray() + 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".encodeToByteArray() + byteArrayOf(SLOT_VERSION) + private val DECOY_KEY_SALT = "encly/decoy/key/salt/v1".encodeToByteArray() + private val DECOY_KEY_INFO = "encly/decoy/key/kek/v1".encodeToByteArray() + + /** 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 iv = Crypto.randomBytes(IV_LENGTH) + val encrypted = Crypto.aesGcmSeal(kek, iv, aad, dek) + return try { + byteArrayOf(SLOT_VERSION) + iv + encrypted + } finally { + SensitiveDataCleaner.clear(iv) + 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 { + Crypto.aesGcmOpen(kek, iv, aad, ciphertext) + } 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 = Crypto.randomBytes(DECOY_SLOT_LENGTH).also { it[0] = SLOT_VERSION } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/HandoffStagingSweeper.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/HandoffStagingSweeper.kt new file mode 100644 index 00000000..c80b16f2 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/HandoffStagingSweeper.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.core.security + +/** Deletes what hand-offs of killed processes left in the app's cache (see `HandoffStaging.sweep`). */ +public fun interface HandoffStagingSweeper { + /** Deletes every staged hand-off file. */ + public fun sweep() +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Hkdf.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Hkdf.kt new file mode 100644 index 00000000..4a870327 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Hkdf.kt @@ -0,0 +1,18 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.crypto.Crypto + +/** + * HKDF-SHA256 (RFC 5869) for outputs of at most one hash block (32 bytes), which is all the + * vault and backup key hierarchy needs. The intermediate PRK is zeroized before returning; + * the caller owns (and must wipe) the returned key. + */ +internal object Hkdf { + const val MAX_LENGTH = 32 + + fun sha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + require(length in 1..MAX_LENGTH) + require(salt.isNotEmpty()) + return Crypto.hkdfSha256(ikm, salt, info, length) + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/KeyboardPrivacy.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/KeyboardPrivacy.kt new file mode 100644 index 00000000..0d433ced --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/KeyboardPrivacy.kt @@ -0,0 +1,32 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.platform.KeyValueStore +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * "Strict keyboard privacy" (Settings → Security, off by default). When on, every text field + * presents itself to the keyboard as a visible-password field without suggestions (see + * SecureTextInputBoundary): compliant keyboards then offer no suggestions or cloud + * prediction and get no surrounding text. The cost is no autocorrect, and on some keyboards + * no emoji or voice input. + */ +@SingleIn(AppScope::class) +@Inject +public class KeyboardPrivacy(private val flags: KeyValueStore) { + private val _strict = MutableStateFlow(flags.getBoolean(STRICT_KEY, false)) + public val strict: StateFlow = _strict.asStateFlow() + + public fun setStrict(enabled: Boolean) { + flags.edit { putBoolean(STRICT_KEY, enabled) } + _strict.value = enabled + } + + private companion object { + const val STRICT_KEY = "keyboard_strict_privacy" + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/LockoutClock.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/LockoutClock.kt new file mode 100644 index 00000000..6d6dd527 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/LockoutClock.kt @@ -0,0 +1,27 @@ +package com.pasich.encly.core.security + +import kotlin.time.TimeSource + +/** + * Time for the PIN lockout. Monotonic, so changing the wall clock does not end a lockout. + * `elapsedRealtime` restarts at boot, so the lockout also needs to know which boot it is in. + */ +public interface LockoutClock { + /** Milliseconds since boot, deep sleep included. */ + public fun elapsedRealtime(): Long + + /** Changes on every reboot; -1 when unknown. */ + public fun bootCount(): Int +} + +/** + * A monotonic clock without Android (unit tests). Unlike `elapsedRealtime` it may not + * count deep sleep, so the app itself always gets [SystemLockoutClock] from the graph. + */ +public object JvmMonotonicClock : LockoutClock { + private val origin = TimeSource.Monotonic.markNow() + + override fun elapsedRealtime(): Long = origin.elapsedNow().inWholeMilliseconds + + override fun bootCount(): Int = -1 +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Mnemonic.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Mnemonic.kt new file mode 100644 index 00000000..48c4f688 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Mnemonic.kt @@ -0,0 +1,57 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.security.bip39.Bip39 + +/** + * The one normalization every recovery-phrase key goes through: the vault's recovery KEK and + * the backup root both hash exactly these bytes, so a phrase that opens one opens the other. + */ +public object Mnemonic { + private const val ENTROPY_BYTES = 16 + + /** A new 12-word recovery phrase from 128 bits of system randomness. */ + public fun generate(): CharArray { + val entropy = Crypto.randomBytes(ENTROPY_BYTES) + return try { + Bip39().encode(entropy) + } finally { + SensitiveDataCleaner.clear(entropy) + } + } + + /** Lower-cases and collapses any whitespace between words to a single space. */ + internal fun normalize(words: CharArray): CharArray { + val out = CharArray(words.size) + var length = 0 + var pendingSpace = false + for (c in words) { + if (c.isWhitespace()) { + pendingSpace = length > 0 + } else { + if (pendingSpace) { + out[length++] = ' ' + pendingSpace = false + } + out[length++] = c.lowercaseChar() + } + } + return try { + out.copyOf(length) + } finally { + SensitiveDataCleaner.clear(out) + } + } + + /** SHA-256 of the normalized words in UTF-8. No String copy of the words is made. */ + internal fun seedHash(words: CharArray): ByteArray { + val normalized = normalize(words) + val utf8 = Utf8.encode(normalized) + SensitiveDataCleaner.clear(normalized) + return try { + Crypto.sha256(utf8) + } finally { + SensitiveDataCleaner.clear(utf8) + } + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PinHardwareFactor.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PinHardwareFactor.kt new file mode 100644 index 00000000..3a3d5cf9 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PinHardwareFactor.kt @@ -0,0 +1,49 @@ +package com.pasich.encly.core.security + +/** + * The two places the device-bound PIN key can live. [A] is the one every vault starts with + * (and the only one older builds know); a wipe-PIN unlock moves the PIN slot to the other one + * and deletes the old key, so the erased slots can never be opened again (crypto-erase). The + * vault records which one is active. + */ +public enum class PinKeySlot { + A, + B, + ; + + public val other: PinKeySlot get() = if (this == A) B else A +} + +/** + * The device-bound half of the PIN key: an HMAC-SHA256 whose key never leaves this device's + * secure hardware. Mixed into the PIN KEK, it makes every PIN guess run here, on the + * Keystore, instead of on an attacker's GPU against a copied slot. An interface so JVM tests + * can replace the Keystore. + */ +public interface PinHardwareFactor { + /** + * Creates the key in [slot] unless it already exists. True when it had to be created: any + * slot sealed with a key that was there before (a key the system or a backup restore deleted) + * can no longer be opened. + */ + @Throws(PinFactorException::class) + public fun ensureKey(slot: PinKeySlot): Boolean + + /** Replaces the key in [slot] with a new one; slots sealed with the old one can no longer be opened. */ + @Throws(PinFactorException::class) + public fun reset(slot: PinKeySlot) + + /** HMAC-SHA256 of [data] under the key in [slot]. The caller wipes the result. */ + @Throws(PinFactorException::class) + public fun mac(slot: PinKeySlot, data: ByteArray): ByteArray + + /** Deletes the key in [slot], if any. Never throws. */ + public fun delete(slot: PinKeySlot) +} + +/** + * The factor failed. [lost] means the key is gone for good (deleted, or invalidated by the + * system): the PIN slot can never be opened again on this device and only the recovery phrase + * (or the biometric slot) still unlocks. Otherwise the failure may be transient. + */ +public class PinFactorException(public val lost: Boolean, cause: Throwable? = null) : Exception(cause) diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PinSlots.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PinSlots.kt new file mode 100644 index 00000000..a814e300 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PinSlots.kt @@ -0,0 +1,97 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.crypto.Crypto + +/** + * 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_BYTES = Crypto.TAG_BYTES + const val IV_LENGTH = Crypto.NONCE_BYTES + 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 val PIN_AAD = "encly/pin/slot/v3".encodeToByteArray() + private val WIPE_AAD = "encly/pin/wipe-slot/v3".encodeToByteArray() + + /** `version ‖ salt ‖ iv ‖ AES-GCM(dek)`, with the version and salt authenticated as AAD. */ + fun sealSlot(dek: ByteArray, kek: ByteArray, salt: ByteArray): ByteArray { + val iv = Crypto.randomBytes(IV_LENGTH) + val encrypted = Crypto.aesGcmSeal(kek, iv, aad(salt), dek) + return try { + byteArrayOf(SLOT_VERSION) + salt + iv + encrypted + } finally { + SensitiveDataCleaner.clear(iv) + 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 (a wrong PIN). The caller wipes the result. */ + fun openSlotOrNull(slot: ParsedSlot, kek: ByteArray): ByteArray? = + Crypto.aesGcmOpen(kek, slot.iv, aad(slot.salt), slot.ciphertext) + + 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 = Crypto.randomBytes(WIPE_MARKER_LENGTH) + val iv = Crypto.randomBytes(IV_LENGTH) + val encrypted = try { + Crypto.aesGcmSeal(wipeKek, iv, wipeAad(salt), marker) + } finally { + SensitiveDataCleaner.clear(marker) + } + return try { + byteArrayOf(SLOT_VERSION) + iv + encrypted + } finally { + SensitiveDataCleaner.clear(iv) + 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 marker = Crypto.aesGcmOpen(wipeKek, iv, wipeAad(salt), ciphertext) + if (marker != null) SensitiveDataCleaner.clear(marker) + marker != null + } 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 = Crypto.randomBytes(WIPE_SLOT_LENGTH).also { 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/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/RecoveryWords.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/RecoveryWords.kt new file mode 100644 index 00000000..5634c6c3 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/RecoveryWords.kt @@ -0,0 +1,26 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.security.bip39.Bip39 +import com.pasich.encly.core.security.bip39.Bip39Wordlist + +/** The English BIP39 wordlist the recovery phrase is drawn from, for checking typed words. */ +public object RecoveryWords { + /** Words in a recovery phrase. */ + public const val COUNT: Int = 12 + + /** Sorted, as BIP39 publishes it. */ + private val words: List by lazy { Bip39Wordlist.words } + private val wordSet: Set by lazy { words.toHashSet() } + + public fun isWord(word: String): Boolean = word in wordSet + + /** Whether some word starts with [prefix]; an empty prefix always does. */ + public fun isPrefix(prefix: String): Boolean { + if (prefix.isEmpty()) return true + val index = words.binarySearch(prefix) + return index >= 0 || words.getOrNull(-index - 1)?.startsWith(prefix) == true + } + + /** Whether [phrase] (lower case, single spaces) is a valid mnemonic, checksum included. */ + public fun isValidPhrase(phrase: CharArray): Boolean = Bip39().isValid(phrase) +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/ScreenshotAllowance.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/ScreenshotAllowance.kt new file mode 100644 index 00000000..37d918a2 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/ScreenshotAllowance.kt @@ -0,0 +1,79 @@ +package com.pasich.encly.core.security + +import androidx.annotation.MainThread +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * "Allow screenshots until the app locks" (Settings → Security). + * + * Capture protection is the rule; this is a temporary exception the user makes after + * re-authenticating. It lives in memory only: it is never stored, so a new process always starts + * protected, and [SessionLockManager] revokes it on every lock (manual, automatic, device lock). + * + * Screens that show a secret (the lock screen, PIN entry, recovery words) hold [protect] while + * they are on screen: [capturable] is false then, even while the allowance is on. The window + * follows [capturable] through [observe], synchronously, so the flag is back before the next frame. + * + * Main thread only, like the lifecycle callbacks and the composition that use it. + */ +@SingleIn(AppScope::class) +@Inject +public class ScreenshotAllowance { + private val _allowed = MutableStateFlow(false) + + /** The user turned the allowance on and the app has not locked since. */ + public val allowed: StateFlow = _allowed.asStateFlow() + + private var protectedScreens = 0 + private val observers = mutableListOf<(Boolean) -> Unit>() + + /** Whether the window may be captured right now: allowed, and no secret is on screen. */ + public val capturable: Boolean get() = _allowed.value && protectedScreens == 0 + + /** Called once the user re-authenticated and accepted the warning. */ + @MainThread + public fun allow(): Unit = change { _allowed.value = true } + + /** Ends the allowance: the user turned it off, or the app locked. */ + @MainThread + public fun revoke(): Unit = change { _allowed.value = false } + + /** + * A screen with a secret is showing: the window is not capturable until the returned function + * is called (once; calling it again does nothing). + */ + @MainThread + public fun protect(): () -> Unit { + change { protectedScreens++ } + var released = false + return { + if (!released) { + released = true + change { protectedScreens-- } + } + } + } + + /** + * Tells [observer] now, and on every change, whether the window is [capturable]. The returned + * function stops it. + */ + @MainThread + public fun observe(observer: (capturable: Boolean) -> Unit): () -> Unit { + observers += observer + observer(capturable) + return { observers -= observer } + } + + private fun change(update: () -> Unit) { + val before = capturable + update() + val after = capturable + if (after != before) observers.toList().forEach { it(after) } + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SecurityManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SecurityManager.kt new file mode 100644 index 00000000..74ee4759 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SecurityManager.kt @@ -0,0 +1,782 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.backup.BackupFlags +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.platform.KeyValueStore +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlin.concurrent.Volatile +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +public enum class InitialStatus { + NO, + MAIN, + ONBOARDING, + LOSS_DATABASE, + LOSS_CRYPTO, + + /** + * An encrypted database without any v2 vault metadata: data written by Encly 1.x, which + * 2.0 cannot open (there is no migration). Wiped only after an explicit confirmation. + */ + LEGACY_VAULT, + AUTH, + SETUP_AUTH, + + /** + * The vault's state file cannot be read right now (an I/O error, not damage): nothing is + * known about the vault, so nothing may be wiped or created. Retried by asking again. + */ + STORAGE_UNREADABLE, +} + +public enum class VaultUnlockResult { + SUCCESS, + INVALID_CREDENTIAL, + + /** A PIN lockout is running; the PIN was not checked. */ + LOCKED_OUT, + + /** + * The device-bound half of the PIN key is gone (Keystore reset, key invalidated): the PIN + * can no longer unlock on this device. The recovery phrase (or fingerprint) still can. + */ + PIN_KEY_LOST, + DB_ERROR, +} + +/** + * Security coordinator for the v2 vault. + * + * The database key is never re-derived from locally stored seed material. Each unlock factor + * must unwrap the same random DEK, after which the DEK opens SQLCipher. + */ +@OptIn(ExperimentalAtomicApi::class) +@SingleIn(AppScope::class) +@Inject +public class SecurityManager( + private val appFlags: KeyValueStore, + private val vaultStore: VaultStore, + private val seedPhraseManager: SeedPhraseManager, + private val vaultDatabase: VaultDatabase, + private val authenticationManager: AuthenticationManager, + private val biometricManager: BiometricManager, + /** The graph always passes the real one; the default only spares tests that never erase. */ + private val handoffStaging: HandoffStagingSweeper = HandoffStagingSweeper {}, +) { + public companion object { + private const val TAG = "SecurityManager" + private const val ONBOARDING_SHOWN_KEY = "onboarding_shown_v3" + private const val DEK_LENGTH = 32 + } + + @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: VaultKind? = null + + // The vault the last session of this process opened, so a switch moves [eraseEpoch]. + private var lastVault: VaultKind? = null + + // What the open session still owes after an unlock with the recovery phrase (see [RecoveryStage]). While it is + // not NONE the user proved the vault's strongest secret, so they may replace a forgotten PIN without typing it. + @Volatile + private var recoveryStage = RecoveryStage.NONE + + // Serializes what creates, opens or deletes the database file while a wipe-PIN erase is + // pending: an unlock that replaces the database, and the erase's finish at startup (which a + // recreated activity runs again while an unlock may be in the middle of creating the new one). + private val databaseLock = SynchronizedObject() + + // The erase's finish at startup runs once per process: later calls (a rotated activity, the + // My Notes hand-off) find the vault already in use. + private val startupFinished = AtomicBoolean(false) + + // 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(Crypto.randomLong()) + + public var securityStatus: InitialStatus = InitialStatus.NO + + /** + * Never throws: vault storage that cannot be read (a damaged state file, a storage or + * Keystore failure) routes to the damaged-vault screen instead of crash-looping the app. + */ + @Suppress("TooGenericExceptionCaught") // Any startup failure must route, never crash-loop. + public fun resolveInitialStatus(): InitialStatus { + // A recreated activity (rotation, dark mode, font size) asks again while the vault is + // open in this process: it stays open. Only a vault that is not open starts locked. + if (isSessionOpen()) return InitialStatus.MAIN.also { securityStatus = it } + val status = try { + initializeSecurity() + } catch (e: Exception) { + AppLogger.e(TAG, "Vault state could not be read", e) + InitialStatus.LOSS_CRYPTO + } + securityStatus = status + return status + } + + private fun initializeSecurity(): InitialStatus { + if (vaultStore.isUnreadable()) return InitialStatus.STORAGE_UNREADABLE + if (vaultStore.isCorrupt()) return InitialStatus.LOSS_CRYPTO + if (!isOnboardingShown()) { + if (!isCommittedWithoutFlag()) return uncommittedVaultStatus() + // The flag was lost (or its file could not be read): the vault is committed all the same. + if (!appFlags.commit { putBoolean(ONBOARDING_SHOWN_KEY, true) }) { + AppLogger.w(TAG, "The onboarding flag of a committed vault could not be repaired") + } + } + // Vaults from before the wipe PIN get their decoy wipe slot; no PIN needed, nothing shown. + authenticationManager.ensureWipeSlot() + if (startupFinished.compareAndSet(false, true)) { + // A key left by an erase the process died in before it was recorded. + authenticationManager.deleteStrayPinKey() + // A wipe-PIN erase the process died in is finished before anything else looks at the vault. + completePendingWipe() + } + if (!seedPhraseManager.verificationKeyData()) return InitialStatus.LOSS_CRYPTO + // An erase that has not created the empty database yet does so at the next PIN unlock. + if (!vaultDatabase.hasEncryptedDatabase() && authenticationManager.pendingWipe() == null) { + return InitialStatus.LOSS_DATABASE + } + + // A lost PIN slot is recoverable only when the user explicitly created a recovery slot. + // The lock screen can unwrap the DEK with that seed; once unlocked, Settings can create + // a fresh PIN around the live session DEK. + if (!authenticationManager.hasPinSlot() && !seedPhraseManager.hasRecoverySeed()) { + return InitialStatus.LOSS_CRYPTO + } + return InitialStatus.AUTH + } + + /** + * No committed vault. Onboarding creates a new vault and deletes database.db, so it is only + * safe when no user data can exist yet. An encrypted database with no vault metadata is a + * pre-2.0 (v1) vault: 2.0 cannot open it and does not migrate it, so it is only deleted + * after the explicit confirmation on the older-version screen. + */ + private fun uncommittedVaultStatus(): InitialStatus = if (vaultDatabase.hasEncryptedDatabase() && + !seedPhraseManager.hasStoredSeed() + ) { + InitialStatus.LEGACY_VAULT + } else { + InitialStatus.ONBOARDING + } + + /** + * A vault the non-secret flags store forgot: a stored seed, a PIN or recovery slot and the + * encrypted database (or an erase that is about to replace it) all exist. Setup writes the seed + * and the slots first and the database last, and holds the bootstrap key in memory until the commit, + * so an onboarding killed at any earlier step (no seed, no slot, or no database yet: nothing to lose) and + * the one still + * running in this process do not match, and restart. + */ + private fun isCommittedWithoutFlag(): Boolean = !seedPhraseManager.hasBootstrapKey() && + seedPhraseManager.hasStoredSeed() && + (authenticationManager.hasPinSlot() || seedPhraseManager.hasRecoverySeed()) && + // A wipe-PIN erase that has not created the empty database yet leaves none: the vault is still committed. + (vaultDatabase.hasEncryptedDatabase() || authenticationManager.pendingWipe() != null) + + /** True when first-run setup may (re)create the vault without destroying user data. */ + private fun isSafeToCreateVault(): Boolean = !vaultStore.isUnreadable() && + !isOnboardingShown() && + !isCommittedWithoutFlag() && + uncommittedVaultStatus() == InitialStatus.ONBOARDING + + public fun authStrategy(): AuthStrategy = authenticationManager.isAuthStrategy() + + public fun isBiometricEnabled(): Boolean = authenticationManager.isBiometricEnabled() && biometricManager.hasSlot() + + public fun biometricAvailable(): Boolean = biometricManager.isStrongBiometricAvailable() + + /** + * Whether a re-authentication offers the fingerprint: biometric unlock is on and usable, and the + * open vault is the real one (a fingerprint belongs to it and never confirms anything in the + * decoy vault, so the option is not shown there either). + */ + public fun canReauthWithBiometric(): Boolean = isBiometricEnabled() && biometricAvailable() && !isDecoySession() + + public fun biometricStatus(): BiometricStatus = biometricManager.strongBiometricStatus() + + public fun hasRecoverySeed(): Boolean = seedPhraseManager.hasRecoverySeed() + + /** + * True while the open session is the decoy vault the duress PIN opened. Nothing on screen + * may say so: Settings looks the same, and this layer refuses what belongs to the real vault. + */ + public fun isDecoySession(): Boolean = sessionDek != null && sessionVault == VaultKind.DECOY + + /** Sets a new PIN slot around the live DEK. [pin] is wiped. */ + public 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. + */ + public fun changePin(pin: CharArray): PinChange { + if (isDecoySession()) { + SensitiveDataCleaner.clear(pin) + return PinChange.FAILED + } + val dek = currentKeyCopy() + val change = try { + if (dek == null) PinChange.FAILED else authenticationManager.changePin(pin, dek) + } finally { + dek?.let(SensitiveDataCleaner::clear) + SensitiveDataCleaner.clear(pin) + } + if (change == PinChange.SET) recoveryStage = RecoveryStage.NONE + 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, kept ([keepUnlockMethodsAfterRecovery]) + * or the vault locks. + */ + public fun canResetPinWithoutCurrent(): Boolean = + recoveryStage != RecoveryStage.NONE && sessionDek != null && !isDecoySession() + + /** + * What the session unlocked with the recovery phrase still has to decide: [RecoveryStage.MUST_SET_PIN] + * when the PIN can not be used any more (missing, lost key, locked out), [RecoveryStage.CHOOSE] when + * it can (keep them or set a new PIN), [RecoveryStage.NONE] otherwise. + */ + public fun recoveryStage(): RecoveryStage = if (sessionDek != null && !isDecoySession()) { + recoveryStage + } else { + RecoveryStage.NONE + } + + /** + * "Keep my PIN" after a recovery-phrase unlock that left the choice ([RecoveryStage.CHOOSE]): nothing + * is changed, and the permission to reset the PIN without the current one ends. False when there was no + * choice to make (a forced reset cannot be skipped). + */ + public fun keepUnlockMethodsAfterRecovery(): Boolean { + if (recoveryStage != RecoveryStage.CHOOSE) return false + recoveryStage = RecoveryStage.NONE + return true + } + + /** + * One PIN attempt from the lock screen; [pin] is wiped. The wipe PIN reports + * [VaultUnlockResult.SUCCESS] like the PIN, with the new, empty vault open: phases 1 and 2 + * of the erase ran in [AuthenticationManager.unlockWithPin], and [unlockWithRawKey] replaces + * the old database with an empty one. The rest of the erase is left for + * [completePendingWipe], which the caller runs once the vault is shown (or the next start + * does), so the wipe PIN does not wait for it. + */ + public fun unlockWithPin(pin: CharArray): VaultUnlockResult { + val attempt = try { + authenticationManager.unlockWithPin(pin) + } finally { + SensitiveDataCleaner.clear(pin) + } + return when (attempt) { + is PinUnlock.Success -> try { + if (unlockWithRawKey(attempt.dek)) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR + } finally { + SensitiveDataCleaner.clear(attempt.dek) + } + + is PinUnlock.Erased -> try { + if (unlockWithRawKey(attempt.dek)) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR + } finally { + 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 + + PinUnlock.KeyLost -> VaultUnlockResult.PIN_KEY_LOST + + PinUnlock.Failed -> VaultUnlockResult.DB_ERROR + } + } + + public fun unlockWithSeed(phrase: CharArray): VaultUnlockResult { + val dek = seedPhraseManager.unlockWithSeed(phrase) + ?: return VaultUnlockResult.INVALID_CREDENTIAL + val opened = try { + unlockWithRawKey(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + if (opened) { + // The unlock itself did not touch the PIN or the lockout: whether they still serve + // is decided now, once, and the lock screen routes on it. + recoveryStage = when (authenticationManager.recoveryFollowUp()) { + RecoveryFollowUp.KEEP_OR_CHANGE -> RecoveryStage.CHOOSE + RecoveryFollowUp.MUST_SET_PIN -> RecoveryStage.MUST_SET_PIN + } + } + return if (opened) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR + } + + public fun requestBiometricKey(host: BiometricHost, onResult: (ByteArray?) -> Unit) { + biometricManager.unlock(host, onResult) + } + + public fun enrollBiometric(host: BiometricHost, onResult: (Boolean) -> Unit) { + // The biometric slot opens the real vault only. + val dek = if (isDecoySession()) null else currentKeyCopy() + if (dek == null) { + onResult(false) + return + } + + biometricManager.enroll(host, dek) { ok -> + authenticationManager.markBiometricEnabled(ok) + onResult(ok) + } + SensitiveDataCleaner.clear(dek) + } + + public fun disableBiometric() { + if (isDecoySession()) return + biometricManager.disable() + authenticationManager.markBiometricEnabled(false) + } + + public fun confirmBiometric(host: BiometricHost, 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( + host, + BiometricManager.BiometricType.SETTINGS_TOGGLE, + object : BiometricManager.BiometricCallback { + override fun onSuccess() = onResult(true) + override fun onError(errorCode: Int, errorMessage: String) = onResult(false) + override fun onFailed() = Unit + override fun onCancelled() = onResult(false) + }, + ) + } + + // --- encrypted backups -------------------------------------------------------------- + + /** True when backups of this vault can be sealed to its recovery phrase without asking. */ + public fun hasBackupKey(): Boolean = seedPhraseManager.hasBackupKey() + + public fun isValidRecoveryPhrase(phrase: CharArray): Boolean = seedPhraseManager.isValidMnemonic(phrase) + + /** + * The seed-derived backup root of the unlocked vault, or null when the session is locked + * or the vault has no backup-key slot. The caller must wipe the returned key. + */ + public fun copyBackupRootKey(): ByteArray? { + val dek = primarySessionKeyCopy() ?: return null + return try { + seedPhraseManager.unwrapBackupRoot(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } + + /** + * Adds a recovery seed (and backup key) to the unlocked vault, which then behaves like one + * created in user-managed mode. See [SeedPhraseManager.addRecoverySeed]. + */ + public fun addRecoverySeed(phrase: CharArray): Boolean { + val dek = primarySessionKeyCopy() ?: return false + return try { + seedPhraseManager.addRecoverySeed(phrase, dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } + + /** + * Replaces the recovery phrase of the unlocked vault with [phrase] (see + * [SeedPhraseManager.replaceRecoverySeed]). The old words stop opening this vault; backups + * sealed to them still need them. The caller wipes [phrase]. + */ + public fun replaceRecoverySeed(phrase: CharArray): Boolean { + val dek = primarySessionKeyCopy() ?: return false + return try { + seedPhraseManager.replaceRecoverySeed(phrase, dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } + + /** Adds the backup-key slot to an unlocked recovery-seed vault; [phrase] must be its seed. */ + public fun createBackupKey(phrase: CharArray): Boolean { + val dek = primarySessionKeyCopy() ?: return false + return try { + seedPhraseManager.createBackupKey(phrase, dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } + + public fun pinLockoutRemainingMillis(): Long = authenticationManager.remainingLockoutMillis() + + /** + * Re-authentication with the PIN of the open vault (the duress PIN in a decoy session); + * counts like an unlock attempt. [pin] is wiped. + */ + public fun verifyPin(pin: CharArray): Boolean = try { + authenticationManager.verifyPinAuth(pin, decoy = isDecoySession()) + } finally { + SensitiveDataCleaner.clear(pin) + } + + public fun verifySeed(phrase: CharArray): Boolean = seedPhraseManager.verifyMnemonic(phrase) + + /** + * Opens SQLCipher with an already unwrapped v2 DEK. + * Caller retains ownership of [dek] and should wipe it after this call. + * + * While a wipe-PIN erase still has to create the empty database ([WipeStage.DATABASE]), + * whatever is left of the old one is deleted first and the new one is created with [dek]: + * only the new PIN slot can still produce a DEK then. Phase 3 of the erase: the erased slots + * are already gone, so a kill from here on ends in an empty vault, which the next start or + * unlock finishes. + */ + public fun unlockWithRawKey(dek: ByteArray, allowCreate: Boolean = false): Boolean { + if (dek.size != DEK_LENGTH) return false + return synchronized(databaseLock) { + val replaceDatabase = authenticationManager.pendingWipe() == WipeStage.DATABASE + if (replaceDatabase) vaultDatabase.wipe() + val opened = vaultDatabase.unlockDatabase(dek, allowCreate = allowCreate || replaceDatabase) + val ok = opened && (!replaceDatabase || recordErasedDatabase()) + if (ok) { + setSessionKey(dek, VaultKind.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 = !vaultDatabase.hasEncryptedDatabase(VaultKind.DECOY) + val ok = vaultDatabase.unlockDatabase(dek, create, VaultKind.DECOY) + if (ok) { + setSessionKey(dek, VaultKind.DECOY) + securityStatus = InitialStatus.MAIN + } + ok + } + } + + /** + * The empty database of an erase was created: records it before anything can be written to + * it. Unrecorded, the next start would take it for the old database and delete it, with + * everything added since; so when the store cannot be written the database is closed again + * and the unlock fails, and the next one creates it anew. + */ + private fun recordErasedDatabase(): Boolean { + if (!authenticationManager.markWipeDatabaseCreated()) { + AppLogger.w(TAG, "The erased vault's new database could not be recorded") + vaultDatabase.reset() + return false + } + eraseEpoch.addAndFetch(1L) + return true + } + + /** + * Finishes a wipe-PIN erase (see [WipeStage]); does nothing when none is pending. Safe to + * repeat: every step is a deletion. Runs at startup, and after an unlock that opened the + * empty database (off the unlock's path, see [unlockWithPin]). Theme, sorting and + * auto-lock settings are kept, as on any vault. + */ + public fun completePendingWipe() { + synchronized(databaseLock) { + val stage = authenticationManager.pendingWipe() ?: return + // Before the empty database exists, whatever is left of the old one goes (never an open one). + if (stage == WipeStage.DATABASE) vaultDatabase.wipeIfClosed() + authenticationManager.deleteRetiredPinKey() + // Its slot went with the erase; this deletes the Keystore key behind it. + biometricManager.disable() + appFlags.commit { remove(BackupFlags.LAST_EXPORT_KEY) } + // A My Notes hand-off a killed process left in the cache is plaintext of the old vault's time. + handoffStaging.sweep() + if (stage == WipeStage.CLEANUP) authenticationManager.clearPendingWipe() + } + } + + /** + * 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. + */ + public fun eraseEpoch(): Long = eraseEpoch.load() + + // --- wipe PIN ----------------------------------------------------------------------- + + /** + * Sets (or replaces) the wipe PIN; only in an unlocked session, after the caller checked the + * PIN. [pin] is wiped. + */ + public fun configureWipePin(pin: CharArray): WipePinChange = try { + 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. */ + public 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. + */ + public fun configureDuressPin(pin: CharArray): DuressPinChange { + val dek = primarySessionKeyCopy() + return try { + if (dek == null) { + DuressPinChange.FAILED + } else { + authenticationManager.configureDuressPin(pin, dek) { + vaultDatabase.wipeIfClosed(VaultKind.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. + */ + public fun removeDuressPin(): Boolean { + if (!primarySessionOpen()) return false + return authenticationManager.removeDuressPin() && vaultDatabase.wipeIfClosed(VaultKind.DECOY) + } + + /** + * Whether the real vault has a decoy vault (its duress PIN may have been turned off, see + * [duressPinTurnedOff]); false outside a session of the real vault. + */ + public fun hasDuressPin(): Boolean = withPrimaryKey(authenticationManager::hasDecoyVault) + + /** Whether the real vault's duress PIN stopped working; false outside a session of the real vault. */ + public fun duressPinTurnedOff(): Boolean = withPrimaryKey(authenticationManager::duressPinTurnedOff) + + private fun withPrimaryKey(check: (ByteArray) -> Boolean): Boolean { + val dek = primarySessionKeyCopy() ?: return false + return try { + check(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } + + /** + * Completes first-run setup only after a PIN slot exists and the bootstrap DEK opens SQLCipher. + */ + public fun finishInitialSetup(): Boolean = openInitialVault() && commitInitialSetup() + + /** + * First half of [finishInitialSetup]: opens (creating) the new vault with the bootstrap DEK + * once a PIN slot exists, without committing onboarding. Lets a staged restore be imported + * before the vault counts as set up; until [commitInitialSetup], a killed process simply + * starts onboarding over. + */ + public fun openInitialVault(): Boolean { + val dek = if (authenticationManager.hasPinSlot()) { + seedPhraseManager.copyBootstrapKey() + } else { + null + } ?: return false + + return try { + unlockWithRawKey(dek, allowCreate = true) + } finally { + SensitiveDataCleaner.clear(dek) + } + } + + /** Second half of [finishInitialSetup]: commits onboarding and drops the bootstrap DEK. */ + public fun commitInitialSetup(): Boolean { + val finalized = setOnboardingShown() + if (finalized) seedPhraseManager.clearBootstrapKey() + return finalized + } + + /** + * Closes the vault and drops the session key. Serialized with the open ([unlockWithRawKey]): a lock never lands + * between the database being opened and its key being recorded, which would leave a key with no database to + * close it. It can therefore wait for an open in progress. + */ + public fun lock() { + synchronized(databaseLock) { + vaultDatabase.reset() + clearSessionKey() + securityStatus = InitialStatus.AUTH + } + } + + /** + * Whether backgrounding must close the open vault. Not before onboarding is committed: the + * only vault open then is the one first-run setup just created, possibly still importing a + * restore that a re-lock would abort. Setup closes it itself if it finishes in the + * background (SessionLockManager.onUnlocked) or fails. + */ + public fun isLockable(): Boolean = (isOnboardingShown() || isCommittedWithoutFlag()) && + (authenticationManager.hasPinSlot() || seedPhraseManager.hasRecoverySeed()) + + public fun isDatabaseUnlocked(): Boolean = vaultDatabase.isDatabaseUnlocked() + + /** True while this process holds the vault open: the session key and the database. */ + private fun isSessionOpen(): Boolean = sessionDek != null && vaultDatabase.isDatabaseUnlocked() + + public fun isOnboardingShow(): Boolean = !isOnboardingShown() + + private fun isOnboardingShown(): Boolean = appFlags.getBoolean(ONBOARDING_SHOWN_KEY, false) + + public fun setOnboardingShown(): Boolean { + if (!seedPhraseManager.verificationKeyData() || !authenticationManager.hasPinSlot()) { + return false + } + return appFlags.commit { putBoolean(ONBOARDING_SHOWN_KEY, true) } + } + + public fun generateMnemonicCode(): CharArray = seedPhraseManager.generateMnemonic() + + /** + * Starts a brand-new vault with no legacy state. First-run setup is intentionally + * destructive until onboarding has been finalized, because no user data is considered + * committed before a valid PIN slot exists. + */ + public fun initializeNewVault(recoverySeed: CharArray?): Boolean { + // Refuse to overwrite a committed vault or a database nothing can open yet. + if (!isSafeToCreateVault()) return false + vaultDatabase.wipe() + clearSessionKey() + biometricManager.disable() + authenticationManager.wipe() + seedPhraseManager.wipe() + appFlags.commit { remove(ONBOARDING_SHOWN_KEY) } + // A damaged state file cannot be edited slot by slot; a new vault starts from none. + if (vaultStore.isCorrupt()) vaultStore.clear() + securityStatus = InitialStatus.ONBOARDING + return seedPhraseManager.initializeVault(recoverySeed) + } + + private fun currentKeyCopy(): ByteArray? = sessionDek?.copyOf() ?: seedPhraseManager.copyBootstrapKey() + + private fun primarySessionOpen(): Boolean = sessionDek != null && sessionVault == VaultKind.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 == + VaultKind.PRIMARY + ) { + sessionDek?.copyOf() + } else { + null + } + + private fun setSessionKey(dek: ByteArray, vault: VaultKind) { + clearSessionKey() + // What a screen remembered in the other vault must not come back in this one. + if (lastVault != null && lastVault != vault) eraseEpoch.addAndFetch(1L) + lastVault = vault + sessionDek = dek.copyOf() + sessionVault = vault + } + + private fun clearSessionKey() { + sessionDek?.let(SensitiveDataCleaner::clear) + sessionDek = null + sessionVault = null + recoveryStage = RecoveryStage.NONE + } + + /** + * Erases everything: both vaults, every key slot. Never from a decoy session, where it + * changes nothing and returns false. + */ + public fun wipeAndReset(): Boolean { + if (isDecoySession()) return false + vaultDatabase.wipe() + clearSessionKey() + biometricManager.disable() + authenticationManager.wipe() + seedPhraseManager.wipe() + // Also the way out of a damaged state file, which the per-slot wipes above cannot edit. + vaultStore.clear() + appFlags.commit { clear() } + securityStatus = InitialStatus.ONBOARDING + return true + } + + public fun getSettingsAuth(): AuthSettings = AuthSettings( + authType = authenticationManager.getAuthType(), + isBiometricEnabled = isBiometricEnabled(), + isUserCreatedSeedKey = seedPhraseManager.hasRecoverySeed(), + hasPinSlot = authenticationManager.hasPinSlot(), + wipePinTurnedOff = authenticationManager.wipePinTurnedOff(), + hasDuressPin = hasDuressPin(), + duressPinTurnedOff = duressPinTurnedOff(), + ) +} + +/** + * The vault's security state as Settings shows it. [wipePinTurnedOff]: the PIN key was reset, + * so any wipe PIN stopped working (whether one was set is never known without it). + */ +public data class AuthSettings( + val authType: AuthType, + val isBiometricEnabled: Boolean, + val isUserCreatedSeedKey: Boolean, + val hasPinSlot: Boolean = authType == AuthType.PIN, + val wipePinTurnedOff: Boolean = false, + /** The real vault has a duress PIN (known only in its own session). */ + val hasDuressPin: Boolean = false, + /** A new PIN slot turned the duress PIN off; setting it again keeps the notes. */ + val duressPinTurnedOff: Boolean = false, +) + +/** What a session unlocked with the recovery phrase still has to decide. */ +public enum class RecoveryStage { + /** Nothing: the session was not unlocked with the phrase, or what it owed is settled. */ + NONE, + + /** The PIN still serves: keep it, or set a new PIN. */ + CHOOSE, + + /** The PIN can not be used (missing, lost key, locked out): a new PIN is set before going on. */ + MUST_SET_PIN, +} diff --git a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SeedPhraseManager.kt similarity index 76% rename from app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt rename to core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SeedPhraseManager.kt index 3c2aeb07..841711f0 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SeedPhraseManager.kt @@ -1,16 +1,14 @@ package com.pasich.encly.core.security -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode -import cash.z.ecc.android.bip39.Mnemonics.WordCount import com.pasich.encly.core.backup.BackupKeys -import java.security.GeneralSecurityException -import java.security.MessageDigest -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec -import javax.inject.Inject -import javax.inject.Singleton +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.security.bip39.Bip39 +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlin.concurrent.Volatile /** * Encly vault bootstrap and recovery manager. @@ -27,9 +25,10 @@ import javax.inject.Singleton * * Both slots live in the [VaultStore] and are always written together, in one atomic edit. */ -@Singleton -class SeedPhraseManager @Inject constructor(private val store: VaultStore) { - companion object { +@SingleIn(AppScope::class) +@Inject +public class SeedPhraseManager(private val store: VaultStore) { + public companion object { private const val VAULT_PREFIX = "vault." internal const val RECOVERY_PREFIX = "recovery." internal const val BACKUP_PREFIX = "backup." @@ -38,30 +37,30 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { private const val RECOVERY_ENABLED_KEY = "recovery.enabled" private const val BACKUP_SLOT_KEY = "backup.slot" private const val VAULT_VERSION = 3 - private const val GCM_TAG_LENGTH = 128 private const val IV_LENGTH = 12 private const val DEK_LENGTH = 32 - private val RECOVERY_SALT = "encly/recovery/salt/v3".toByteArray(Charsets.UTF_8) - private val RECOVERY_INFO = "encly/recovery/kek/v3".toByteArray(Charsets.UTF_8) - private val RECOVERY_AAD = "encly/recovery/slot/v3".toByteArray(Charsets.UTF_8) - private val BACKUP_SLOT_SALT = "encly/backup/slot/salt/v1".toByteArray(Charsets.UTF_8) - private val BACKUP_SLOT_INFO = "encly/backup/slot/kek/v1".toByteArray(Charsets.UTF_8) - private val BACKUP_SLOT_AAD = "encly/backup/slot/v1".toByteArray(Charsets.UTF_8) + private val RECOVERY_SALT = "encly/recovery/salt/v3".encodeToByteArray() + private val RECOVERY_INFO = "encly/recovery/kek/v3".encodeToByteArray() + private val RECOVERY_AAD = "encly/recovery/slot/v3".encodeToByteArray() + private val BACKUP_SLOT_SALT = "encly/backup/slot/salt/v1".encodeToByteArray() + private val BACKUP_SLOT_INFO = "encly/backup/slot/kek/v1".encodeToByteArray() + private val BACKUP_SLOT_AAD = "encly/backup/slot/v1".encodeToByteArray() } + // Written and copied under [lock]. @Volatile private var bootstrapDek: ByteArray? = null - fun generateMnemonic(): MnemonicCode = MnemonicCode(WordCount.COUNT_12) + // The one lock of the manager: every state-changing operation holds it (reentrant, so one may call another). + private val lock = SynchronizedObject() - fun isValidMnemonic(phrase: CharArray): Boolean { + public fun generateMnemonic(): CharArray = Mnemonic.generate() + + public fun isValidMnemonic(phrase: CharArray): Boolean { val normalized = Mnemonic.normalize(phrase) return try { - MnemonicCode(normalized).validate() - true - } catch (_: Exception) { - false + Bip39().isValid(normalized) } finally { SensitiveDataCleaner.clear(normalized) } @@ -74,14 +73,13 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { * mandatory PIN slot is created. If setup is interrupted before then, onboarding restarts. */ @Suppress("ReturnCount") // Invalid seed and failed durable metadata commits abort setup immediately. - @Synchronized - fun initializeVault(recoverySeed: CharArray?): Boolean { + public fun initializeVault(recoverySeed: CharArray?): Boolean = synchronized(lock) { if (recoverySeed != null && !isValidMnemonic(recoverySeed)) return false clearBootstrapKey() if (!store.edit { clearOwnKeys() }) return false - val dek = ByteArray(DEK_LENGTH).also { SecureRandom().nextBytes(it) } + val dek = Crypto.randomBytes(DEK_LENGTH) val recoverySlot = recoverySeed?.let { sealRecoverySlot(it, dek) } val backupSlot = recoverySeed?.let { sealBackupRoot(it, dek) } return try { @@ -100,23 +98,24 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { } } - fun hasStoredSeed(): Boolean = store.getInt(VERSION_KEY, 0) == VAULT_VERSION + public fun hasStoredSeed(): Boolean = store.getInt(VERSION_KEY, 0) == VAULT_VERSION - fun hasRecoverySeed(): Boolean = hasStoredSeed() && + public fun hasRecoverySeed(): Boolean = hasStoredSeed() && store.getBoolean(RECOVERY_ENABLED_KEY, false) && store.contains(RECOVERY_SLOT_KEY) - fun verificationKeyData(): Boolean { + public fun verificationKeyData(): Boolean { if (!hasStoredSeed()) return false val recoveryEnabled = store.getBoolean(RECOVERY_ENABLED_KEY, false) return !recoveryEnabled || hasRecoverySeed() } - @Synchronized - fun copyBootstrapKey(): ByteArray? = bootstrapDek?.copyOf() + /** True while this process holds the key of a vault whose setup is not committed yet. */ + public fun hasBootstrapKey(): Boolean = synchronized(lock) { bootstrapDek != null } + + public fun copyBootstrapKey(): ByteArray? = synchronized(lock) { bootstrapDek?.copyOf() } - @Synchronized - fun clearBootstrapKey() { + public fun clearBootstrapKey(): Unit = synchronized(lock) { bootstrapDek?.let(SensitiveDataCleaner::clear) bootstrapDek = null } @@ -126,41 +125,37 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { * The returned key belongs to the caller and must be zeroized after use. */ @Suppress("ReturnCount") // Fail-closed early exits keep malformed recovery metadata out of crypto operations. - fun unlockWithSeed(phrase: CharArray): ByteArray? { + public fun unlockWithSeed(phrase: CharArray): ByteArray? { if (!hasRecoverySeed() || !isValidMnemonic(phrase)) return null val wrapped = store.getBytes(RECOVERY_SLOT_KEY) ?: return null val kek = deriveRecoveryKek(phrase) return try { unwrap(wrapped, kek, RECOVERY_AAD) - } catch (_: GeneralSecurityException) { - null } finally { SensitiveDataCleaner.clear(kek) SensitiveDataCleaner.clear(wrapped) } } - fun verifyMnemonic(phrase: CharArray): Boolean { + public fun verifyMnemonic(phrase: CharArray): Boolean { val dek = unlockWithSeed(phrase) ?: return false SensitiveDataCleaner.clear(dek) return true } - fun hasBackupKey(): Boolean = hasRecoverySeed() && store.contains(BACKUP_SLOT_KEY) + public fun hasBackupKey(): Boolean = hasRecoverySeed() && store.contains(BACKUP_SLOT_KEY) /** * Opens the backup-key slot with the live [dek]. The returned `backupRoot` belongs to the * caller and must be zeroized after use. */ @Suppress("ReturnCount") // Fail-closed early exits keep malformed metadata out of crypto. - fun unwrapBackupRoot(dek: ByteArray): ByteArray? { + public fun unwrapBackupRoot(dek: ByteArray): ByteArray? { if (!hasBackupKey() || dek.size != DEK_LENGTH) return null val wrapped = store.getBytes(BACKUP_SLOT_KEY) ?: return null val kek = deriveBackupSlotKek(dek) return try { unwrap(wrapped, kek, BACKUP_SLOT_AAD) - } catch (_: GeneralSecurityException) { - null } finally { SensitiveDataCleaner.clear(kek) SensitiveDataCleaner.clear(wrapped) @@ -171,8 +166,7 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { * Adds the backup-key slot to a recovery-seed vault that lacks one. [phrase] must be this * vault's recovery seed: it has to unwrap the recovery slot to exactly [dek]. */ - @Synchronized - fun createBackupKey(phrase: CharArray, dek: ByteArray): Boolean { + public fun createBackupKey(phrase: CharArray, dek: ByteArray): Boolean = synchronized(lock) { if (!isSeedOfVault(phrase, dek)) return false val slot = sealBackupRoot(phrase, dek) return try { @@ -186,7 +180,7 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { private fun isSeedOfVault(phrase: CharArray, dek: ByteArray): Boolean { val recovered = unlockWithSeed(phrase) ?: return false return try { - MessageDigest.isEqual(recovered, dek) + Crypto.constantTimeEquals(recovered, dek) } finally { SensitiveDataCleaner.clear(recovered) } @@ -197,8 +191,7 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { * [dek] under [phrase] (recovery slot) and adds the backup-key slot. The PIN and biometric * slots are untouched. Refuses when a recovery seed already exists (see [replaceRecoverySeed]). */ - @Synchronized - fun addRecoverySeed(phrase: CharArray, dek: ByteArray): Boolean { + public fun addRecoverySeed(phrase: CharArray, dek: ByteArray): Boolean = synchronized(lock) { if (!hasStoredSeed() || hasRecoverySeed()) return false return writeRecoverySeed(phrase, dek) } @@ -209,8 +202,7 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { * one atomic write that also drops the old recovery slot. From then on the old words open * nothing on this device; backups already made with them still open only with them. */ - @Synchronized - fun replaceRecoverySeed(newPhrase: CharArray, dek: ByteArray): Boolean { + public fun replaceRecoverySeed(newPhrase: CharArray, dek: ByteArray): Boolean = synchronized(lock) { if (!hasRecoverySeed()) return false return writeRecoverySeed(newPhrase, dek) } @@ -268,27 +260,25 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { } } + /** `iv ‖ AES-GCM(plaintext)` with [aad] authenticated. */ private fun wrap(plaintext: ByteArray, kek: ByteArray, aad: ByteArray): ByteArray { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(kek, "AES")) - cipher.updateAAD(aad) - val encrypted = cipher.doFinal(plaintext) + val iv = Crypto.randomBytes(IV_LENGTH) + val encrypted = Crypto.aesGcmSeal(kek, iv, aad, plaintext) return try { - cipher.iv + encrypted + iv + encrypted } finally { + SensitiveDataCleaner.clear(iv) SensitiveDataCleaner.clear(encrypted) } } + /** The plaintext of [wrapped] (see [wrap]), or null when [kek] and [aad] do not open it. */ private fun unwrap(wrapped: ByteArray, kek: ByteArray, aad: ByteArray): ByteArray? { if (wrapped.size <= IV_LENGTH) return null val iv = wrapped.copyOfRange(0, IV_LENGTH) val ciphertext = wrapped.copyOfRange(IV_LENGTH, wrapped.size) return try { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(kek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, iv)) - cipher.updateAAD(aad) - cipher.doFinal(ciphertext) + Crypto.aesGcmOpen(kek, iv, aad, ciphertext) } finally { SensitiveDataCleaner.clear(iv) SensitiveDataCleaner.clear(ciphertext) @@ -301,7 +291,7 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { removePrefix(BACKUP_PREFIX) } - fun wipe() { + public fun wipe() { clearBootstrapKey() store.edit { clearOwnKeys() } } diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SessionLockManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SessionLockManager.kt new file mode 100644 index 00000000..be5fe31d --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SessionLockManager.kt @@ -0,0 +1,330 @@ +package com.pasich.encly.core.security + +import androidx.annotation.MainThread +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.pasich.encly.core.platform.MainThreadTimer +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.concurrent.Volatile +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Whether the open session was closed. ViewModels that hold decrypted content observe it and + * drop that content at once: after a background re-lock the UI (and its navigation away from + * protected screens) only runs again once the app is back in front. + */ +public interface VaultLockEvents { + /** True from the moment the vault is closed until the next unlock is published. */ + public val locked: StateFlow + + /** + * Registers [flush] to run before the vault is closed by a lock: an editor writes its pending changes in it. A + * lock waits for the registered flushes for at most [LOCK_FLUSH_TIMEOUT_MS], then closes the vault whether they + * finished or not. Returns the function that unregisters it. Nothing happens here by default. + */ + public fun registerPreLockFlush(flush: suspend () -> Unit): () -> Unit = {} +} + +/** The longest a lock waits for the pending editor saves before it closes the vault anyway. */ +public const val LOCK_FLUSH_TIMEOUT_MS: Long = 1_000L + +/** For code that runs without a session manager (tests, previews): never locks. */ +public object NeverLocked : VaultLockEvents { + override val locked: StateFlow = MutableStateFlow(false).asStateFlow() +} + +/** + * Auto-locks the app when it goes to the background. + * + * Registered against [androidx.lifecycle.ProcessLifecycleOwner] in [com.pasich.encly.MyApplication]. + * On [onStop] (the whole process backgrounded) it re-locks the encrypted database if a + * re-authentication strategy is configured and the session is currently unlocked, then flips + * [locked] so the UI navigates back to the lock screen the next time the app is foregrounded. + * + * Re-locking closes SQLCipher and zeroizes Encly's in-memory DEK copy. The next foreground + * unlock must unwrap the DEK again through the PIN, auth-bound biometric, or recovery slot. + * + * It waits the user's auto-lock delay first ([AutoLockPolicy], 15 s by default, "immediately" + * possible), and a backup import's system file picker (another app) gets its own short grace: + * see [allowSystemPicker]. Both are measured on `elapsedRealtime` (deep sleep counts), end + * the moment the screen turns off or the device locks, and are re-checked when the app comes + * back. + */ +@OptIn(ExperimentalAtomicApi::class) +@SingleIn(AppScope::class) +@Suppress("TooManyFunctions") // The whole session lifecycle: lifecycle hooks, picker grace, unlock publishing. +@Inject +public class SessionLockManager( + private val securityManager: SecurityManager, + private val deviceLock: DeviceLockWatcher = DeviceLockWatcher.None, + systemClock: LockoutClock = JvmMonotonicClock, + private val autoLock: AutoLockPolicy = AutoLockPolicy.Immediately, + private val mainThread: MainThreadTimer = MainThreadTimer.None, + private val screenshots: ScreenshotAllowance = ScreenshotAllowance(), +) : DefaultLifecycleObserver, + VaultLockEvents { + + private val _locked = MutableStateFlow(false) + + private class PreLockFlush(val run: suspend () -> Unit) + + private val preLockFlushes = MutableStateFlow>(emptyList()) + + /** True from the moment a lock starts waiting for the editors until the vault is closed. */ + private val flushing = AtomicBoolean(false) + + /** + * Counts the lock requests of a lockable vault (an OS lock, the idle lock, "Lock now", a background stop), whether + * or not anything was open when they came. An unlock that was running while one arrived must not be published: see + * [lockRequests] and [onUnlocked]. + */ + private val lockRequestCount = AtomicLong(0L) + + /** Emits true when the app was re-locked in the background and must show the lock screen. */ + override val locked: StateFlow = _locked.asStateFlow() + + /** + * Counts published unlocks: each one starts a new session. A screen that was stopped while + * this changed (the vault was unlocked on another lock screen, such as the My Notes + * hand-off's) shows a session it never saw, possibly after an erase. + */ + @Volatile + public var sessionGeneration: Long = 0L + private set + + /** + * Whether the process is in the foreground (between ProcessLifecycleOwner ON_START and + * ON_STOP). An unlock that finishes after ON_STOP must not leave the vault open. + */ + @Volatile + private var foreground = false + + /** Milliseconds since boot, deep sleep included (`elapsedRealtime`); replaceable in tests. */ + internal var clock: () -> Long = systemClock::elapsedRealtime + + @Volatile + private var systemPickerRequestedAt = NONE + + @Volatile + private var suspendedAt = NONE + + /** How long the current suspension may last: the picker grace or the auto-lock delay. */ + @Volatile + private var awayLimitMs = 0L + + /** Set when the screen went off (or the keyguard came up) during a picker grace. */ + @Volatile + private var lockedWhileAway = false + + /** Where a lock waits for the editors' saves; replaceable in tests. */ + internal var lockScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** Schedules [action] on the main thread after a delay; returns a cancel function. */ + internal var timer: (delayMs: Long, action: () -> Unit) -> () -> Unit = mainThread::schedule + + private var cancelPickerTimeout: (() -> Unit)? = null + private var stopScreenOffWatch: (() -> Unit)? = null + + /** + * Announces that the app is about to open the system file picker (Storage Access Framework) + * to choose a backup to import. The picker is another app, so the process goes to the + * background; the next ON_STOP within [PICKER_LAUNCH_WINDOW_MS] therefore keeps the vault + * open instead of re-locking it, for at most [PICKER_MAX_AWAY_MS]. Turning the screen off + * or locking the device ends the grace at once. + * + * Export does not need this: its file is sealed before the picker opens. + */ + @MainThread + public fun allowSystemPicker() { + systemPickerRequestedAt = clock() + } + + /** The picker returned (or never opened): no later ON_STOP gets the grace. */ + @MainThread + public fun endSystemPicker() { + systemPickerRequestedAt = NONE + endSuspension() + } + + /** + * Startup found a committed vault that is not open. Starts [locked], so any screen that a + * restored back stack (process death) or an intent would open is replaced by the lock + * screen before it can show anything. + */ + @MainThread + public fun requireUnlock() { + lockRequestCount.addAndFetch(1L) + screenshots.revoke() + _locked.value = true + } + + override fun onStart(owner: LifecycleOwner) { + foreground = true + val stoppedAt = suspendedAt + val lockedAway = lockedWhileAway + endSuspension() + if (stoppedAt == NONE) return + if (graceBroken(stoppedAt, lockedAway) && isOpenAndLockable()) relock() + } + + override fun onStop(owner: LifecycleOwner) { + foreground = false + val requestedAt = systemPickerRequestedAt + systemPickerRequestedAt = NONE + val now = clock() + if (requestedAt != NONE && now - requestedAt in 0..PICKER_LAUNCH_WINDOW_MS) { + suspend(now, PICKER_MAX_AWAY_MS) + return + } + if (!isOpenAndLockable()) return + val delay = autoLock.delayMillis + if (delay > 0) suspend(now, delay) else relock() + } + + /** + * Keeps the vault open for up to [limitMs] while the app is away (the picker grace, or the + * auto-lock delay). The screen going off closes it right away, even if the app never comes + * back; the timer closes it when the time runs out. + */ + private fun suspend(now: Long, limitMs: Long) { + suspendedAt = now + awayLimitMs = limitMs + lockedWhileAway = false + stopScreenOffWatch = deviceLock.watchScreenOff { onAwayTimeout(screenOff = true) } + cancelPickerTimeout = timer(limitMs) { onAwayTimeout(screenOff = false) } + } + + /** 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() + + private fun onAwayTimeout(screenOff: Boolean) { + if (foreground || suspendedAt == NONE) return + if (screenOff) lockedWhileAway = true + endSuspension() + if (isOpenAndLockable()) relock() + } + + private fun endSuspension() { + suspendedAt = NONE + lockedWhileAway = false + cancelPickerTimeout?.invoke() + cancelPickerTimeout = null + stopScreenOffWatch?.invoke() + stopScreenOffWatch = null + } + + /** + * The number of lock requests so far. A caller that starts an unlock reads it first and passes it to [onUnlocked] + * when the unlock is done: a lock that came in between (an OS lock or the idle lock while the PIN was being + * checked, when there was nothing open yet to close) must not be lost to the unlock that finishes after it. + */ + public fun lockRequests(): Long = lockRequestCount.load() + + /** + * Whether the device reports itself locked right now. An unlock that [onUnlocked] refuses for this reason can say + * so, instead of leaving the lock screen as it was. + */ + public fun isDeviceLocked(): Boolean = deviceLock.isDeviceLocked() + + /** + * Publishes a completed unlock (PIN, biometric, recovery seed, first-run setup or v1 + * upgrade). Call on the main thread, where ON_START/ON_STOP are delivered too, right + * after the vault was opened. [lockRequestsAtStart] is [lockRequests] as it was when the unlock began. + * + * PBKDF2 and the SQLCipher open run off the main thread and can finish after the app was + * sent to the background, when [onStop] already ran and found nothing to close, or after a lock request that + * found nothing open. In that case the vault is closed again at once, [locked] stays true and this returns + * false: the caller must not navigate into protected content. + */ + @MainThread + public fun onUnlocked(lockRequestsAtStart: Long = lockRequests()): Boolean { + if (!foreground || lockRequestCount.load() != lockRequestsAtStart || deviceLock.isDeviceLocked()) { + relock() + return false + } + sessionGeneration++ + _locked.value = false + return true + } + + /** + * An unlock finished for a screen that no longer exists (its ViewModel was cleared while + * the key was being checked). Nobody will navigate into the vault, so it is closed again. + */ + @MainThread + public fun onUnlockAbandoned() { + if (isOpenAndLockable()) relock() + } + + /** + * "Lock now" on the notes screen: closes the open vault exactly as backgrounding does, so the + * lock screen follows. Nothing happens before onboarding is committed or with no vault open. + */ + @MainThread + public fun lockNow() { + // Counted even with nothing open: an unlock that is running must not publish after it. + if (securityManager.isLockable()) lockRequestCount.addAndFetch(1L) + if (isOpenAndLockable()) relock() + } + + private fun isOpenAndLockable(): Boolean = securityManager.isLockable() && securityManager.isDatabaseUnlocked() + + override fun registerPreLockFlush(flush: suspend () -> Unit): () -> Unit { + val entry = PreLockFlush(flush) + preLockFlushes.update { it + entry } + return { preLockFlushes.update { it - entry } } + } + + private fun relock() { + lockRequestCount.addAndFetch(1L) + // The screenshot allowance ends with every lock, before anything else can show. + screenshots.revoke() + val flushes = preLockFlushes.value + if (flushes.isEmpty()) { + closeVault() + return + } + // A lock already waiting for the editors covers this one. + if (!flushing.compareAndSet(false, true)) return + lockScope.launch { + try { + // The saves run in jobs of their own, so a save stuck in the database cannot hold the lock past + // the timeout: only the wait for them is cancelled. + val saves = flushes.map { flush -> + launch { runCatching { flush.run() }.onFailure { if (it is CancellationException) throw it } } + } + withTimeoutOrNull(LOCK_FLUSH_TIMEOUT_MS) { saves.joinAll() } + } finally { + closeVault() + flushing.store(false) + } + } + } + + private fun closeVault() { + securityManager.lock() + _locked.value = true + } + + private companion object { + const val NONE = Long.MIN_VALUE + const val PICKER_LAUNCH_WINDOW_MS = 5_000L + const val PICKER_MAX_AWAY_MS = 60_000L + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Utf8.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Utf8.kt new file mode 100644 index 00000000..8288b850 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/Utf8.kt @@ -0,0 +1,88 @@ +package com.pasich.encly.core.security + +internal object Utf8 { + private const val ASCII_MAX = 0x7F + private const val TWO_BYTE_MAX = 0x7FF + private const val SIX_BITS = 0x3F + private const val SIX = 6 + private const val TWELVE = 12 + private const val EIGHTEEN = 18 + private const val LEAD_2 = 0xC0 + private const val LEAD_3 = 0xE0 + private const val LEAD_4 = 0xF0 + private const val CONTINUATION = 0x80 + private const val REPLACEMENT = '?'.code + private const val SUPPLEMENTARY_BASE = 0x10000 + private const val TEN = 10 + private const val THREE_BYTES = 3 + private const val FOUR_BYTES = 4 + + /** + * [chars] in UTF-8, in a fresh array the caller wipes. No String and no intermediate buffer + * is made, so nothing but the returned array holds the encoding. An unpaired surrogate + * becomes `?`, as the JDK's encoder does. + */ + fun encode(chars: CharArray): ByteArray { + val bytes = ByteArray(encodedSize(chars)) + var out = 0 + var index = 0 + while (index < chars.size) { + val char = chars[index] + val code = char.code + when { + code <= ASCII_MAX -> bytes[out++] = code.toByte() + + code <= TWO_BYTE_MAX -> { + bytes[out++] = (LEAD_2 or (code shr SIX)).toByte() + bytes[out++] = (CONTINUATION or (code and SIX_BITS)).toByte() + } + + char.isHighSurrogate() && index + 1 < chars.size && chars[index + 1].isLowSurrogate() -> { + val point = SUPPLEMENTARY_BASE + + ((code - Char.MIN_HIGH_SURROGATE.code) shl TEN) + + (chars[index + 1].code - Char.MIN_LOW_SURROGATE.code) + bytes[out++] = (LEAD_4 or (point shr EIGHTEEN)).toByte() + bytes[out++] = (CONTINUATION or ((point shr TWELVE) and SIX_BITS)).toByte() + bytes[out++] = (CONTINUATION or ((point shr SIX) and SIX_BITS)).toByte() + bytes[out++] = (CONTINUATION or (point and SIX_BITS)).toByte() + index++ + } + + char.isSurrogate() -> bytes[out++] = REPLACEMENT.toByte() + + else -> { + bytes[out++] = (LEAD_3 or (code shr TWELVE)).toByte() + bytes[out++] = (CONTINUATION or ((code shr SIX) and SIX_BITS)).toByte() + bytes[out++] = (CONTINUATION or (code and SIX_BITS)).toByte() + } + } + index++ + } + return bytes + } + + private fun encodedSize(chars: CharArray): Int { + var size = 0 + var index = 0 + while (index < chars.size) { + val char = chars[index] + val code = char.code + size += when { + code <= ASCII_MAX -> 1 + + code <= TWO_BYTE_MAX -> 2 + + char.isHighSurrogate() && index + 1 < chars.size && chars[index + 1].isLowSurrogate() -> { + index++ + FOUR_BYTES + } + + char.isSurrogate() -> 1 + + else -> THREE_BYTES + } + index++ + } + return size + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultCodec.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultCodec.kt new file mode 100644 index 00000000..54c08312 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultCodec.kt @@ -0,0 +1,123 @@ +package com.pasich.encly.core.security + +import kotlinx.io.IOException + +/** Big-endian integers, as `DataOutputStream` and `ByteBuffer` write them. */ +internal object BigEndian { + private const val BYTE_MASK = 0xFF + + fun bytesOf(value: Int): ByteArray = ByteArray(Int.SIZE_BYTES).also { write(it, 0, value) } + + fun bytesOf(value: Long): ByteArray = ByteArray(Long.SIZE_BYTES).also { + for (i in it.indices) it[i] = (value ushr (Byte.SIZE_BITS * (Long.SIZE_BYTES - 1 - i))).toByte() + } + + /** Writes [value] at [at] in [into]; returns the position after it. */ + fun write(into: ByteArray, at: Int, value: Int): Int { + for (i in 0 until Int.SIZE_BYTES) { + into[at + i] = (value ushr (Byte.SIZE_BITS * (Int.SIZE_BYTES - 1 - i))).toByte() + } + return at + Int.SIZE_BYTES + } + + fun readInt(bytes: ByteArray, at: Int = 0): Int { + var value = 0 + for (i in 0 until Int.SIZE_BYTES) value = (value shl Byte.SIZE_BITS) or (bytes[at + i].toInt() and BYTE_MASK) + return value + } + + fun readLong(bytes: ByteArray): Long { + var value = 0L + for (i in 0 until Long.SIZE_BYTES) { + value = (value shl Byte.SIZE_BITS) or (bytes[i].toLong() and BYTE_MASK.toLong()) + } + return value + } +} + +/** + * Java's "modified UTF-8", the encoding of `DataOutputStream.writeUTF` keys: NUL is two bytes + * and a character outside the BMP is its two surrogates, three bytes each. The vault file keeps + * this form so every file an earlier build wrote still reads back. + */ +internal object ModifiedUtf8 { + private const val ONE_BYTE_MAX = 0x7F + private const val TWO_BYTE_MAX = 0x7FF + private const val SIX_BITS = 0x3F + private const val FIVE_BITS = 0x1F + private const val FOUR_BITS = 0x0F + private const val SIX = 6 + private const val TWELVE = 12 + private const val LEAD_2 = 0xC0 + private const val LEAD_3 = 0xE0 + private const val CONTINUATION = 0x80 + private const val CONTINUATION_MASK = 0xC0 + private const val LEAD_MASK_2 = 0xE0 + private const val LEAD_MASK_3 = 0xF0 + private const val BYTE_MASK = 0xFF + private const val MAX_LENGTH = 0xFFFF + private const val THREE_BYTES = 3 + + fun encode(text: String): ByteArray { + val out = ArrayList(text.length) + for (char in text) { + val code = char.code + when { + code in 1..ONE_BYTE_MAX -> out.add(code.toByte()) + + code <= TWO_BYTE_MAX -> { + out.add((LEAD_2 or (code shr SIX)).toByte()) + out.add((CONTINUATION or (code and SIX_BITS)).toByte()) + } + + else -> { + out.add((LEAD_3 or (code shr TWELVE)).toByte()) + out.add((CONTINUATION or ((code shr SIX) and SIX_BITS)).toByte()) + out.add((CONTINUATION or (code and SIX_BITS)).toByte()) + } + } + } + if (out.size > MAX_LENGTH) throw IOException("key too long") + return out.toByteArray() + } + + @Suppress("ThrowsCount") // Each malformed form is its own failure. + fun decode(bytes: ByteArray): String { + val out = StringBuilder(bytes.size) + var at = 0 + while (at < bytes.size) { + val first = bytes[at].toInt() and BYTE_MASK + when { + first <= ONE_BYTE_MAX -> { + out.append(first.toChar()) + at++ + } + + first and LEAD_MASK_2 == LEAD_2 -> { + if (at + 1 >= bytes.size) throw IOException("malformed key") + val second = bytes[at + 1].toInt() and BYTE_MASK + if (second and CONTINUATION_MASK != CONTINUATION) throw IOException("malformed key") + out.append((((first and FIVE_BITS) shl SIX) or (second and SIX_BITS)).toChar()) + at += 2 + } + + first and LEAD_MASK_3 == LEAD_3 -> { + if (at + 2 >= bytes.size) throw IOException("malformed key") + val second = bytes[at + 1].toInt() and BYTE_MASK + val third = bytes[at + 2].toInt() and BYTE_MASK + if (second and CONTINUATION_MASK != CONTINUATION || third and CONTINUATION_MASK != CONTINUATION) { + throw IOException("malformed key") + } + out.append( + (((first and FOUR_BITS) shl TWELVE) or ((second and SIX_BITS) shl SIX) or (third and SIX_BITS)) + .toChar(), + ) + at += THREE_BYTES + } + + else -> throw IOException("malformed key") + } + } + return out.toString() + } +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultDatabase.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultDatabase.kt new file mode 100644 index 00000000..a0388d32 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultDatabase.kt @@ -0,0 +1,45 @@ +package com.pasich.encly.core.security + +/** + * The two vaults a device can hold: the real one, and the decoy vault the duress PIN opens (its own + * database, its own random DEK). At most one of them is open at a time. + */ +public enum class VaultKind { + PRIMARY, + DECOY, +} + +/** + * What [SecurityManager] needs from the encrypted database: open it with a DEK, ask whether it is open, + * close it, delete it. The database module implements it; the security logic never sees Room or the + * cipher. A [VaultKind] names the database file, `PRIMARY` unless said otherwise. + */ +public interface VaultDatabase { + /** Whether a database is open. */ + public fun isDatabaseUnlocked(): Boolean + + /** Whether [vault]'s database file exists and is encrypted. */ + public fun hasEncryptedDatabase(vault: VaultKind = VaultKind.PRIMARY): Boolean + + /** [unlockDatabase] for the real vault's database. */ + public fun unlockDatabase(dek: ByteArray, allowCreate: Boolean = false): Boolean + + /** + * Opens [vault]'s database with the raw 32-byte [dek]; the caller keeps (and wipes) [dek]. A missing + * file is created only when [allowCreate]. While one is open, a repeated call succeeds only with the + * same key and vault. + */ + public fun unlockDatabase(dek: ByteArray, allowCreate: Boolean, vault: VaultKind): Boolean + + /** Closes the open database and wipes the copy of its key. */ + public fun reset() + + /** Closes the open database and deletes the database files. */ + public fun wipe() + + /** + * Deletes the database file of [only] (every one when null) unless it is the open one; true when + * what was asked for is gone afterwards. + */ + public fun wipeIfClosed(only: VaultKind? = null): Boolean +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultFileIo.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultFileIo.kt new file mode 100644 index 00000000..98435869 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultFileIo.kt @@ -0,0 +1,29 @@ +package com.pasich.encly.core.security + +/** + * The file operations [VaultStore] needs, kept behind an interface because they carry its + * durability promise: kotlinx-io has neither an fsync nor an atomic replace, so the + * implementation (JvmVaultFileIo on Android and the JVM) does the write-temp, fsync, atomic-rename + * itself. Every failure is a [kotlinx.io.IOException]. + */ +public interface VaultFileIo { + /** Whether the vault file exists. */ + public fun exists(): Boolean + + /** The whole vault file. */ + public fun read(): ByteArray + + /** Removes the temp file a write that never reached its rename left behind; never throws. */ + public fun deleteTemp() + + /** + * Replaces the vault file with [bytes] so that a crash leaves the old content or the new, + * never a mix: the bytes go to a temp file, which is fsynced, then [beforeRename] runs (a test + * hook), then the temp file is renamed over the vault file atomically. A failure removes the + * temp file and throws. + */ + public fun replace(bytes: ByteArray, beforeRename: (() -> Unit)?) + + /** Deletes the vault file; true when it is gone afterwards (it may never have existed). */ + public fun delete(): Boolean +} diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultStore.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultStore.kt new file mode 100644 index 00000000..31f4b919 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/VaultStore.kt @@ -0,0 +1,257 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.crypto.Crypto +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.io.IOException + +/** + * The vault's key slots and PIN lockout state, in one app-private file. + * + * Every secret stored here is already an AES-256-GCM envelope (PIN, recovery, backup-key and + * biometric slots), so the file needs no second encryption layer and no Keystore key to be + * read: unlike EncryptedSharedPreferences, a broken Keystore/Tink keyset can not make it + * unreadable. What it does need is to never be half-written and to notice damage: + * + * - every change rewrites the whole file to a temp file, fsyncs it and renames it over the + * old one (an atomic replace on the same filesystem), so a crash leaves either the old or + * the new state, never a mix; + * - the file ends with a SHA-256 of its content. A file that fails the check is reported by + * [isCorrupt] and reads as empty (fail closed: no slot, nothing unlocks), writes are + * refused, and only [clear] (the damaged-vault wipe) starts over. + * - a file that cannot be read right now (an I/O error: a scanner holding it, iOS data protection + * while the device is locked) is not damage. [isUnreadable] reports it, it is not remembered, + * every access tries again, and writes are refused meanwhile; [isCorrupt] stays false. + * + * The digest detects accidental damage only; tampering is caught by each envelope's GCM tag. + * Values are copied in and out, and the caller wipes what it gets. + * + * The file format is the one `DataOutputStream` wrote (big-endian ints, modified-UTF-8 keys), + * coded here by hand so the logic is platform-free; only the file itself (the fsync and the + * atomic replace, which kotlinx-io cannot promise) is behind [VaultFileIo]. + */ +@Suppress("TooManyFunctions") // Typed getters/setters of one small key-value file. +public class VaultStore(private val io: VaultFileIo) { + private val lock = SynchronizedObject() + + // Guarded by [lock]. Null until the first read. + private var cache: Map? = null + private var corrupt = false + private var unreadable = false + + /** True when the file exists but could not be read back intact. */ + public fun isCorrupt(): Boolean = synchronized(lock) { + load() + corrupt + } + + /** + * True when the file exists but the last attempt to read it failed with an I/O error. Not + * damage and not remembered: the next access reads again. What is read meanwhile is empty and + * must not be taken for a vault that has no slots. + */ + public fun isUnreadable(): Boolean = synchronized(lock) { + load() + unreadable + } + + public fun contains(key: String): Boolean = synchronized(lock) { load().containsKey(key) } + + /** The keys present; for tests that compare what a store reveals by its shape. */ + internal fun keys(): Set = synchronized(lock) { load().keys.toSet() } + + public fun getBytes(key: String): ByteArray? = synchronized(lock) { load()[key]?.copyOf() } + + public fun getInt(key: String, default: Int): Int = synchronized(lock) { + load()[key]?.takeIf { it.size == Int.SIZE_BYTES }?.let { BigEndian.readInt(it) } ?: default + } + + public fun getLong(key: String, default: Long): Long = synchronized(lock) { + load()[key]?.takeIf { it.size == Long.SIZE_BYTES }?.let { BigEndian.readLong(it) } ?: default + } + + public fun getBoolean(key: String, default: Boolean): Boolean = synchronized(lock) { + load()[key]?.takeIf { it.size == BOOLEAN_BYTES }?.let { it[0] == TRUE } ?: default + } + + /** + * Applies every change made in [block] in one atomic, durable write. Returns false (and + * changes nothing) when the file is damaged, cannot be read or cannot be written. + */ + public fun edit(block: Editor.() -> Unit): Boolean = synchronized(lock) { + val current = load() + if (corrupt || unreadable) return false + val next = LinkedHashMap(current) + Editor(next).block() + val written = try { + write(next) + true + } catch (_: IOException) { + false + } + if (written) { + cache = next + // Values dropped or replaced by this edit are no longer referenced. + current.forEach { (key, value) -> if (next[key] !== value) SensitiveDataCleaner.clear(value) } + } else { + next.forEach { (key, value) -> if (current[key] !== value) SensitiveDataCleaner.clear(value) } + } + written + } + + /** Deletes the file (damaged or not) and forgets everything. */ + public fun clear(): Boolean = synchronized(lock) { + cache?.values?.forEach(SensitiveDataCleaner::clear) + cache = emptyMap() + corrupt = false + unreadable = false + io.deleteTemp() + io.delete() + } + + public class Editor internal constructor(private val values: MutableMap) { + /** Stores a copy of [value]; the caller keeps (and wipes) its own. */ + public fun putBytes(key: String, value: ByteArray) { + values[key] = value.copyOf() + } + + public fun putInt(key: String, value: Int) { + values[key] = BigEndian.bytesOf(value) + } + + public fun putLong(key: String, value: Long) { + values[key] = BigEndian.bytesOf(value) + } + + public fun putBoolean(key: String, value: Boolean) { + values[key] = byteArrayOf(if (value) TRUE else FALSE) + } + + public fun remove(key: String) { + values.remove(key) + } + + /** Removes every key starting with [prefix]. */ + public fun removePrefix(prefix: String) { + values.keys.removeAll { it.startsWith(prefix) } + } + } + + private fun load(): Map { + cache?.let { return it } + // A temp file left by a write that never reached its rename is never the state. + io.deleteTemp() + var loaded: Map? = emptyMap() + unreadable = false + if (io.exists()) { + try { + loaded = parse(io.read()) + } catch (_: IOException) { + // Not damage, and not remembered: the next access reads the file again. + unreadable = true + } + } + corrupt = !unreadable && loaded == null + val result = loaded ?: emptyMap() + if (!unreadable) cache = result + return result + } + + private fun write(values: Map) { + val bytes = serialize(values) + try { + io.replace(bytes, beforeRename) + } finally { + SensitiveDataCleaner.clear(bytes) + } + } + + /** Test hook: runs after the temp file is durable and before it replaces the real one. */ + internal var beforeRename: (() -> Unit)? = null + + private companion object { + const val DIGEST_LENGTH = 32 + const val UTF_LENGTH_BYTES = 2 + const val BYTE_MASK = 0xFF + const val MAX_ENTRIES = 256 + const val MAX_VALUE_LENGTH = 64 * 1024 + const val BOOLEAN_BYTES = 1 + const val TRUE: Byte = 1 + const val FALSE: Byte = 0 + val MAGIC = "ENCLYVS3".encodeToByteArray() + + fun serialize(values: Map): ByteArray { + val keys = values.keys.map { ModifiedUtf8.encode(it) } + val bodyLength = MAGIC.size + Int.SIZE_BYTES + + keys.sumOf { UTF_LENGTH_BYTES + it.size + Int.SIZE_BYTES } + values.values.sumOf { it.size } + val body = ByteArray(bodyLength + DIGEST_LENGTH) + MAGIC.copyInto(body) + var at = BigEndian.write(body, MAGIC.size, values.size) + values.values.forEachIndexed { index, value -> + val key = keys[index] + body[at++] = (key.size ushr Byte.SIZE_BITS).toByte() + body[at++] = key.size.toByte() + key.copyInto(body, at) + at += key.size + at = BigEndian.write(body, at, value.size) + value.copyInto(body, at) + at += value.size + } + val digest = Crypto.sha256(body, 0, bodyLength) + digest.copyInto(body, bodyLength) + SensitiveDataCleaner.clear(digest) + return body + } + + /** The entries of [bytes], or null when it is not an intact store file. */ + @Suppress("ReturnCount") // Each structural check fails closed. + fun parse(bytes: ByteArray): Map? { + try { + if (bytes.size < MAGIC.size + Int.SIZE_BYTES + DIGEST_LENGTH) return null + val bodyLength = bytes.size - DIGEST_LENGTH + val expected = Crypto.sha256(bytes, 0, bodyLength) + if (!Crypto.constantTimeEquals(expected, bytes.copyOfRange(bodyLength, bytes.size))) return null + val input = Cursor(bytes, bodyLength) + val magic = input.readBytes(MAGIC.size) + if (!magic.contentEquals(MAGIC)) return null + val count = input.readInt() + if (count !in 0..MAX_ENTRIES) return null + val values = LinkedHashMap(count) + repeat(count) { + val key = ModifiedUtf8.decode(input.readBytes(input.readUnsignedShort())) + val length = input.readInt() + if (length !in 0..MAX_VALUE_LENGTH) return null + values[key] = input.readBytes(length) + } + return if (input.remaining == 0) values else null + } catch (_: IOException) { + return null + } finally { + SensitiveDataCleaner.clear(bytes) + } + } + } + + /** A read position in [bytes], up to [limit]; running out is an [IOException], as with a stream. */ + private class Cursor(private val bytes: ByteArray, private val limit: Int) { + private var at = 0 + + val remaining: Int get() = limit - at + + fun readBytes(length: Int): ByteArray { + if (length > remaining) throw IOException("end of file") + return bytes.copyOfRange(at, at + length).also { at += length } + } + + fun readInt(): Int { + if (Int.SIZE_BYTES > remaining) throw IOException("end of file") + return BigEndian.readInt(bytes, at).also { at += Int.SIZE_BYTES } + } + + fun readUnsignedShort(): Int { + if (UTF_LENGTH_BYTES > remaining) throw IOException("end of file") + return (((bytes[at].toInt() and BYTE_MASK) shl Byte.SIZE_BITS) or (bytes[at + 1].toInt() and BYTE_MASK)) + .also { at += UTF_LENGTH_BYTES } + } + } +} diff --git a/app/src/test/java/com/pasich/encly/core/backup/BackupCipherTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupCipherTest.kt similarity index 82% rename from app/src/test/java/com/pasich/encly/core/backup/BackupCipherTest.kt rename to core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupCipherTest.kt index 23d5a4bf..bba1f931 100644 --- a/app/src/test/java/com/pasich/encly/core/backup/BackupCipherTest.kt +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupCipherTest.kt @@ -1,18 +1,16 @@ package com.pasich.encly.core.backup -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode -import cash.z.ecc.android.bip39.Mnemonics.WordCount -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.fail -import org.junit.Test -import java.nio.ByteBuffer +import com.pasich.encly.core.security.Mnemonic +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.fail class BackupCipherTest { - private val words = MnemonicCode(WordCount.COUNT_12).chars - private val plaintext = "{\"notes\":[\"secret note body\"]}".toByteArray() + private val words = Mnemonic.generate() + private val plaintext = "{\"notes\":[\"secret note body\"]}".encodeToByteArray() private fun phrase(chars: CharArray = words) = BackupSecret.RecoveryPhrase(chars.copyOf()) @@ -20,7 +18,7 @@ class BackupCipherTest { fun sealedFileOpensWithTheSameWords() { val file = BackupCipher.seal(plaintext, phrase()) - assertArrayEquals(plaintext, BackupCipher.open(file, phrase())) + assertContentEquals(plaintext, BackupCipher.open(file, phrase())) } @Test @@ -29,21 +27,21 @@ class BackupCipherTest { val root = BackupKeys.rootFromMnemonic(words.copyOf()) val file = BackupCipher.seal(plaintext, BackupSecret.RecoveryRoot(root)) - assertArrayEquals(plaintext, BackupCipher.open(file, phrase())) + assertContentEquals(plaintext, BackupCipher.open(file, phrase())) } @Test fun wordsAreMatchedRegardlessOfCaseAndSpacing() { val file = BackupCipher.seal(plaintext, phrase()) - val sloppy = (" " + String(words).uppercase().replace(" ", " \n") + " ").toCharArray() + val sloppy = (" " + words.concatToString().uppercase().replace(" ", " \n") + " ").toCharArray() - assertArrayEquals(plaintext, BackupCipher.open(file, phrase(sloppy))) + assertContentEquals(plaintext, BackupCipher.open(file, phrase(sloppy))) } @Test fun wrongWordsAreRejected() { val file = BackupCipher.seal(plaintext, phrase()) - val other = MnemonicCode(WordCount.COUNT_12).chars + val other = Mnemonic.generate() assertError(BackupError.WRONG_SECRET) { BackupCipher.open(file, phrase(other)) } } @@ -62,7 +60,7 @@ class BackupCipherTest { fun theFileNeverContainsThePlaintext() { val file = BackupCipher.seal(plaintext, phrase()) - assertFalse(String(file, Charsets.ISO_8859_1).contains("secret note body")) + assertFalse(file.decodeToString().contains("secret note body")) } @Test @@ -104,7 +102,10 @@ class BackupCipherTest { @Test fun newerFormatVersionIsReportedBeforeAnyDecryption() { val file = BackupCipher.seal(plaintext, phrase()) - ByteBuffer.wrap(file).putShort(VERSION_OFFSET, (BackupFormat.VERSION + 1).toShort()) + // The version field is a big-endian short. + val newer = BackupFormat.VERSION + 1 + file[VERSION_OFFSET] = (newer shr Byte.SIZE_BITS).toByte() + file[VERSION_OFFSET + 1] = newer.toByte() assertError(BackupError.UNSUPPORTED_VERSION) { BackupCipher.inspect(file) } assertError(BackupError.UNSUPPORTED_VERSION) { BackupCipher.open(file, phrase()) } @@ -128,7 +129,7 @@ class BackupCipherTest { @Test fun otherFilesAreNotBackups() { assertError(BackupError.NOT_A_BACKUP) { BackupCipher.inspect(ByteArray(0)) } - assertError(BackupError.NOT_A_BACKUP) { BackupCipher.inspect("%PDF-1.7 not a backup".toByteArray()) } + assertError(BackupError.NOT_A_BACKUP) { BackupCipher.inspect("%PDF-1.7 not a backup".encodeToByteArray()) } } @Test @@ -140,7 +141,7 @@ class BackupCipherTest { fun emptyPayloadRoundTrips() { val file = BackupCipher.seal(ByteArray(0), phrase()) - assertArrayEquals(ByteArray(0), BackupCipher.open(file, phrase())) + assertContentEquals(ByteArray(0), BackupCipher.open(file, phrase())) } private companion object { diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupFormatTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupFormatTest.kt new file mode 100644 index 00000000..666cacf6 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupFormatTest.kt @@ -0,0 +1,99 @@ +package com.pasich.encly.core.backup + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +/** The container header byte by byte, and the order in which a damaged one is reported. */ +class BackupFormatTest { + + private val salt = ByteArray(BackupFormat.SALT_LENGTH) { (it + 1).toByte() } + private val nonce = ByteArray(BackupFormat.NONCE_LENGTH) { (0x80 + it).toByte() } + + private fun header(ciphertextLength: Int = 100) = + BackupFormat.encodeHeader(BackupKdf.RECOVERY_PHRASE_HKDF_SHA256, salt, nonce, ciphertextLength) + + /** A header with [ciphertextLength] declared, followed by that many ciphertext bytes. */ + private fun file(ciphertextLength: Int = 100) = header(ciphertextLength) + ByteArray(ciphertextLength) + + private fun ByteArray.with(at: Int, value: Int) = copyOf().also { it[at] = value.toByte() } + + @Test + fun theHeaderIsBigEndianAtTheDocumentedOffsets() { + val bytes = header(ciphertextLength = 0x01020304) + + assertEquals(BackupFormat.HEADER_LENGTH, bytes.size) + assertContentEquals("ENCLYBAK".encodeToByteArray(), bytes.copyOfRange(0, 8)) + assertContentEquals(byteArrayOf(0, 1), bytes.copyOfRange(8, 10)) + assertEquals(1, bytes[10].toInt()) + assertEquals(BackupFormat.SALT_LENGTH, bytes[11].toInt()) + assertContentEquals(salt, bytes.copyOfRange(12, 44)) + assertEquals(BackupFormat.NONCE_LENGTH, bytes[44].toInt()) + assertContentEquals(nonce, bytes.copyOfRange(45, 57)) + assertContentEquals(byteArrayOf(1, 2, 3, 4), bytes.copyOfRange(57, 61)) + } + + @Test + fun aHeaderParsesBackIntoItsFields() { + val parsed = BackupFormat.parse(file(ciphertextLength = 100)) + + assertEquals(BackupFormat.VERSION, parsed.header.formatVersion) + assertEquals(BackupKdf.RECOVERY_PHRASE_HKDF_SHA256, parsed.header.kdf) + assertContentEquals(salt, parsed.header.salt) + assertContentEquals(nonce, parsed.header.nonce) + assertEquals(100, parsed.header.ciphertextLength) + } + + @Test + fun theMagicIsCheckedFirstThenTheVersionThenTheLengthOfTheHeader() { + assertError(BackupError.NOT_A_BACKUP) { BackupFormat.parseHeader(ByteArray(0)) } + assertError(BackupError.NOT_A_BACKUP) { BackupFormat.parseHeader(header().with(0, 'X'.code)) } + // Magic only: the version is not there yet. + assertError(BackupError.TRUNCATED) { BackupFormat.parseHeader(header().copyOf(9)) } + // A newer version is reported even when the rest of the header is missing or wrong. + assertError(BackupError.UNSUPPORTED_VERSION) { + BackupFormat.parseHeader(header().with(9, 2).copyOf(BackupFormat.HEADER_LENGTH - 1)) + } + assertError(BackupError.TRUNCATED) { BackupFormat.parseHeader(header().copyOf(BackupFormat.HEADER_LENGTH - 1)) } + } + + @Test + fun anUnknownKdfOrAWrongSaltOrNonceLengthIsCorrupted() { + assertError(BackupError.CORRUPTED) { BackupFormat.parseHeader(header().with(10, 0x7f)) } + assertError(BackupError.CORRUPTED) { BackupFormat.parseHeader(header().with(11, 16)) } + assertError(BackupError.CORRUPTED) { BackupFormat.parseHeader(header().with(44, 8)) } + } + + @Test + fun theDeclaredSizeIsCheckedAgainstTheLimitBeforeItIsTrustedAsATagCarrier() { + val limit = (BackupFormat.HEADER_LENGTH + 100).toLong() + assertEquals(100, BackupFormat.parseHeader(header(100), limit).ciphertextLength) + assertError(BackupError.TOO_LARGE) { BackupFormat.parseHeader(header(101), limit) } + // 0xFFFFFFFF is a huge unsigned length, not -1. + assertError(BackupError.TOO_LARGE) { BackupFormat.parseHeader(header(-1)) } + // Smaller than the GCM tag it must contain. + assertError(BackupError.CORRUPTED) { BackupFormat.parseHeader(header(BackupFormat.TAG_LENGTH - 1)) } + assertEquals( + BackupFormat.TAG_LENGTH, + BackupFormat.parseHeader(header(BackupFormat.TAG_LENGTH)).ciphertextLength, + ) + } + + @Test + fun theWholeFileIsCheckedAgainstTheDeclaredLength() { + val whole = file(100) + assertError(BackupError.TRUNCATED) { BackupFormat.parse(whole.copyOf(whole.size - 1)) } + assertError(BackupError.CORRUPTED) { BackupFormat.parse(whole + byteArrayOf(0)) } + assertError(BackupError.TOO_LARGE) { BackupFormat.parse(ByteArray(BackupFormat.MAX_FILE_BYTES + 1)) } + // A length field that overruns the file it sits in is a truncation, not a size problem. + assertError(BackupError.TRUNCATED) { BackupFormat.parse(header(BackupFormat.MAX_FILE_BYTES) + ByteArray(20)) } + } + + @Test + fun parsingReadsOnlyTheHeaderOfALargeFile() { + val whole = file(1_000_000) + + assertEquals(1_000_000, BackupFormat.parse(whole).header.ciphertextLength) + assertEquals(BackupFormat.HEADER_LENGTH, BackupFormat.parse(whole).ciphertextOffset) + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt new file mode 100644 index 00000000..06c5a571 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt @@ -0,0 +1,389 @@ +package com.pasich.encly.core.backup + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BackupPayloadCodecTest { + + private val tag = BackupTag(uid = "t1", name = "Work", visible = true, position = 0) + private val payload = BackupPayload( + exportedAt = 1_700_000_000_000, + tags = listOf(tag), + notes = listOf( + BackupNote("n1", "Title", "[]", "desc", 2L, 1L, tagUids = listOf("t1"), isTrash = true), + ), + tasks = listOf( + BackupTask( + "k1", + "Task", + null, + false, + 3L, + null, + priority = 2, + categoryTagUid = "t1", + position = 5, + subtasks = emptyList(), + ), + ), + ) + private val subtasks = listOf( + BackupSubtask(uid = "s1", title = "Milk", isCompleted = true, position = 0), + BackupSubtask(uid = "s2", title = "Bread", isCompleted = false, position = 1), + ) + private val withSubtasks = payload.copy(tasks = payload.tasks.map { it.copy(subtasks = subtasks) }) + private val withSettings = withSubtasks.copy( + settings = linkedMapOf("theme_type" to "DARK", "font_size" to "18", "is_grid_note_list" to "true"), + ) + + @Test + fun encodeDecodeRoundTrips() { + assertEquals(payload, BackupPayloadCodec.decode(BackupPayloadCodec.encode(payload))) + } + + @Test + fun subtasksRoundTrip() { + assertEquals(withSubtasks, BackupPayloadCodec.decode(BackupPayloadCodec.encode(withSubtasks))) + } + + @Test + fun emptyVaultRoundTrips() { + val empty = BackupPayload(exportedAt = 0, tags = emptyList(), notes = emptyList(), tasks = emptyList()) + + assertEquals(empty, BackupPayloadCodec.decode(BackupPayloadCodec.encode(empty))) + } + + @Test + fun malformedJsonIsInvalid() { + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode("{not json".encodeToByteArray()) } + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode("{}".encodeToByteArray()) } + } + + @Test + fun newerSchemaIsUnsupportedNotCorrupted() { + val newer = BackupPayloadCodec.encode(payload.copy(schema = BackupPayload.SCHEMA_VERSION + 1)) + + assertError(BackupError.UNSUPPORTED_VERSION) { BackupPayloadCodec.decode(newer) } + } + + @Test + fun newerSchemaWithFieldsThisVersionDoesNotKnowIsUnsupported() { + // What a future app writes: a bumped schema plus a field this build has never heard of. + val newer = withExtraField(payload.copy(schema = BackupPayload.SCHEMA_VERSION + 1)) + + assertError(BackupError.UNSUPPORTED_VERSION) { BackupPayloadCodec.decode(newer) } + } + + @Test + fun unknownFieldInTheCurrentSchemaIsInvalid() { + // Every payload change must bump SCHEMA_VERSION, so this is a malformed file. + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(withExtraField(payload)) } + } + + @Test + fun olderUnknownSchemaIsInvalid() { + assertInvalid(payload.copy(schema = 0)) + } + + @Test + fun schema1BackupDecodesWithoutItsReminders() { + val decoded = BackupPayloadCodec.decode(schema1Json().encodeToByteArray()) + + assertEquals(payload, decoded) + assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) + } + + @Test + fun schema2BackupDecodesWithNoSubtasks() { + val decoded = BackupPayloadCodec.decode(schema2Json().encodeToByteArray()) + + assertEquals(payload, decoded) + assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) + } + + @Test + fun schema2BackupWithSubtasksIsInvalid() { + // Schema 2 never had sub-tasks: such a file is malformed, not a newer one. + val json = schema2Json().replace("\"position\":5}", "\"position\":5,\"subtasks\":[]}") + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.encodeToByteArray()) } + } + + @Test + fun schema3BackupDecodesWithDefaultNoteOrganizing() { + val decoded = BackupPayloadCodec.decode(schema3Json().encodeToByteArray()) + + assertEquals(withSubtasks, decoded) + assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) + with(decoded.notes.single()) { + assertEquals(false, isPinned) + assertEquals(false, isArchived) + assertEquals("none", color) + } + } + + @Test + fun schema3BackupWithNoteOrganizingKeysIsInvalid() { + // Schema 3 never had these keys: such a file is malformed, not a newer one. + val json = schema3Json().replace("\"isTrash\":true}", "\"isTrash\":true,\"isPinned\":true}") + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.encodeToByteArray()) } + } + + @Test + fun noteOrganizingRoundTrips() { + val organized = payload.copy( + notes = payload.notes.map { it.copy(isPinned = true, isArchived = true, color = "teal") }, + ) + + assertEquals(organized, BackupPayloadCodec.decode(BackupPayloadCodec.encode(organized))) + } + + @Test + fun aNotesOwnTextRoundTripsAndOnlyKnownValuesAreValid() { + val own = payload.copy(notes = payload.notes.map { it.copy(textSize = 24, lineSpacing = 2) }) + assertEquals(own, BackupPayloadCodec.decode(BackupPayloadCodec.encode(own))) + + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(textSize = 9) })) + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(textSize = 33) })) + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(lineSpacing = 3) })) + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(lineSpacing = -1) })) + } + + @Test + fun schema3BackupWithATextSizeKeyIsInvalid() { + val json = schema3Json().replace("\"isTrash\":true}", "\"isTrash\":true,\"textSize\":20}") + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.encodeToByteArray()) } + } + + @Test + fun blankOrOverlongColorIsInvalid() { + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(color = "") })) + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(color = "x".repeat(33)) })) + } + + @Test + fun currentSchemaRequiresTheSubtasksList() { + val json = BackupPayloadCodec.encode(payload).decodeToString().replace(",\"subtasks\":[]", "") + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.encodeToByteArray()) } + } + + @Test + fun schema1BackupStillRejectsOtherUnknownFields() { + val json = schema1Json().replaceFirst("{", "{\"pinnedNotes\":[\"n1\"],") + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.encodeToByteArray()) } + } + + @Test + fun reminderInTheCurrentSchemaIsInvalid() { + val json = BackupPayloadCodec.encode(payload).decodeToString() + .replace("\"completedDate\":null,", "\"completedDate\":null,\"reminderDate\":4,") + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.encodeToByteArray()) } + } + + /** + * The key names of a backup file are its format: backups written by the release build must + * be readable by any other build, so none of them may follow a (renamable) property name. + * This is the schema-6 vector, kept byte for byte: the schema-4 file with the note's tags as a list. + */ + @Test + fun encodedKeyNamesArePinned() { + assertEquals(schema6Json(), BackupPayloadCodec.encode(withSubtasks).decodeToString()) + } + + /** The same file plus the `settings` section, last, in the order given. */ + @Test + fun encodedSettingsSectionIsPinned() { + val expected = schema6Json().removeSuffix("}") + + ""","settings":{"theme_type":"DARK","font_size":"18","is_grid_note_list":"true"}}""" + + assertEquals(expected, BackupPayloadCodec.encode(withSettings).decodeToString()) + } + + @Test + fun schema5BackupDecodesWithItsSettingsAndTheNotesOneTagAsAList() { + val json = schema4Json().replaceFirst("\"schema\":4", "\"schema\":5").removeSuffix("}") + + ""","settings":{"theme_type":"DARK","font_size":"18","is_grid_note_list":"true"}}""" + + val decoded = BackupPayloadCodec.decode(json.encodeToByteArray()) + + assertEquals(withSettings, decoded) + assertEquals(listOf("t1"), decoded.notes.single().tagUids) + } + + @Test + fun schema5NoteWithoutATagHasNone() { + val json = schema4Json().replaceFirst("\"schema\":4", "\"schema\":5") + .replace("\"tagUid\":\"t1\"", "\"tagUid\":null") + + assertEquals(emptyList(), BackupPayloadCodec.decode(json.encodeToByteArray()).notes.single().tagUids) + } + + @Test + fun schema5BackupWithTheTagListIsInvalidAndSchema6WithOneTagIsInvalid() { + // Each schema has its own key: a file that mixes them is malformed, not a newer one. + val schema5 = schema4Json().replaceFirst("\"schema\":4", "\"schema\":5") + .replace("\"tagUid\":\"t1\"", "\"tagUids\":[\"t1\"]") + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(schema5.encodeToByteArray()) } + + val schema6 = schema6Json().replace("\"tagUids\":[\"t1\"]", "\"tagUid\":\"t1\"") + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(schema6.encodeToByteArray()) } + } + + @Test + fun aNoteWithSeveralTagsRoundTrips() { + val second = BackupTag(uid = "t2", name = "Home", visible = false, position = 1) + val tagged = payload.copy( + tags = listOf(tag, second), + notes = payload.notes.map { it.copy(tagUids = listOf("t2", "t1")) } + + payload.notes.single().copy(uid = "n2", tagUids = emptyList()), + ) + + assertEquals(tagged, BackupPayloadCodec.decode(BackupPayloadCodec.encode(tagged))) + } + + @Test + fun aPayloadWithoutSettingsLeavesTheSectionOut() { + assertEquals(false, BackupPayloadCodec.encode(payload).decodeToString().contains("settings")) + } + + @Test + fun settingsRoundTrip() { + assertEquals(withSettings, BackupPayloadCodec.decode(BackupPayloadCodec.encode(withSettings))) + } + + @Test + fun schema4BackupDecodesWithNoSettings() { + val decoded = BackupPayloadCodec.decode(schema4Json().encodeToByteArray()) + + assertEquals(withSubtasks.copy(schema = BackupPayload.SCHEMA_VERSION), decoded) + assertEquals(emptyMap(), decoded.settings) + } + + @Test + fun schema4BackupWithSettingsIsInvalid() { + // Schema 4 never had the section: such a file is malformed, not a newer one. + val json = schema4Json().removeSuffix("}") + ""","settings":{"theme_type":"DARK"}}""" + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.encodeToByteArray()) } + } + + @Test + fun aSchemaAboveTheKnownOneIsUnsupportedEvenWithASettingsSection() { + // How a reader treats a file newer than itself: the schema number is read first, before any field, + // so a section it does not know is "update the app", never "damaged". An app that reads 4 does + // exactly this to a schema-5 file; this build is shown the same with the schema after its own. + val future = withSettings.copy(schema = BackupPayload.SCHEMA_VERSION + 1) + + assertError(BackupError.UNSUPPORTED_VERSION) { BackupPayloadCodec.decode(BackupPayloadCodec.encode(future)) } + } + + @Test + fun settingsAreNeverValidatedIntoAFailure() { + // Unknown keys and bad values are the restore's to ignore, key by key; the file stays valid. + val odd = payload.copy(settings = mapOf("from_the_future" to "x", "theme_type" to "NEON")) + + assertEquals(odd, BackupPayloadCodec.decode(BackupPayloadCodec.encode(odd))) + } + + /** [payload] as the schema-1 app wrote it: every task carried a `reminderDate`. */ + private fun schema1Json(): String = """{"schema":1,"exportedAt":1700000000000,""" + + """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + + """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + + """"dateCreate":1,"tagUid":"t1","isTrash":true}],""" + + """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + + """"createdDate":3,"completedDate":null,"reminderDate":4,"priority":2,""" + + """"categoryTagUid":"t1","position":5}]}""" + + /** [payload] as the schema-2 app wrote it: tasks had no `subtasks`. */ + private fun schema2Json(): String = """{"schema":2,"exportedAt":1700000000000,""" + + """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + + """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + + """"dateCreate":1,"tagUid":"t1","isTrash":true}],""" + + """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + + """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5}]}""" + + /** [withSubtasks] as the schema-4 app wrote it: the final shape before preferences. */ + private fun schema4Json(): String = """{"schema":4,"exportedAt":1700000000000,""" + + """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + + """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + + """"dateCreate":1,"tagUid":"t1","isTrash":true,"isPinned":false,"isArchived":false,""" + + """"color":"none","textSize":null,"lineSpacing":null}],""" + + """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + + """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5,""" + + """"subtasks":[{"uid":"s1","title":"Milk","isCompleted":true,"position":0},""" + + """{"uid":"s2","title":"Bread","isCompleted":false,"position":1}]}]}""" + + /** [withSubtasks] as this app writes it (schema 6): the schema-4 file with the note's tags as a list. */ + private fun schema6Json(): String = schema4Json().replaceFirst("\"schema\":4", "\"schema\":6") + .replace("\"tagUid\":\"t1\"", "\"tagUids\":[\"t1\"]") + + /** [withSubtasks] as the schema-3 app wrote it: notes had no pin, archive or colour. */ + private fun schema3Json(): String = """{"schema":3,"exportedAt":1700000000000,""" + + """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + + """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + + """"dateCreate":1,"tagUid":"t1","isTrash":true}],""" + + """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + + """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5,""" + + """"subtasks":[{"uid":"s1","title":"Milk","isCompleted":true,"position":0},""" + + """{"uid":"s2","title":"Bread","isCompleted":false,"position":1}]}]}""" + + private fun withExtraField(value: BackupPayload): ByteArray { + val json = BackupPayloadCodec.encode(value).decodeToString() + return json.replaceFirst("{", "{\"pinnedNotes\":[\"n1\"],").encodeToByteArray() + } + + @Test + fun linksMustPointAtTagsInTheBackup() { + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(tagUids = listOf("missing")) })) + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(tagUids = listOf("t1", "missing")) })) + // A tag at most once per note. + assertInvalid(payload.copy(notes = payload.notes.map { it.copy(tagUids = listOf("t1", "t1")) })) + assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(categoryTagUid = "missing") })) + } + + @Test + fun uidsMustBeUniqueAndPresent() { + assertInvalid(payload.copy(tags = listOf(tag, tag.copy(name = "Other")))) + assertInvalid(payload.copy(notes = payload.notes + payload.notes)) + assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(uid = " ") })) + assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(uid = "x".repeat(65)) })) + } + + @Test + fun subtaskUidsMustBeUniqueAcrossTheVaultAndPresent() { + val second = payload.tasks.single().copy(uid = "k2", subtasks = listOf(subtasks.first())) + assertInvalid(withSubtasks.copy(tasks = withSubtasks.tasks + second)) + assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(subtasks = subtasks + subtasks) })) + assertInvalid( + payload.copy( + tasks = payload.tasks.map { + it.copy(subtasks = listOf(subtasks[0].copy(uid = ""))) + }, + ), + ) + assertInvalid( + payload.copy( + tasks = payload.tasks.map { + it.copy(subtasks = listOf(subtasks[0].copy(uid = "x".repeat(65)))) + }, + ), + ) + } + + @Test + fun priorityMustBeKnown() { + assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(priority = 3) })) + } + + private fun assertInvalid(bad: BackupPayload) { + // encode() does not validate, so this is what a hand-crafted (authentic) file would hold. + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.validate(bad) } + val json = BackupPayloadCodec.encode(bad) + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json) } + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupSettingsTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupSettingsTest.kt new file mode 100644 index 00000000..ed3a5453 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupSettingsTest.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.core.backup + +import com.pasich.encly.core.security.AutoLockDelay +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BackupSettingsTest { + + @Test + fun aKeyIsEitherIncludedOrExcludedNeverBoth() { + assertEquals(emptySet(), BackupSettings.included.keys intersect BackupSettings.excluded.keys) + } + + @Test + fun noSecurityStateIsEverIncluded() { + // Words (of `snake_case` keys) that mark a credential, a lockout or install state. + val forbidden = setOf( + "pin", "biometric", "duress", "decoy", "wipe", "lockout", "failures", "penalty", "slot", "recovery", + "backup", "onboarding", "screenshot", "screenshots", "dek", "pending", "window", "seen", "version", + ) + BackupSettings.included.keys.forEach { key -> + val words = key.split('_', '.').toSet() + assertEquals(emptySet(), words intersect forbidden, "$key must not be backed up") + } + } + + @Test + fun everyIncludedKeyHasAKindThatAcceptsItsOwnDefault() { + val defaults = mapOf( + BackupSettings.IS_DYNAMIC_THEME to "false", + BackupSettings.THEME_TYPE to "SYSTEM", + BackupSettings.FONT_SIZE to "16", + BackupSettings.AUTO_LOCK_DELAY to AutoLockDelay.DEFAULT.name, + BackupSettings.APP_LANGUAGE to "SYSTEM", + ) + assertEquals(defaults, BackupSettings.accepted(defaults)) + } + + @Test + fun autoLockChoicesAreExactlyTheAppsDelays() { + AutoLockDelay.entries.forEach { delay -> + assertTrue(BackupSettings.included.getValue(BackupSettings.AUTO_LOCK_DELAY).accepts(delay.name)) + } + } + + @Test + fun unknownKeysAndBadValuesAreDroppedOneByOne() { + val raw = mapOf( + BackupSettings.THEME_TYPE to "DARK", + BackupSettings.THEME_PALETTE to "NEON", + BackupSettings.FONT_SIZE to "9", + BackupSettings.SHOW_TASKS to "yes", + BackupSettings.SORT to "CREATED_DESC", + "auth.biometric_enabled" to "true", + "pin.slot" to "AAAA", + "from_the_future" to "1", + ) + + assertEquals( + mapOf(BackupSettings.THEME_TYPE to "DARK", BackupSettings.SORT to "CREATED_DESC"), + BackupSettings.accepted(raw), + ) + } + + @Test + fun numbersMustBeInRangeAndPlainDigits() { + val size = BackupSettings.included.getValue(BackupSettings.FONT_SIZE) + + assertTrue(size.accepts("10") && size.accepts("32") && size.accepts("20")) + assertFalse(size.accepts("33") || size.accepts("9") || size.accepts("") || size.accepts("1e1")) + assertFalse(size.accepts("20.0") || size.accepts("-12")) + } + + @Test + fun aHugeMapIsReadOnlyUpToTheLimit() { + val raw = (0 until BackupSettings.MAX_ENTRIES + 10).associate { "key_$it" to "x" } + + mapOf(BackupSettings.SHOW_TASKS to "true") + + // The valid key sits past the limit, so it is not read; nothing throws. + assertEquals(emptyMap(), BackupSettings.accepted(raw)) + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/AutoLockTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/AutoLockTest.kt new file mode 100644 index 00000000..aa1f660c --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/AutoLockTest.kt @@ -0,0 +1,55 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.testutil.InMemoryKeyValueStore +import kotlin.test.Test +import kotlin.test.assertEquals + +class AutoLockTest { + @Test + fun theDefaultIsFifteenSeconds() { + val autoLock = AutoLock(InMemoryKeyValueStore()) + + assertEquals(AutoLockDelay.SECONDS_15, autoLock.delay.value) + assertEquals(15_000L, autoLock.delayMillis) + } + + @Test + fun aChosenDelayIsKept() { + val prefs = InMemoryKeyValueStore() + AutoLock(prefs).setDelay(AutoLockDelay.MINUTES_2) + + assertEquals(AutoLockDelay.MINUTES_2, AutoLock(prefs).delay.value) + } + + @Test + fun fiveAndTenMinutesPersistAndAStoredNameOfAnEarlierVersionStillReads() { + val prefs = InMemoryKeyValueStore() + AutoLock(prefs).setDelay(AutoLockDelay.MINUTES_5) + assertEquals(300_000L, AutoLock(prefs).delayMillis) + + AutoLock(prefs).setDelay(AutoLockDelay.MINUTES_10) + assertEquals(600_000L, AutoLock(prefs).delayMillis) + + // Delays are stored by name, so the ones added between others change no stored value. + prefs.edit { putString("auto_lock_delay", "MINUTES_2") } + assertEquals(AutoLockDelay.MINUTES_2, AutoLock(prefs).delay.value) + prefs.edit { putString("auto_lock_delay", "MINUTES_15") } + assertEquals(AutoLockDelay.MINUTES_15, AutoLock(prefs).delay.value) + } + + @Test + fun delaysAreOfferedFromShortestToLongest() { + assertEquals(AutoLockDelay.entries.sortedBy { it.millis }, AutoLockDelay.entries) + } + + @Test + fun longerDelaysPersistWithoutChangingTheDefault() { + val prefs = InMemoryKeyValueStore() + 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(InMemoryKeyValueStore()).delay.value) + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt new file mode 100644 index 00000000..0b98e4b6 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt @@ -0,0 +1,158 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.backup.BackupKeys +import com.pasich.encly.core.security.bip39.Bip39 +import com.pasich.encly.testutil.InMemoryVaultFileIo +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins what recovery phrases already given to users rely on: the official BIP-39 vectors, the + * accept/reject rules of the two validators, and the bytes Encly derives from the words. + */ +class Bip39GoldenTest { + private val seeds = SeedPhraseManager(VaultStore(InMemoryVaultFileIo())) + private val abandon = List(11) { "abandon" }.joinToString(" ") + " about" + + private fun vectors() = Bip39OfficialVectors.all + + private fun encode(entropy: ByteArray): String = Bip39().encode(entropy).concatToString() + + private fun strict(phrase: String) = RecoveryWords.isValidPhrase(phrase.toCharArray()) + + private fun lenient(phrase: String) = seeds.isValidMnemonic(phrase.toCharArray()) + + @Test + fun officialVectorsEncode() { + val all = vectors() + assertEquals(24, all.size) + all.forEach { (entropy, mnemonic) -> assertEquals(mnemonic, encode(entropy)) } + } + + @Test + fun officialVectorsDecode() { + vectors().forEach { (entropy, mnemonic) -> + assertContentEquals(entropy, Bip39().decode(mnemonic.toCharArray()), mnemonic) + } + } + + @Test + fun entropyOfEverySizeRoundTrips() { + listOf(16, 20, 24, 28, 32).forEach { size -> + val entropy = ByteArray(size) { (it * 7 + size).toByte() } + val phrase = Bip39().encode(entropy) + assertEquals(size * 3 / 4, phrase.concatToString().split(' ').size) + assertContentEquals(entropy, Bip39().decode(phrase)) + } + } + + @Test + fun oddEntropySizesAreRefused() { + listOf(0, 15, 17, 33).forEach { size -> + assertFailsWith { Bip39().encode(ByteArray(size)) } + } + } + + @Test + fun decodeWipesNothingItWasGiven() { + val phrase = abandon.toCharArray() + Bip39().decode(phrase) + assertEquals(abandon, phrase.concatToString()) + } + + @Test + fun officialVectorsValidate() { + vectors().forEach { (_, mnemonic) -> + assertTrue(strict(mnemonic), mnemonic) + assertTrue(lenient(mnemonic), mnemonic) + } + } + + @Test + fun generatedPhrasesAreTwelveValidWords() { + repeat(20) { + val phrase = seeds.generateMnemonic().concatToString() + assertEquals(12, phrase.split(' ').size) + assertTrue(strict(phrase)) + } + } + + @Test + fun badChecksumIsRejected() { + val swapped = abandon.replaceAfterLast(' ', "abandon") + assertFalse(strict(swapped)) + assertFalse(lenient(swapped)) + } + + @Test + fun unknownWordIsRejected() { + val unknown = abandon.replaceAfterLast(' ', "aboot") + assertFalse(strict(unknown)) + assertFalse(lenient(unknown)) + assertFalse(strict(abandon.replaceAfterLast(' ', "abo"))) + } + + @Test + fun wrongWordCountIsRejected() { + val words = abandon.split(' ') + listOf(0, 1, 11, 13, 14, 25).forEach { count -> + val phrase = List(count) { words[it % 11] }.joinToString(" ") + assertFalse(strict(phrase), "$count") + assertFalse(lenient(phrase), "$count") + } + } + + @Test + fun strictValidatorWantsLowerCaseAndSingleSpaces() { + assertTrue(strict(abandon)) + assertFalse(strict(abandon.uppercase())) + assertFalse(strict(abandon.replaceFirstChar { it.uppercase() })) + assertFalse(strict(abandon.replaceFirst(" ", " "))) + assertFalse(strict(" $abandon")) + assertFalse(strict("$abandon ")) + assertFalse(strict(abandon.replaceFirst(" ", "\n"))) + } + + @Test + fun lenientValidatorNormalizesCaseAndWhitespace() { + assertTrue(lenient(abandon.uppercase())) + assertTrue(lenient(" ${abandon.replace(" ", " \t\n ")} ")) + assertTrue(lenient(abandon.replaceFirstChar { it.uppercase() })) + } + + @Test + fun nonAsciiLookalikesAreRejected() { + assertFalse(lenient(abandon.replaceFirst('a', 'а'))) + assertFalse(lenient(abandon.replaceFirst("abandon", "abandon­"))) + } + + @Test + fun seedHashIsPinned() { + assertEquals(SEED_HASH, hex(Mnemonic.seedHash(abandon.toCharArray()))) + assertEquals(SEED_HASH, hex(Mnemonic.seedHash(" ${abandon.uppercase()}\n".toCharArray()))) + } + + @Test + fun backupRootIsPinned() { + assertEquals(BACKUP_ROOT, hex(BackupKeys.rootFromMnemonic(abandon.toCharArray()))) + } + + @Test + fun recoverySlotOpensWithTheSameWords() { + assertTrue(seeds.initializeVault(abandon.toCharArray())) + val dek = checkNotNull(seeds.copyBootstrapKey()) + + assertContentEquals(dek, seeds.unlockWithSeed(abandon.uppercase().toCharArray())) + } + + private fun hex(bytes: ByteArray) = bytes.joinToString("") { it.toUByte().toString(16).padStart(2, '0') } + + private companion object { + const val SEED_HASH = "c557eec878dfd852ba3f88087c4f350f09c55537ab5e549c3cd14320ec3cef38" + const val BACKUP_ROOT = "1a33cffc42964db0c0dd84e985bc0d0acfd3264809e289e8a85700cc38cfeb63" + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39OfficialVectors.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39OfficialVectors.kt new file mode 100644 index 00000000..44ec687b --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39OfficialVectors.kt @@ -0,0 +1,91 @@ +package com.pasich.encly.core.security + +/** + * The official BIP-39 English test vectors (entropy as hex, then the mnemonic). + * Source: https://github.com/trezor/python-mnemonic/blob/master/vectors.json + */ +internal object Bip39OfficialVectors { + val all: List> = listOf( + "00000000000000000000000000000000" to ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "about" + ), + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f" to + "legal winner thank year wave sausage worth useful legal winner thank yellow", + "80808080808080808080808080808080" to + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + "ffffffffffffffffffffffffffffffff" to + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "000000000000000000000000000000000000000000000000" to ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon abandon agent" + ), + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f" to ( + "legal winner thank year wave sausage worth useful legal winner thank year wave sausage " + + "worth useful legal will" + ), + "808080808080808080808080808080808080808080808080" to ( + "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount " + + "doctor acoustic avoid letter always" + ), + "ffffffffffffffffffffffffffffffffffffffffffffffff" to + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when", + "0000000000000000000000000000000000000000000000000000000000000000" to ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon art" + ), + "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f" to ( + "legal winner thank year wave sausage worth useful legal winner thank year wave sausage " + + "worth useful legal winner thank year wave sausage worth title" + ), + "8080808080808080808080808080808080808080808080808080808080808080" to ( + "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount " + + "doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless" + ), + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" to ( + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " + + "zoo vote" + ), + "9e885d952ad362caeb4efe34a8e91bd2" to + "ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic", + "6610b25967cdcca9d59875f5cb50b0ea75433311869e930b" to ( + "gravity machine north sort system female filter attitude volume fold club stay feature " + + "office ecology stable narrow fog" + ), + "68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c" to ( + "hamster diagram private dutch cause delay private meat slide toddler razor book happy " + + "fancy gospel tennis maple dilemma loan word shrug inflict delay length" + ), + "c0ba5a8e914111210f2bd131f3d5e08d" to + "scheme spot photo card baby mountain device kick cradle pact join borrow", + "6d9be1ee6ebd27a258115aad99b7317b9c8d28b6d76431c3" to ( + "horn tenant knee talent sponsor spell gate clip pulse soap slush warm silver nephew swap " + + "uncle crack brave" + ), + "9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863" to ( + "panda eyebrow bullet gorilla call smoke muffin taste mesh discover soft ostrich alcohol " + + "speed nation flash devote level hobby quick inner drive ghost inside" + ), + "23db8160a31d3e0dca3688ed941adbf3" to + "cat swing flag economy stadium alone churn speed unique patch report train", + "8197a4a47f0425faeaa69deebc05ca29c0a5b5cc76ceacc0" to ( + "light rule cinnamon wrap drastic word pride squirrel upgrade then income fatal apart " + + "sustain crack supply proud access" + ), + "066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad" to ( + "all hour make first leader extend hole alien behind guard gospel lava path output census " + + "museum junior mass reopen famous sing advance salt reform" + ), + "f30f8c1da665478f49b001d94c5fc452" to + "vessel ladder alter error federal sibling chat ability sun glass valve picture", + "c10ec20dc3cd9f652c7fac2f1230f7a3c828389a14392f05" to ( + "scissors invite lock maple supreme raw rapid void congress muscle digital elegant little " + + "brisk hair mango congress clump" + ), + "f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f" to ( + "void come effort suffer camp survey warrior heavy shoot primary clutch crush open " + + "amazing screen patrol group space point ten exist slush involve unfold" + ), + ).map { (entropy, mnemonic) -> entropy.chunked(2).map { it.toInt(16).toByte() }.toByteArray() to mnemonic } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt new file mode 100644 index 00000000..3d285b06 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt @@ -0,0 +1,478 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.backup.BackupCipher +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.BackupKeys +import com.pasich.encly.core.backup.BackupPayload +import com.pasich.encly.core.backup.BackupPayloadCodec +import com.pasich.encly.core.backup.BackupSecret +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.testutil.FakeLockoutClock +import com.pasich.encly.testutil.InMemoryVaultFileIo +import com.pasich.encly.testutil.hmacSha256 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Byte-for-byte pins of every cryptographic format the app persists: the PIN, wipe, duress and + * decoy-key slots, the recovery and backup-key slots, the backup file, the slot file's checksum + * and the key derivations under them. The expected bytes in [GoldenVectors] were produced with + * the platform JCA over fixed inputs, so these tests hold for any implementation underneath. + * Seals use random IVs, so they are checked by opening them again with an independent JCA + * reference ([Reference]) and by their layout. + */ +class CryptoGoldenTest { + private val dek = seq(32, 0, 1) + private val kek = seq(32, 100, 1) + private val salt = seq(16, 0, 3) + private val wipeKek = seq(32, 150, 1) + private val decoyDek = seq(32, 0x80, 1) + private val decoyKek = seq(32, 200, 1) + private val backupSalt = seq(32, 0x10, 1) + private val hardwareKey = seq(32, 0x70, 1) + private val payload = "{\"notes\":[\"secret note body\"]}".encodeToByteArray() + + // --- HKDF ----------------------------------------------------------------------------- + + @Test + fun hkdfMatchesRfc5869() { + // Test cases 1 and 2, first 32 bytes of the OKM (all this app ever asks for). + val tc1 = Hkdf.sha256( + ikm = ByteArray(22) { 0x0b }, + salt = seq(13, 0, 1), + info = seq(10, 0xf0, 1), + length = 32, + ) + val tc2 = Hkdf.sha256( + ikm = seq(80, 0x00, 1), + salt = seq(80, 0x60, 1), + info = seq(80, 0xb0, 1), + length = 32, + ) + + assertContentEquals(GoldenVectors.rfc5869Tc1Okm32, tc1) + assertContentEquals(GoldenVectors.rfc5869Tc2Okm32, tc2) + } + + @Test + fun hkdfShorterOutputIsThePrefixOfTheLongerOne() { + val full = Hkdf.sha256(dek, salt, "info".encodeToByteArray(), 32) + val short = Hkdf.sha256(dek, salt, "info".encodeToByteArray(), 16) + + assertContentEquals(full.copyOf(16), short) + } + + @Test + fun hkdfRefusesAnEmptySaltAndAnOutputLongerThanOneBlock() { + assertFailsWith { Hkdf.sha256(dek, ByteArray(0), ByteArray(1), 32) } + assertFailsWith { Hkdf.sha256(dek, salt, ByteArray(1), 33) } + assertFailsWith { Hkdf.sha256(dek, salt, ByteArray(1), 0) } + } + + @Test + fun theKeyHierarchyDerivesTheGoldenKeys() { + val stretchedAndHardware = GoldenVectors.pinSw + GoldenVectors.pinHw + + assertContentEquals(GoldenVectors.decoyKeyKek, DuressSlots.decoyKeyKek(dek)) + assertContentEquals(GoldenVectors.pinKek, pinKey(stretchedAndHardware, "encly/pin/kek/v3")) + assertContentEquals(GoldenVectors.pinWipeKek, pinKey(stretchedAndHardware, "encly/pin/wipe/v3")) + assertContentEquals(GoldenVectors.pinDecoyKek, pinKey(stretchedAndHardware, "encly/pin/decoy/v3")) + } + + private fun pinKey(ikm: ByteArray, info: String) = Hkdf.sha256(ikm, salt, info.encodeToByteArray(), 32) + + // --- Recovery phrase, SHA-256 --------------------------------------------------------- + + @Test + fun seedHashIsSha256OfTheNormalizedWords() { + // FIPS 180-2 "abc". + assertContentEquals(GoldenVectors.sha256Abc, Mnemonic.seedHash("abc".toCharArray())) + assertContentEquals(GoldenVectors.seedHash, Mnemonic.seedHash(PHRASE.toCharArray())) + } + + @Test + fun seedHashIgnoresCaseAndWhitespaceShape() { + val messy = " Abandon\tabandon ABANDON abandon abandon abandon abandon abandon abandon abandon" + + " abandon\n\nAbout \n" + + assertContentEquals(GoldenVectors.seedHash, Mnemonic.seedHash(messy.toCharArray())) + } + + @Test + fun seedHashOfNonAsciiWordsIsSha256OfTheirUtf8() { + val words = "café über" + + assertContentEquals( + Crypto.sha256(words.encodeToByteArray()), + Mnemonic.seedHash(words.toCharArray()), + ) + } + + @Test + fun backupKeysDeriveTheGoldenRootAndFileKey() { + val root = BackupKeys.rootFromMnemonic(PHRASE.toCharArray()) + + assertContentEquals(GoldenVectors.backupRoot, root) + assertContentEquals(GoldenVectors.backupFileKey, BackupKeys.fileKeyFromRoot(root, backupSalt)) + } + + // --- PIN, wipe, duress and decoy-key slots -------------------------------------------- + + @Test + fun theGoldenPinSlotOpensWithItsKek() { + val parsed = PinSlots.parseSlot(GoldenVectors.pinSlot)!! + + assertContentEquals(salt, parsed.salt) + assertContentEquals(dek, PinSlots.openSlotOrNull(parsed, kek)) + assertNull(PinSlots.openSlotOrNull(parsed, wipeKek)) + } + + @Test + fun aSealedPinSlotHasTheDocumentedLayoutAndOpensWithAnIndependentImplementation() { + val slot = PinSlots.sealSlot(dek, kek, salt) + + assertEquals(1 + 16 + 12 + 32 + 16, slot.size) + assertEquals(3, slot[0].toInt()) + assertContentEquals(salt, slot.copyOfRange(1, 17)) + val aad = "encly/pin/slot/v3".encodeToByteArray() + byteArrayOf(3) + salt + assertContentEquals(dek, Reference.gcmOpen(kek, slot.copyOfRange(17, 29), aad, slot.copyOfRange(29, slot.size))) + assertFalse(slot.contentEquals(PinSlots.sealSlot(dek, kek, salt))) + } + + @Test + fun theGoldenWipeSlotOpensWithItsKekAndTheSaltItIsBoundTo() { + assertTrue(PinSlots.opensWipeSlot(GoldenVectors.wipeSlot, wipeKek, salt)) + assertFalse(PinSlots.opensWipeSlot(GoldenVectors.wipeSlot, kek, salt)) + assertFalse(PinSlots.opensWipeSlot(GoldenVectors.wipeSlot, wipeKek, seq(16, 1, 3))) + assertTrue(PinSlots.opensWipeSlot(GoldenVectors.chainWipeSlot, GoldenVectors.pinWipeKek, salt)) + } + + @Test + fun aSealedWipeSlotHasTheDocumentedLayoutAndOpensWithAnIndependentImplementation() { + val slot = PinSlots.sealWipeSlot(wipeKek, salt) + + assertEquals(1 + 12 + 32 + 16, slot.size) + assertEquals(3, slot[0].toInt()) + val aad = "encly/pin/wipe-slot/v3".encodeToByteArray() + byteArrayOf(3) + salt + val marker = Reference.gcmOpen(wipeKek, slot.copyOfRange(1, 13), aad, slot.copyOfRange(13, slot.size)) + assertEquals(32, marker.size) + assertTrue(PinSlots.opensWipeSlot(slot, wipeKek, salt)) + assertFalse(slot.contentEquals(PinSlots.sealWipeSlot(wipeKek, salt))) + } + + @Test + fun aDecoyWipeSlotIsShapedLikeARealOneAndOpensWithNoKey() { + val decoy = PinSlots.decoyWipeSlot() + + assertEquals(PinSlots.sealWipeSlot(wipeKek, salt).size, decoy.size) + assertEquals(3, decoy[0].toInt()) + assertFalse(PinSlots.opensWipeSlot(decoy, wipeKek, salt)) + assertFalse(decoy.contentEquals(PinSlots.decoyWipeSlot())) + } + + @Test + fun theGoldenDuressSlotOpensWithItsKekAndSalt() { + val aad = DuressSlots.decoySlotAad(salt) + + assertContentEquals("encly/pin/decoy-slot/v3".encodeToByteArray() + byteArrayOf(3) + salt, aad) + assertContentEquals(decoyDek, DuressSlots.openDecoy(GoldenVectors.duressSlot, decoyKek, aad)) + assertNull(DuressSlots.openDecoy(GoldenVectors.duressSlot, kek, aad)) + assertNull(DuressSlots.openDecoy(GoldenVectors.duressSlot, decoyKek, DuressSlots.decoySlotAad(seq(16, 1, 3)))) + } + + @Test + fun theGoldenDecoyKeyOpensOnlyUnderItsOwnAad() { + val kek = GoldenVectors.decoyKeyKek + + assertContentEquals( + decoyDek, + DuressSlots.openDecoy(GoldenVectors.decoyKeyActive, kek, DuressSlots.DECOY_KEY_AAD), + ) + assertNull(DuressSlots.openDecoy(GoldenVectors.decoyKeyActive, kek, DuressSlots.DECOY_KEY_OFF_AAD)) + assertContentEquals( + decoyDek, + DuressSlots.openDecoy(GoldenVectors.decoyKeyOff, kek, DuressSlots.DECOY_KEY_OFF_AAD), + ) + assertNull(DuressSlots.openDecoy(GoldenVectors.decoyKeyOff, kek, DuressSlots.DECOY_KEY_AAD)) + assertContentEquals("encly/decoy/key/v1".encodeToByteArray() + byteArrayOf(3), DuressSlots.DECOY_KEY_AAD) + assertContentEquals( + "encly/decoy/key/off/v1".encodeToByteArray() + byteArrayOf(3), + DuressSlots.DECOY_KEY_OFF_AAD, + ) + } + + @Test + fun aSealedDecoyHasTheDocumentedLayoutAndOpensWithAnIndependentImplementation() { + val aad = DuressSlots.decoySlotAad(salt) + val slot = DuressSlots.sealDecoy(decoyDek, decoyKek, aad) + + assertEquals(1 + 12 + 32 + 16, slot.size) + assertEquals(3, slot[0].toInt()) + assertContentEquals( + decoyDek, + Reference.gcmOpen(decoyKek, slot.copyOfRange(1, 13), aad, slot.copyOfRange(13, 61)), + ) + assertEquals(slot.size, DuressSlots.randomSlot().size) + assertEquals(3, DuressSlots.randomSlot()[0].toInt()) + } + + @Test + fun aTamperedOrMisSizedSlotIsRefusedWithoutAnException() { + val slot = GoldenVectors.duressSlot.copyOf().also { it[it.size - 1] = (it.last().toInt() xor 1).toByte() } + val aad = DuressSlots.decoySlotAad(salt) + + assertNull(DuressSlots.openDecoy(slot, decoyKek, aad)) + assertNull(DuressSlots.openDecoy(GoldenVectors.duressSlot.copyOf(60), decoyKek, aad)) + assertFalse(PinSlots.opensWipeSlot(GoldenVectors.wipeSlot.copyOf(40), wipeKek, salt)) + } + + // --- Recovery and backup-key slots (SeedPhraseManager) --------------------------------- + + @Test + fun theGoldenRecoveryAndBackupSlotsOpenInAVault() { + val manager = vaultWith( + "recovery.slot" to GoldenVectors.recoverySlot, + "backup.slot" to GoldenVectors.backupSlot, + ) + + assertContentEquals(dek, manager.unlockWithSeed(PHRASE.toCharArray())) + assertContentEquals(dek, manager.unlockWithSeed(MESSY_PHRASE.toCharArray())) + assertNull(manager.unlockWithSeed(OTHER_PHRASE.toCharArray())) + assertContentEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(dek)) + assertNull(manager.unwrapBackupRoot(seq(32, 1, 1))) + } + + @Test + fun theSlotsAVaultWritesOpenWithAnIndependentImplementation() { + val store = VaultStore(InMemoryVaultFileIo()) + val manager = SeedPhraseManager(store) + assertTrue(manager.initializeVault(PHRASE.toCharArray())) + val bootstrap = manager.copyBootstrapKey()!! + + val recovery = store.getBytes("recovery.slot")!! + val backup = store.getBytes("backup.slot")!! + + assertEquals(12 + 32 + 16, recovery.size) + assertEquals(12 + 32 + 16, backup.size) + assertContentEquals( + bootstrap, + Reference.gcmOpen( + GoldenVectors.recoveryKek, + recovery.copyOf(12), + "encly/recovery/slot/v3".encodeToByteArray(), + recovery.copyOfRange(12, recovery.size), + ), + ) + val slotKek = Reference.hkdf(bootstrap, "encly/backup/slot/salt/v1", "encly/backup/slot/kek/v1") + assertContentEquals( + GoldenVectors.backupRoot, + Reference.gcmOpen( + slotKek, + backup.copyOf(12), + "encly/backup/slot/v1".encodeToByteArray(), + backup.copyOfRange(12, backup.size), + ), + ) + } + + @Test + fun aVaultOpensItsOwnBackupKeyAndAddsOneForTheRightPhraseOnly() { + val store = VaultStore(InMemoryVaultFileIo()) + val manager = SeedPhraseManager(store) + assertTrue(manager.initializeVault(PHRASE.toCharArray())) + val bootstrap = manager.copyBootstrapKey()!! + + assertContentEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(bootstrap)) + assertFalse(manager.createBackupKey(OTHER_PHRASE.toCharArray(), bootstrap)) + assertTrue(manager.createBackupKey(PHRASE.toCharArray(), bootstrap)) + assertContentEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(bootstrap)) + } + + // --- Backup file ---------------------------------------------------------------------- + + @Test + fun theGoldenBackupFileWithSettingsOpensAndReadsItsSettingsSection() { + val plaintext = BackupCipher.open( + GoldenVectors.backupFileWithSettings.copyOf(), + BackupSecret.RecoveryPhrase(PHRASE.toCharArray()), + ) + + val decoded = BackupPayloadCodec.decode(plaintext) + + assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) + assertEquals( + mapOf("theme_type" to "DARK", "font_size" to "18", "auto_lock_delay" to "MINUTE_1"), + decoded.settings, + ) + } + + @Test + fun theGoldenBackupFileOpensWithThePhraseAndWithTheRoot() { + assertContentEquals( + payload, + BackupCipher.open(GoldenVectors.backupFile.copyOf(), BackupSecret.RecoveryPhrase(PHRASE.toCharArray())), + ) + assertContentEquals( + payload, + BackupCipher.open(goldenFile(), BackupSecret.RecoveryPhrase(MESSY_PHRASE.toCharArray())), + ) + assertContentEquals( + payload, + BackupCipher.open(goldenFile(), BackupSecret.RecoveryRoot(GoldenVectors.backupRoot.copyOf())), + ) + } + + @Test + fun theGoldenBackupFileIsRefusedWithAWrongSecretOrAnyChangedByte() { + val phrase = { BackupSecret.RecoveryPhrase(PHRASE.toCharArray()) } + + assertWrongSecret { BackupCipher.open(goldenFile(), BackupSecret.RecoveryPhrase(OTHER_PHRASE.toCharArray())) } + // One byte in the salt, the nonce, the ciphertext and the tag of the file. + for (index in intArrayOf(14, 50, 70, GoldenVectors.backupFile.size - 1)) { + val file = GoldenVectors.backupFile.copyOf().also { it[index] = (it[index].toInt() xor 1).toByte() } + assertWrongSecret { BackupCipher.open(file, phrase()) } + } + } + + @Test + fun aSealedBackupHasTheDocumentedLayoutAndOpensWithAnIndependentImplementation() { + val file = BackupCipher.seal(payload, BackupSecret.RecoveryPhrase(PHRASE.toCharArray())) + + assertEquals(BackupFormat.HEADER_LENGTH + payload.size + 16, file.size) + assertContentEquals("ENCLYBAK".encodeToByteArray(), file.copyOf(8)) + assertEquals(1, file[9].toInt()) + assertEquals(1, file[10].toInt()) + assertEquals(32, file[11].toInt()) + assertEquals(12, file[44].toInt()) + val header = file.copyOf(61) + val fileKey = Reference.hkdfBytes(GoldenVectors.backupRoot, file.copyOfRange(12, 44), "encly/backup/file/v1") + val opened = Reference.gcmOpen(fileKey, file.copyOfRange(45, 57), header, file.copyOfRange(61, file.size)) + assertContentEquals(payload, opened) + val root = BackupSecret.RecoveryRoot(GoldenVectors.backupRoot.copyOf()) + assertContentEquals(payload, BackupCipher.open(file, root)) + } + + @Test + fun twoSealsOfTheSamePayloadShareNeitherSaltNorNonce() { + val first = BackupCipher.seal(payload, BackupSecret.RecoveryPhrase(PHRASE.toCharArray())) + val second = BackupCipher.seal(payload, BackupSecret.RecoveryPhrase(PHRASE.toCharArray())) + + assertFalse(first.copyOfRange(12, 44).contentEquals(second.copyOfRange(12, 44))) + assertFalse(first.copyOfRange(45, 57).contentEquals(second.copyOfRange(45, 57))) + } + + // --- Slot file (SHA-256 checksum) ----------------------------------------------------- + + @Test + fun theSlotFileIsTheGoldenBytes() { + val io = InMemoryVaultFileIo() + val store = VaultStore(io) + + assertTrue( + store.edit { + putBytes("a", byteArrayOf(1, 2, 3)) + putBytes("bc", ByteArray(0)) + }, + ) + + assertContentEquals(GoldenVectors.storeFile, io.bytes) + } + + @Test + fun theGoldenSlotFileIsReadBackAndAChangedByteMakesItCorrupt() { + val store = VaultStore(InMemoryVaultFileIo(GoldenVectors.storeFile)) + + assertFalse(store.isCorrupt()) + assertContentEquals(byteArrayOf(1, 2, 3), store.getBytes("a")) + assertContentEquals(ByteArray(0), store.getBytes("bc")) + + for (index in intArrayOf(10, GoldenVectors.storeFile.size - 1)) { + val damaged = GoldenVectors.storeFile.copyOf().also { it[index] = flipped(it[index]) } + assertTrue(VaultStore(InMemoryVaultFileIo(damaged)).isCorrupt()) + } + } + + // --- The whole PIN chain: PBKDF2 (600 000) -> HKDF -> AES-GCM ---------------------------- + + @Test + fun theGoldenPinAndDuressSlotsUnlockThroughTheFullKdfChain() { + val store = VaultStore(InMemoryVaultFileIo()) + store.edit { + putBytes("pin.slot", GoldenVectors.chainPinSlot) + putBytes("pin.decoy.slot", GoldenVectors.chainDuressSlot) + } + val manager = AuthenticationManager(store, FixedKeyFactor(hardwareKey), FakeLockoutClock()) + + val real = manager.unlockWithPin("123456".toCharArray()) + val duress = manager.unlockWithPin("654321".toCharArray()) + val wrong = manager.unlockWithPin("000000".toCharArray()) + + assertContentEquals(dek, (real as PinUnlock.Success).dek) + assertContentEquals(decoyDek, (duress as PinUnlock.Decoy).dek) + assertEquals(PinUnlock.WrongPin, wrong) + } + + private class FixedKeyFactor(private val key: ByteArray) : PinHardwareFactor { + override fun ensureKey(slot: PinKeySlot) = false + + override fun reset(slot: PinKeySlot) = Unit + + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray = hmacSha256(key, data) + + override fun delete(slot: PinKeySlot) = Unit + } + + // --- Helpers -------------------------------------------------------------------------- + + private fun vaultWith(vararg slots: Pair): SeedPhraseManager { + val store = VaultStore(InMemoryVaultFileIo()) + store.edit { + putInt("vault.version", 3) + putBoolean("recovery.enabled", true) + slots.forEach { (key, value) -> putBytes(key, value) } + } + return SeedPhraseManager(store) + } + + private fun assertWrongSecret(block: () -> Unit) { + val e = assertFailsWith { block() } + assertEquals(BackupError.WRONG_SECRET, e.error) + } + + /** + * Opens what the code under test sealed without going through its classes: the AES-GCM of [Crypto] (pinned by + * the published vectors in :core:crypto), and an HMAC written out from SHA-256 in the test helpers. + */ + private object Reference { + fun gcmOpen(key: ByteArray, iv: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray = + checkNotNull(Crypto.aesGcmOpen(key, iv, aad, ciphertext)) + + fun hkdf(ikm: ByteArray, salt: String, info: String): ByteArray = hkdfBytes(ikm, salt.encodeToByteArray(), info) + + fun hkdfBytes(ikm: ByteArray, salt: ByteArray, info: String): ByteArray { + val prk = hmacSha256(salt, ikm) + return hmacSha256(prk, info.encodeToByteArray() + byteArrayOf(1)) + } + } + + private fun goldenFile() = GoldenVectors.backupFile.copyOf() + + private fun flipped(byte: Byte) = (byte.toInt() xor 1).toByte() + + private fun seq(size: Int, offset: Int, step: Int) = ByteArray(size) { (it * step + offset).toByte() } + + private companion object { + const val PHRASE = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val MESSY_PHRASE = + " ABANDON abandon\tabandon abandon abandon abandon abandon abandon abandon abandon abandon\nAbout " + const val OTHER_PHRASE = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/GoldenVectors.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/GoldenVectors.kt new file mode 100644 index 00000000..6e2b18a5 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/GoldenVectors.kt @@ -0,0 +1,129 @@ +package com.pasich.encly.core.security + +/** + * Expected bytes for the golden tests, computed once with the platform JCA (`Cipher`, `Mac`, + * `SecretKeyFactory`, `MessageDigest`) over the fixed inputs described in [CryptoGoldenTest], + * and cross-checked against RFC 5869 (HKDF), RFC 7914 (PBKDF2) and the McGrew and Viega AES-GCM + * test cases. They pin the on-disk formats: a change here is a change to what users' vaults + * and backups hold. + */ +internal object GoldenVectors { + val rfc5869Tc1Okm32 = hex("3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf") + + val rfc5869Tc2Okm32 = hex("b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c") + + val rfc7914C1 = hex("55ac046e56e3089fec1691c22544b605f94185216dde0465e68b9d57c20dacbc") + + val rfc7914C80000 = hex("4ddcd8f60b98be21830cee5ef22701f9641a4418d04c0414aeff08876b34ab56") + + val gcmTc15 = hex( + "522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838" + + "c5f61e6393ba7a0abcc9f662898015adb094dac5d93471bdec1a502270e3cc6c", + ) + + val gcmTc16 = hex( + "522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838" + + "c5f61e6393ba7a0abcc9f66276fc6ece0f4e1768cddf8853bb2d551b", + ) + + val sha256Abc = hex("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + + val pinSlot = hex( + "03000306090c0f1215181b1e2124272a2d202122232425262728292a2b58a9c03c53e5da37e57c6d6bd7b79ab45c8f05" + + "ff14a64ca347f3ca7ad54a01253bca20ffa570bb18224cd4725d9b1e73", + ) + + val wipeSlot = hex( + "03202122232425262728292a2b27b18aa6b5910246745d4d3702abbff4370bbe364d253f08b6fdf78894aa997d676544" + + "c50176745e3ae5c0bc24d2f2a8", + ) + + val duressSlot = hex( + "03202122232425262728292a2b84ee003088b65199cf851f23dbd85498733e690ab0a0ab22184d78fbcd9ab2ca8d5b9e" + + "a99ef53ca2242d4856334771a0", + ) + + val decoyKeyKek = hex("69c119168825a610a9652135ed080e5b8ed8f39462046af535277a11ebcb746d") + + val decoyKeyActive = hex( + "03202122232425262728292a2b76adf108921fa1ecdf5e950c998d88f08aa772815a5ec6a659b485f0c84c1485f93486" + + "768d6b641d1dc5fa212846404f", + ) + + val decoyKeyOff = hex( + "03202122232425262728292a2b76adf108921fa1ecdf5e950c998d88f08aa772815a5ec6a659b485f0c84c148579c57f" + + "ed2d3ab2273355da6fa8f81369", + ) + + val seedHash = hex("c557eec878dfd852ba3f88087c4f350f09c55537ab5e549c3cd14320ec3cef38") + + val backupRoot = hex("1a33cffc42964db0c0dd84e985bc0d0acfd3264809e289e8a85700cc38cfeb63") + + val recoveryKek = hex("2833d39a5f37b2e447c0b94b87e3119db7ecf45e8998896871db06f70f5c3bb2") + + val recoverySlot = hex( + "202122232425262728292a2bcb388f3b03d75bca17e4901a28acf127c14f78f1c8f68f1423dfd8b9090e9836123328c4" + + "24d8ac31a180e35e1ff24e3a", + ) + + val backupSlotKek = hex("04ffdfc969efc8042f6fc218f3095d990c0516fdbf56f0815cf353299950f7ab") + + val backupSlot = hex( + "202122232425262728292a2b2cf5957fd9db925e7adaf2368792a5252840aa956091122bb6d12694ffa4fee167d4a1f7" + + "4ba4fb1c6c1b8a48f70c89d8", + ) + + val backupFileKey = hex("76a68f389c49864c6bb7ac38a71665ec305f011547c63c5f0b73e3d718a58864") + + val backupFile = hex( + "454e434c5942414b00010120101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f0c606162" + + "636465666768696a6b0000002e675ac6b816f6b9eaecd39c3160cd8444d40d892d26ad4ff9254b356403bbfb6d1dda04" + + "a7ef6bcf8bba1903b2e9f3", + ) + + /** + * A backup file whose payload is schema 5 with a `settings` section (same phrase, salt and nonce as + * [backupFile]). Frozen: a restore must read it as long as the format exists. + */ + val backupFileWithSettings = hex( + "454e434c5942414b00010120101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f0c606162" + + "636465666768696a6b000000a6675adbb40af6a7a9f4b28b6e27cb8e51cf5f932736891bb9701e7b766ef6ce32983523" + + "9c07a5f02d51ad3738211c48a28be6f4d625f832bf44a2e917fdf9713d73c3056d81a051de1c572afc950ca4847eda51" + + "f293d757419f10beef717791cd86c81df475b3e85deb46fca156096a23ed9fbe19ca5b9aaa0d731f1dd24727bfe3e5c7" + + "2500f5e3c57a0ab47a50a8053d9deac9286e1d5a66856a81c0d32a69bce19eff4c253c", + ) + + val pinSw = hex("4f5705502cd1802172fbf3eecd6a1b14fb647976595714bce7c922b66c30a4e7") + + val pinHw = hex("8cd97cc6eb415406f66958ab3716a0716f1095f80f2ae50b1c3cf75c5c0eb82c") + + val pinKek = hex("83935b5bf598bc380ce3109b8a7eb79a20461489842956d7047884c25aecbc8e") + + val pinWipeKek = hex("09657ab46e417017d30f5027cd867f73c98775ac04f53bd21b40dc93373693aa") + + val pinDecoyKek = hex("7ca0eaeecd58955a53e89e88ceab672071b444b6cfdef8a599560c320178462d") + + val chainWipeSlot = hex( + "03202122232425262728292a2b0c738a6beff788e0460581a9d1a9abcb80c3e48bc78483d626077ef97735281fc75775" + + "2ee29415e563911fc43810d57b", + ) + + val chainPinSlot = hex( + "03000306090c0f1215181b1e2124272a2d202122232425262728292a2b2897f118c5ddd3b16fa2d1905004f668dc1227" + + "c936b5f1670082bd62540203934aa663fa9e357ebf336f908ccf9395a8", + ) + + val chainDuressSlot = hex( + "03202122232425262728292a2be20e7f8a806e9355535f9c4dba72b1c258b2973442f6b5359c141b1f81868d2d17d38e" + + "fddb368becdab848194f27ec96", + ) + + val storeFile = hex( + "454e434c595653330000000200016100000003010203000262630000000058f24fb12b5c0a411161cd12ebdb9fdbf492" + + "19ef0aee2e78afa6e5562e370519", + ) + + fun hex(text: String): ByteArray = ByteArray(text.length / 2) { + text.substring(2 * it, 2 * it + 2).toInt(16).toByte() + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/PinSlotsTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/PinSlotsTest.kt similarity index 81% rename from app/src/test/java/com/pasich/encly/core/security/PinSlotsTest.kt rename to core/security/src/commonTest/kotlin/com/pasich/encly/core/security/PinSlotsTest.kt index 93a13257..0b9db55a 100644 --- a/app/src/test/java/com/pasich/encly/core/security/PinSlotsTest.kt +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/PinSlotsTest.kt @@ -1,12 +1,12 @@ package com.pasich.encly.core.security -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue /** The slot byte formats: what is accepted, what is refused, and that nothing is opened by the wrong key. */ class PinSlotsTest { @@ -23,8 +23,8 @@ class PinSlotsTest { val parsed = PinSlots.parseSlot(slot)!! - assertArrayEquals(salt, parsed.salt) - assertArrayEquals(dek, PinSlots.openSlotOrNull(parsed, kek)) + assertContentEquals(salt, parsed.salt) + assertContentEquals(dek, PinSlots.openSlotOrNull(parsed, kek)) assertNull(PinSlots.openSlotOrNull(parsed, otherKek)) } @@ -103,7 +103,7 @@ class PinSlotsTest { val aad = DuressSlots.decoySlotAad(salt) val sealed = DuressSlots.sealDecoy(dek, kek, aad) - assertArrayEquals(dek, DuressSlots.openDecoy(sealed, kek, aad)) + assertContentEquals(dek, DuressSlots.openDecoy(sealed, kek, aad)) assertNull(DuressSlots.openDecoy(sealed, otherKek, aad)) assertNull(DuressSlots.openDecoy(sealed, kek, DuressSlots.decoySlotAad(ByteArray(16)))) } @@ -123,7 +123,7 @@ class PinSlotsTest { assertEquals(32, first.size) assertFalse(first.contentEquals(dek)) - assertArrayEquals(first, DuressSlots.decoyKeyKek(dek.copyOf())) + assertContentEquals(first, DuressSlots.decoyKeyKek(dek.copyOf())) assertFalse(first.contentEquals(DuressSlots.decoyKeyKek(otherKek))) } @@ -139,7 +139,7 @@ class PinSlotsTest { assertEquals( "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf", - okm.joinToString("") { "%02x".format(it) }, + okm.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }, ) } @@ -147,17 +147,17 @@ class PinSlotsTest { fun hkdfRefusesALengthOutsideOneHashBlockAndAnEmptySalt() { val ikm = ByteArray(32) - assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(ikm, salt, ByteArray(0), 0) } - assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(ikm, salt, ByteArray(0), 33) } - assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(ikm, ByteArray(0), ByteArray(0), 16) } + assertFailsWith { Hkdf.sha256(ikm, salt, ByteArray(0), 0) } + assertFailsWith { Hkdf.sha256(ikm, salt, ByteArray(0), 33) } + assertFailsWith { Hkdf.sha256(ikm, ByteArray(0), ByteArray(0), 16) } } @Test fun differentInfoGivesIndependentKeys() { - val a = Hkdf.sha256(dek, salt, "a".toByteArray(), 32) - val b = Hkdf.sha256(dek, salt, "b".toByteArray(), 32) + val a = Hkdf.sha256(dek, salt, "a".encodeToByteArray(), 32) + val b = Hkdf.sha256(dek, salt, "b".encodeToByteArray(), 32) assertFalse(a.contentEquals(b)) - assertEquals(16, Hkdf.sha256(dek, salt, "a".toByteArray(), 16).size) + assertEquals(16, Hkdf.sha256(dek, salt, "a".encodeToByteArray(), 16).size) } } diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/RecoveryWordsTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/RecoveryWordsTest.kt new file mode 100644 index 00000000..7ec3cae1 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/RecoveryWordsTest.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.core.security + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RecoveryWordsTest { + @Test + fun knowsTheWordlist() { + assertTrue(RecoveryWords.isWord("abandon")) + assertTrue(RecoveryWords.isWord("zoo")) + assertFalse(RecoveryWords.isWord("aban")) + assertFalse(RecoveryWords.isWord("Abandon")) + } + + @Test + fun prefixes() { + assertTrue(RecoveryWords.isPrefix("")) + assertTrue(RecoveryWords.isPrefix("ab")) + assertTrue(RecoveryWords.isPrefix("zoo")) + assertFalse(RecoveryWords.isPrefix("zz")) + assertFalse(RecoveryWords.isPrefix("abandonx")) + } + + @Test + fun checksum() { + assertTrue(RecoveryWords.isValidPhrase(Mnemonic.generate())) + assertFalse(RecoveryWords.isValidPhrase(List(12) { "abandon" }.joinToString(" ").toCharArray())) + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/ScreenshotAllowanceTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/ScreenshotAllowanceTest.kt new file mode 100644 index 00000000..c619f962 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/ScreenshotAllowanceTest.kt @@ -0,0 +1,101 @@ +package com.pasich.encly.core.security + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ScreenshotAllowanceTest { + private val allowance = ScreenshotAllowance() + + @Test + fun screenshotsAreNotAllowedByDefault() { + assertFalse(allowance.allowed.value) + assertFalse(allowance.capturable) + } + + @Test + fun allowingMakesTheWindowCapturableUntilRevoked() { + allowance.allow() + assertTrue(allowance.allowed.value) + assertTrue(allowance.capturable) + + allowance.revoke() + assertFalse(allowance.allowed.value) + assertFalse(allowance.capturable) + } + + @Test + fun aScreenWithASecretStaysProtectedWhileScreenshotsAreAllowed() { + allowance.allow() + + val release = allowance.protect() + + assertTrue(allowance.allowed.value) + assertFalse(allowance.capturable) + release() + assertTrue(allowance.capturable) + } + + @Test + fun theWindowIsCapturableAgainOnlyWhenEverySecretScreenIsGone() { + allowance.allow() + val pin = allowance.protect() + val phrase = allowance.protect() + + pin() + assertFalse(allowance.capturable) + phrase() + assertTrue(allowance.capturable) + } + + @Test + fun releasingTwiceDoesNotUndoAnotherScreensProtection() { + allowance.allow() + val first = allowance.protect() + allowance.protect() + + first() + first() + + assertFalse(allowance.capturable) + } + + @Test + fun theWindowIsToldAtOnceAboutEveryChangeAndOnlyChanges() { + val seen = mutableListOf() + val stop = allowance.observe { seen += it } + + allowance.allow() + allowance.allow() + val release = allowance.protect() + release() + allowance.revoke() + stop() + allowance.allow() + + assertEquals(listOf(false, true, false, true, false), seen) + } + + @Test + fun aLateObserverIsToldTheCurrentState() { + allowance.allow() + val seen = mutableListOf() + + allowance.observe { seen += it } + + assertEquals(listOf(true), seen) + } + + @Test + fun revokingWhileASecretScreenShowsIsNotAChangeForTheWindow() { + allowance.allow() + allowance.protect() + val seen = mutableListOf() + allowance.observe { seen += it } + + allowance.revoke() + + assertEquals(listOf(false), seen) + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/FakeLockoutClock.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/FakeLockoutClock.kt new file mode 100644 index 00000000..364465c7 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/FakeLockoutClock.kt @@ -0,0 +1,9 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.security.LockoutClock + +/** A [LockoutClock] the test moves by hand. */ +internal class FakeLockoutClock(var elapsed: Long = 1_000_000L, var boot: Int = 1) : LockoutClock { + override fun elapsedRealtime(): Long = elapsed + override fun bootCount(): Int = boot +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/HmacSha256Test.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/HmacSha256Test.kt new file mode 100644 index 00000000..13ca183d --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/HmacSha256Test.kt @@ -0,0 +1,32 @@ +package com.pasich.encly.testutil + +import kotlin.test.Test +import kotlin.test.assertContentEquals + +/** The test helper [hmacSha256] against RFC 4231, so the golden tests that lean on it stand on a published vector. */ +class HmacSha256Test { + @Test + fun matchesRfc4231TestCases1And2() { + assertContentEquals( + hex("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"), + hmacSha256(ByteArray(20) { 0x0b }, "Hi There".encodeToByteArray()), + ) + assertContentEquals( + hex("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"), + hmacSha256("Jefe".encodeToByteArray(), "what do ya want for nothing?".encodeToByteArray()), + ) + } + + @Test + fun matchesRfc4231TestCase6WithAKeyLongerThanABlock() { + assertContentEquals( + hex("60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"), + hmacSha256( + ByteArray(131) { 0xaa.toByte() }, + "Test Using Larger Than Block-Size Key - Hash Key First".encodeToByteArray(), + ), + ) + } + + private fun hex(text: String) = ByteArray(text.length / 2) { text.substring(2 * it, 2 * it + 2).toInt(16).toByte() } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryKeyValueStore.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryKeyValueStore.kt new file mode 100644 index 00000000..dd0ee136 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryKeyValueStore.kt @@ -0,0 +1,55 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.platform.KeyValueStore + +/** + * A [KeyValueStore] for unit tests that keeps its values in a map. [edit] and [commit] apply a batch + * at once, the way the Android adapter does, and [commit] always reports success. + */ +internal class InMemoryKeyValueStore : KeyValueStore { + private val values = mutableMapOf() + + override fun getString(key: String, default: String?): String? = values[key] as? String ?: default + + override fun getBoolean(key: String, default: Boolean): Boolean = values[key] as? Boolean ?: default + + override fun getLong(key: String, default: Long): Long = values[key] as? Long ?: default + + override fun contains(key: String): Boolean = key in values + + override fun edit(block: KeyValueStore.Editor.() -> Unit) { + MapEditor().apply(block).applyTo(values) + } + + override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean { + edit(block) + return true + } + + /** The changes of one batch, in the order they were made. */ + private class MapEditor : KeyValueStore.Editor { + private val steps = mutableListOf<(MutableMap) -> Unit>() + + override fun putString(key: String, value: String) { + steps += { it[key] = value } + } + + override fun putBoolean(key: String, value: Boolean) { + steps += { it[key] = value } + } + + override fun putLong(key: String, value: Long) { + steps += { it[key] = value } + } + + override fun remove(key: String) { + steps += { it.remove(key) } + } + + override fun clear() { + steps += { it.clear() } + } + + fun applyTo(target: MutableMap) = steps.forEach { it(target) } + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultFileIo.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultFileIo.kt new file mode 100644 index 00000000..77d4653a --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultFileIo.kt @@ -0,0 +1,26 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.security.VaultFileIo +import kotlinx.io.IOException + +/** + * A [VaultFileIo] whose file is a byte array: [bytes] is the vault file (null while it does not exist), so a + * test can start from golden bytes and read back exactly what a write left. + */ +internal class InMemoryVaultFileIo(var bytes: ByteArray? = null) : VaultFileIo { + override fun exists(): Boolean = bytes != null + + override fun read(): ByteArray = bytes?.copyOf() ?: throw IOException("no vault file") + + override fun deleteTemp() = Unit + + override fun replace(bytes: ByteArray, beforeRename: (() -> Unit)?) { + beforeRename?.invoke() + this.bytes = bytes.copyOf() + } + + override fun delete(): Boolean { + bytes = null + return true + } +} diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/TestHmac.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/TestHmac.kt new file mode 100644 index 00000000..f7aad680 --- /dev/null +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/testutil/TestHmac.kt @@ -0,0 +1,17 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.crypto.Crypto + +private const val BLOCK_BYTES = 64 +private const val INNER_PAD: Byte = 0x36 +private const val OUTER_PAD: Byte = 0x5c + +/** + * HMAC-SHA-256 (RFC 2104) written out from [Crypto.sha256], so a test can compute a MAC the code under test + * must reproduce without reaching for a platform provider. Checked against RFC 4231 in HmacSha256Test. + */ +internal fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { + val block = (if (key.size > BLOCK_BYTES) Crypto.sha256(key) else key).copyOf(BLOCK_BYTES) + val inner = Crypto.sha256(ByteArray(BLOCK_BYTES) { (block[it].toInt() xor INNER_PAD.toInt()).toByte() } + data) + return Crypto.sha256(ByteArray(BLOCK_BYTES) { (block[it].toInt() xor OUTER_PAD.toInt()).toByte() } + inner) +} diff --git a/core/security/src/desktopTest/kotlin/com/pasich/encly/core/security/SecurityBoundaryTest.kt b/core/security/src/desktopTest/kotlin/com/pasich/encly/core/security/SecurityBoundaryTest.kt new file mode 100644 index 00000000..9e2a3340 --- /dev/null +++ b/core/security/src/desktopTest/kotlin/com/pasich/encly/core/security/SecurityBoundaryTest.kt @@ -0,0 +1,138 @@ +package com.pasich.encly.core.security + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The security core is common code: its shared sources may import nothing of Android or the JVM, and nothing of + * the app (the database, the backup manager, resources, the DI graph). What a platform has to do is behind an + * interface the core declares (KeyValueStore, VaultFileIo, VaultDatabase, PinHardwareFactor, BiometricManager, + * LockoutClock, DeviceLockWatcher, MainThreadTimer, HandoffStagingSweeper) and implemented in `:app`, except + * the two files in [JVM_FILES], which both JVM targets share and which are the only source files that may + * name `java.io`. The module may depend on `:core:common` and `:core:crypto` only. + */ +class SecurityBoundaryTest { + // Tests run with the module directory as the working directory. + private val moduleRoot = listOf(File("."), File("core/security")).first { File(it, "build.gradle.kts").isFile } + private val sourceRoot = File(moduleRoot, "src/commonMain/kotlin") + + private val sources = sourceRoot.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + + private fun File.relative() = relativeTo(sourceRoot).invariantSeparatorsPath + + /** Source lines without comments (line, block and KDoc) and string literals' contents. */ + private fun File.codeLines(): List> { + // A block comment becomes as many line breaks as it had, so line numbers stay right. + val text = BLOCK_COMMENT.replace(readText()) { "\n".repeat(it.value.count { c -> c == '\n' }) } + return text.lines().mapIndexed { index, raw -> + (index + 1) to raw.replace(STRING, "\"\"").substringBefore("//") + } + } + + @Test + fun theSourcesWereFound() { + assertTrue(sources.size >= 25, "no sources under $sourceRoot") + } + + @Test + fun noImportReachesOutOfTheModuleOrOntoThePlatform() { + val offenders = sources.flatMap { file -> + file.codeLines().mapNotNull { (number, line) -> + val import = IMPORT.matchEntire(line.trim())?.groupValues?.get(1) ?: return@mapNotNull null + "${file.relative()}:$number import $import".takeUnless { ALLOWED_PREFIXES.any(import::startsWith) } + } + } + assertEquals(emptyList(), offenders, "move the platform call behind an interface and an adapter") + } + + @Test + fun noCodeNamesThePlatformWithoutAnImport() { + val offenders = sources.flatMap { file -> + file.codeLines().filter { (_, line) -> !line.trim().startsWith("import ") } + .filter { (_, line) -> PLATFORM_USE.containsMatchIn(line) } + .map { (number, line) -> "${file.relative()}:$number ${line.trim()}" } + } + assertEquals(emptyList(), offenders, "the shared sources may not use the JVM or Android directly") + } + + @Test + fun onlyTwoJvmFilesTouchTheJvm() { + val platformSets = File(moduleRoot, "src").listFiles().orEmpty().map { it.name }.filter { it.endsWith("Main") } + assertEquals(listOf("commonMain", "iosMain", "jvmAndAndroidMain"), platformSets.sorted()) + val jvmFiles = File(moduleRoot, "src/jvmAndAndroidMain/kotlin").walkTopDown() + .filter { it.isFile && it.extension == "kt" }.map { it.name }.toSet() + assertEquals(JVM_FILES.keys, jvmFiles) + JVM_FILES.forEach { (name, reason) -> assertTrue(reason.length > 10, "$name: write why it is JVM code") } + } + + @Test + fun theModuleDependsOnlyOnCommonAndCrypto() { + val build = File(moduleRoot, "build.gradle.kts").readText() + val projects = Regex("""project\(\s*"(:[\w:]+)"""").findAll(build).map { it.groupValues[1] }.toList() + assertEquals(listOf(":core:common", ":core:crypto"), projects) + } + + @Test + fun theCheckSeesWhatItShouldReject() { + assertTrue(ALLOWED_PREFIXES.none { "android.content.Context".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "java.io.File".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "javax.crypto.Cipher".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "androidx.fragment.app.FragmentActivity".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "androidx.compose.ui.res.stringResource".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "androidx.room.Dao".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "com.pasich.encly.R".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "com.pasich.encly.data.database.VaultFile".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "com.pasich.encly.core.di.ApplicationContext".startsWith(it) }) + assertTrue(PLATFORM_USE.containsMatchIn("val x = java.util.UUID.randomUUID()")) + assertTrue(PLATFORM_USE.containsMatchIn("val t = System.currentTimeMillis()")) + assertTrue(PLATFORM_USE.containsMatchIn("} catch (_: SecurityException) {")) + assertFalse(PLATFORM_USE.containsMatchIn("val t = Clock.System.now()")) + assertTrue(PLATFORM_USE.containsMatchIn("e.javaClass.simpleName")) + assertFalse(PLATFORM_USE.containsMatchIn("val language = Locale.javascript")) + } + + private companion object { + val IMPORT = Regex("""import ([\w.]+)(?: as \w+)?""") + val STRING = Regex(""""(?:[^"\\]|\\.)*"""") + val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""") + + // The module's own packages, the common and crypto layers it stands on, Metro's annotations, the + // androidx annotations and lifecycle observer, and the Kotlin libraries. + val ALLOWED_PREFIXES = listOf( + "com.pasich.encly.core.security.", + "com.pasich.encly.core.backup.", + "com.pasich.encly.core.crypto.", + "com.pasich.encly.core.common.", + "com.pasich.encly.core.platform.", + "com.pasich.encly.core.AppLogger", + "dev.zacsweers.metro.", + "androidx.annotation.", + "androidx.lifecycle.", + "kotlin.", + "kotlinx.", + ) + + // Direct uses of the platform that need no import: java.*, android.*, System, javaClass. + val PLATFORM_USE = Regex( + listOf( + """\b(?:java|javax|android)\.\w""", + """(? Json.encodeToWipedBytes(serializer: SerializationStrategy, value: T): ByteArray { + val buffer = Buffer() + try { + encodeToSink(serializer, value, buffer) + return buffer.drainAndWipe() + } finally { + buffer.wipe() + } +} + +internal actual fun Json.decodeFromBytes(deserializer: DeserializationStrategy, bytes: ByteArray): T { + val buffer = Buffer() + try { + buffer.write(bytes) + // A peek reads the segments in place and consumes nothing, so they are still here to be wiped. + return decodeFromSource(deserializer, buffer.peek()) + } finally { + buffer.wipe() + } +} + +/** Everything in the buffer as a byte array, each segment zeroized as it is read. */ +private fun Buffer.drainAndWipe(): ByteArray { + val out = ByteArray(size.toInt()) + var written = 0 + while (!exhausted()) { + UnsafeBufferOperations.readFromHead(this) { bytes, start, end -> + bytes.copyInto(out, written, start, end) + written += end - start + bytes.fill(0, start, end) + end - start + } + } + return out +} + +/** Zeroizes what is left in the buffer and empties it. */ +private fun Buffer.wipe() { + while (!exhausted()) { + UnsafeBufferOperations.readFromHead(this) { bytes, start, end -> + bytes.fill(0, start, end) + end - start + } + } +} diff --git a/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosVaultFileIo.kt b/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosVaultFileIo.kt new file mode 100644 index 00000000..5bf416ef --- /dev/null +++ b/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosVaultFileIo.kt @@ -0,0 +1,119 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.core.security + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned +import kotlinx.io.IOException +import platform.Foundation.NSFileManager +import platform.posix.F_FULLFSYNC +import platform.posix.O_CREAT +import platform.posix.O_RDONLY +import platform.posix.O_TRUNC +import platform.posix.O_WRONLY +import platform.posix.close +import platform.posix.errno +import platform.posix.fcntl +import platform.posix.fstat +import platform.posix.fsync +import platform.posix.open +import platform.posix.read +import platform.posix.rename +import platform.posix.stat +import platform.posix.strerror +import platform.posix.unlink +import platform.posix.write + +/** + * The iOS side of [VaultFileIo]: the vault file on POSIX calls, the only place that can fsync it and replace it + * atomically (kotlinx-io has neither). [replace] writes `.tmp`, flushes it to the disk with `F_FULLFSYNC` + * (a plain `fsync` on Apple platforms only reaches the drive's cache; it is the fallback where the file system + * refuses the stronger call), then `rename(2)`s it over the vault file, which is atomic on APFS. Every failure + * is a [IOException]. + */ +public class IosVaultFileIo(private val path: String) : VaultFileIo { + override fun exists(): Boolean = platform.posix.access(path, platform.posix.F_OK) == 0 + + override fun read(): ByteArray { + val fd = open(path, O_RDONLY) + if (fd < 0) fail("open $path") + try { + val size = memScoped { + val info = alloc() + if (fstat(fd, info.ptr) != 0) fail("stat $path") + info.st_size.toInt() + } + val bytes = ByteArray(size) + var done = 0 + while (done < size) { + val count = bytes.usePinned { read(fd, it.addressOf(done), (size - done).convert()) }.toInt() + if (count < 0) fail("read $path") + if (count == 0) throw IOException("$path is shorter than its size") + done += count + } + return bytes + } finally { + close(fd) + } + } + + override fun deleteTemp() { + unlink(tempPath()) + } + + override fun replace(bytes: ByteArray, beforeRename: (() -> Unit)?) { + createParentDirectory() + val temp = tempPath() + try { + writeAndSync(temp, bytes) + beforeRename?.invoke() + if (rename(temp, path) != 0) fail("rename $temp") + } catch (e: IOException) { + unlink(temp) + throw e + } + } + + override fun delete(): Boolean { + unlink(path) + return !exists() + } + + private fun writeAndSync(file: String, bytes: ByteArray) { + val fd = open(file, O_WRONLY or O_CREAT or O_TRUNC, FILE_MODE) + if (fd < 0) fail("open $file") + try { + var done = 0 + while (done < bytes.size) { + val count = bytes.usePinned { write(fd, it.addressOf(done), (bytes.size - done).convert()) }.toInt() + if (count < 0) fail("write $file") + done += count + } + if (fcntl(fd, F_FULLFSYNC) != 0 && fsync(fd) != 0) fail("sync $file") + } finally { + close(fd) + } + } + + private fun createParentDirectory() { + val parent = path.substringBeforeLast('/', "") + if (parent.isEmpty()) return + val created = NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null) + if (!created) throw IOException("cannot create $parent") + } + + private fun tempPath() = path + TEMP_SUFFIX + + private fun fail(what: String): Nothing = throw IOException("$what: ${strerror(errno)?.toKString()}") + + private companion object { + const val TEMP_SUFFIX = ".tmp" + const val FILE_MODE = 384 // 0600 + } +} diff --git a/core/security/src/iosTest/kotlin/com/pasich/encly/core/security/IosVaultFileIoTest.kt b/core/security/src/iosTest/kotlin/com/pasich/encly/core/security/IosVaultFileIoTest.kt new file mode 100644 index 00000000..2ff092bb --- /dev/null +++ b/core/security/src/iosTest/kotlin/com/pasich/encly/core/security/IosVaultFileIoTest.kt @@ -0,0 +1,100 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.core.security + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.io.IOException +import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSUUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The vault file on the iOS file system: the atomic replace, the temp file and the failures. */ +class IosVaultFileIoTest { + private val directory = NSTemporaryDirectory() + "vault-" + NSUUID().UUIDString + private val path = "$directory/nested/vault_state.bin" + private val io = IosVaultFileIo(path) + + @AfterTest + fun cleanUp() { + NSFileManager.defaultManager.removeItemAtPath(directory, null) + } + + @Test + fun aMissingFileDoesNotExistAndCannotBeRead() { + assertFalse(io.exists()) + assertFailsWith { io.read() } + assertTrue(io.delete()) + } + + @Test + fun replaceCreatesTheFileAndItsDirectoriesAndReadReturnsTheBytes() { + val bytes = ByteArray(20_000) { it.toByte() } + + io.replace(bytes, beforeRename = null) + + assertTrue(io.exists()) + assertContentEquals(bytes, io.read()) + assertFalse(NSFileManager.defaultManager.fileExistsAtPath("$path.tmp")) + } + + @Test + fun replaceOverwritesAndAnEmptyFileReadsEmpty() { + io.replace(byteArrayOf(1, 2, 3), beforeRename = null) + io.replace(byteArrayOf(4), beforeRename = null) + assertContentEquals(byteArrayOf(4), io.read()) + + io.replace(ByteArray(0), beforeRename = null) + assertContentEquals(ByteArray(0), io.read()) + } + + @Test + fun aFailureBeforeTheRenameKeepsTheOldFileAndRemovesTheTempFile() { + io.replace(byteArrayOf(1, 2, 3), beforeRename = null) + + assertFailsWith { io.replace(byteArrayOf(9), beforeRename = { throw IOException("crash") }) } + + assertContentEquals(byteArrayOf(1, 2, 3), io.read()) + assertFalse(NSFileManager.defaultManager.fileExistsAtPath("$path.tmp")) + } + + @Test + fun deleteTempRemovesALeftoverAndDeleteRemovesTheFile() { + io.replace(byteArrayOf(1), beforeRename = null) + NSFileManager.defaultManager.createFileAtPath("$path.tmp", NSData(), null) + + io.deleteTemp() + assertFalse(NSFileManager.defaultManager.fileExistsAtPath("$path.tmp")) + io.deleteTemp() + + assertTrue(io.delete()) + assertFalse(io.exists()) + } + + @Test + fun aDirectoryThatCannotBeCreatedIsAnIoException() { + val blocked = IosVaultFileIo("$path/inside/vault_state.bin") + io.replace(byteArrayOf(1), beforeRename = null) + + assertFailsWith { blocked.replace(byteArrayOf(1), beforeRename = null) } + } + + @Test + fun aVaultStoreOnTheFileKeepsWhatItWrote() { + val store = VaultStore(io) + assertTrue(store.edit { putBytes("a", byteArrayOf(1, 2, 3)) }) + + val reopened = VaultStore(IosVaultFileIo(path)) + + assertFalse(reopened.isCorrupt()) + assertContentEquals(byteArrayOf(1, 2, 3), reopened.getBytes("a")) + assertEquals(null, reopened.getBytes("b")) + } +} diff --git a/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/backup/JvmJsonBytes.kt b/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/backup/JvmJsonBytes.kt new file mode 100644 index 00000000..f4bc3bcf --- /dev/null +++ b/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/backup/JvmJsonBytes.kt @@ -0,0 +1,39 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.pasich.encly.core.backup + +import com.pasich.encly.core.security.SensitiveDataCleaner +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/* + * The JVM and Android side of JsonBytes.kt: java.io streams, because the output buffer of a + * ByteArrayOutputStream can be zeroized, which a kotlinx-io buffer cannot be yet. + */ + +internal actual fun Json.encodeToWipedBytes(serializer: SerializationStrategy, value: T): ByteArray { + val out = WipeableByteArrayOutputStream() + return try { + encodeToStream(serializer, value, out) + out.toByteArray() + } finally { + out.wipe() + } +} + +internal actual fun Json.decodeFromBytes(deserializer: DeserializationStrategy, bytes: ByteArray): T = + decodeFromStream(deserializer, ByteArrayInputStream(bytes)) + +/** Lets the serializer's internal buffer be zeroized instead of left for the GC. */ +private class WipeableByteArrayOutputStream : ByteArrayOutputStream() { + fun wipe() { + SensitiveDataCleaner.clear(buf) + reset() + } +} diff --git a/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmVaultFileIo.kt b/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmVaultFileIo.kt new file mode 100644 index 00000000..5ed46374 --- /dev/null +++ b/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmVaultFileIo.kt @@ -0,0 +1,61 @@ +package com.pasich.encly.core.security + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/* + * The Android/JVM side of VaultFileIo.kt: the vault file on java.io and java.nio, the only place + * that can fsync it and replace it atomically. A Kotlin Multiplatform build would turn this into + * an actual of the same interface. + */ + +/** A [VaultStore] on the file [file]. */ +public fun VaultStore(file: File): VaultStore = VaultStore(JvmVaultFileIo(file)) + +public class JvmVaultFileIo(private val file: File) : VaultFileIo { + override fun exists(): Boolean = file.exists() + + override fun read(): ByteArray = file.readBytes() + + override fun deleteTemp() { + tempFile().delete() + } + + override fun replace(bytes: ByteArray, beforeRename: (() -> Unit)?) { + file.parentFile?.mkdirs() + val temp = tempFile() + try { + FileOutputStream(temp).use { out -> + out.write(bytes) + out.flush() + out.fd.sync() + } + beforeRename?.invoke() + try { + Files.move( + temp.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + if (!temp.renameTo(file)) throw IOException("rename failed") + } + } catch (e: IOException) { + temp.delete() + throw e + } + } + + override fun delete(): Boolean = !file.exists() || file.delete() + + private fun tempFile() = File(file.path + TEMP_SUFFIX) + + private companion object { + const val TEMP_SUFFIX = ".tmp" + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt similarity index 99% rename from app/src/test/java/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt index 1dd3a98c..f7971802 100644 --- a/app/src/test/java/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt @@ -86,7 +86,7 @@ class AuthenticationEdgeCasesTest { @Test fun malformedPinsAreRefusedEverywhere() { - listOf("12345", "1234567", "12345a", "").forEach { bad -> + listOf("12345", "1234567890123", "12345a", "").forEach { bad -> assertEquals(PinChange.FAILED, auth.changePin(bad.toCharArray(), dek)) assertEquals(WipePinChange.FAILED, auth.configureWipePin(bad.toCharArray())) assertEquals(DuressPinChange.FAILED, duress(bad)) diff --git a/app/src/test/java/com/pasich/encly/core/security/AuthenticationManagerTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/AuthenticationManagerTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/core/security/AuthenticationManagerTest.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/AuthenticationManagerTest.kt diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/CryptoFormatsTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/CryptoFormatsTest.kt new file mode 100644 index 00000000..97ab84ab --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/CryptoFormatsTest.kt @@ -0,0 +1,98 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.crypto.Crypto +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +/** + * The exact bytes the app's formats hold on disk (PIN, wipe, duress, decoy-key, recovery and backup slots, + * the backup file and the key chain under them), sealed again here with the nonces of the fixtures. + */ +class CryptoFormatsTest { + private val dek = seq(32, 0, 1) + private val kek = seq(32, 100, 1) + private val salt = seq(16, 0, 3) + private val iv = seq(12, 0x20, 1) + private val version = byteArrayOf(3) + + @Test + fun sealingReproducesEveryGoldenSlot() { + val wipeKek = seq(32, 150, 1) + val marker = seq(32, 0x40, 1) + val decoyDek = seq(32, 0x80, 1) + val decoyKek = seq(32, 200, 1) + + assertArrayEquals( + GoldenVectors.pinSlot, + version + salt + iv + Crypto.aesGcmSeal(kek, iv, PinSlots.aad(salt), dek), + ) + assertArrayEquals( + GoldenVectors.wipeSlot, + version + iv + Crypto.aesGcmSeal(wipeKek, iv, PinSlots.wipeAad(salt), marker), + ) + assertArrayEquals( + GoldenVectors.duressSlot, + version + iv + + Crypto.aesGcmSeal(decoyKek, iv, "encly/pin/decoy-slot/v3".toByteArray() + version + salt, decoyDek), + ) + assertArrayEquals( + GoldenVectors.decoyKeyActive, + version + iv + + Crypto.aesGcmSeal( + GoldenVectors.decoyKeyKek, + iv, + "encly/decoy/key/v1".toByteArray() + version, + decoyDek, + ), + ) + assertArrayEquals( + GoldenVectors.decoyKeyOff, + version + iv + + Crypto.aesGcmSeal( + GoldenVectors.decoyKeyKek, + iv, + "encly/decoy/key/off/v1".toByteArray() + version, + decoyDek, + ), + ) + assertArrayEquals( + GoldenVectors.recoverySlot, + iv + Crypto.aesGcmSeal(GoldenVectors.recoveryKek, iv, "encly/recovery/slot/v3".toByteArray(), dek), + ) + assertArrayEquals( + GoldenVectors.backupSlot, + iv + + Crypto.aesGcmSeal( + GoldenVectors.backupSlotKek, + iv, + "encly/backup/slot/v1".toByteArray(), + GoldenVectors.backupRoot, + ), + ) + } + + @Test + fun sealingReproducesTheGoldenBackupFile() { + val header = GoldenVectors.backupFile.copyOf(61) + val nonce = header.copyOfRange(45, 57) + val payload = "{\"notes\":[\"secret note body\"]}".toByteArray() + + val sealed = Crypto.aesGcmSeal(GoldenVectors.backupFileKey, nonce, header, payload) + + assertArrayEquals(GoldenVectors.backupFile, header + sealed) + } + + @Test + fun theKeyChainReproducesTheGoldenPinKeys() { + val ikm = Crypto.pbkdf2HmacSha256("123456".toByteArray(), salt, 600_000, 32) + GoldenVectors.pinHw + + assertArrayEquals(GoldenVectors.pinKek, Crypto.hkdfSha256(ikm, salt, "encly/pin/kek/v3".toByteArray(), 32)) + assertArrayEquals(GoldenVectors.pinWipeKek, Crypto.hkdfSha256(ikm, salt, "encly/pin/wipe/v3".toByteArray(), 32)) + assertArrayEquals( + GoldenVectors.pinDecoyKek, + Crypto.hkdfSha256(ikm, salt, "encly/pin/decoy/v3".toByteArray(), 32), + ) + } + + private fun seq(size: Int, offset: Int, step: Int) = ByteArray(size) { (it * step + offset).toByte() } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/DuressPinTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/DuressPinTest.kt index c7e06e27..412f28e9 100644 --- a/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/DuressPinTest.kt @@ -1,11 +1,9 @@ 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.core.backup.BackupFlags import com.pasich.encly.testutil.FakeLockoutClock import com.pasich.encly.testutil.FakePinFactor -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.eqValue import com.pasich.encly.testutil.tempVaultStore @@ -418,11 +416,11 @@ class DuressPinTest { @Test fun theDuressPinOpensTheDecoyDatabaseAndNeverTheRealOne() { val vault = Vault() - `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.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).unlockDatabase(anyByteArray(), eq(true), eqValue(VaultKind.DECOY)) verify(vault.database, never()).unlockDatabase(anyByteArray(), anyBoolean()) verify(vault.database, never()).wipe() assertTrue(vault.security.isDecoySession()) @@ -431,24 +429,24 @@ class DuressPinTest { @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) + `when`(vault.database.hasEncryptedDatabase(VaultKind.DECOY)).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.DECOY))).thenReturn(true) assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(DURESS_PIN))) - verify(vault.database).unlockDatabase(anyByteArray(), eq(false), eqValue(VaultFile.DECOY)) + verify(vault.database).unlockDatabase(anyByteArray(), eq(false), eqValue(VaultKind.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) + `when`(vault.database.hasEncryptedDatabase(VaultKind.DECOY)).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.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) + verify(vault.database, never()).wipeIfClosed(VaultKind.DECOY) assertFalse(vault.security.isDecoySession()) assertFalse(vault.security.isDatabaseUnlocked()) } @@ -456,7 +454,7 @@ class DuressPinTest { @Test fun aDecoySessionCanNotTouchTheRealVaultsKeys() { val vault = Vault() - `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.DECOY))).thenReturn(true) assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(DURESS_PIN))) val before = vault.store.keys().associateWith { vault.store.getBytes(it)!!.toList() } @@ -468,9 +466,9 @@ class DuressPinTest { 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.addRecoverySeed(vault.seed.generateMnemonic())) + assertFalse(vault.security.replaceRecoverySeed(vault.seed.generateMnemonic())) + assertFalse(vault.security.createBackupKey(vault.seed.generateMnemonic())) assertFalse(vault.security.canResetPinWithoutCurrent()) vault.security.disableBiometric() assertFalse(vault.security.wipeAndReset()) @@ -484,7 +482,7 @@ class DuressPinTest { @Test fun aDecoySessionReChecksWithTheDuressPin() { val vault = Vault() - `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.DECOY))).thenReturn(true) assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(DURESS_PIN))) assertTrue(vault.security.verifyPin(pin(DURESS_PIN))) @@ -495,7 +493,7 @@ class DuressPinTest { fun switchingVaultsChangesTheEpochButReopeningTheSameOneDoesNot() { val vault = Vault() `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) - `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.DECOY))).thenReturn(true) assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) val real = vault.security.eraseEpoch() @@ -520,13 +518,13 @@ class DuressPinTest { fun removingTheDuressPinDeletesOnlyTheDecoyDatabase() { val vault = Vault() `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) - `when`(vault.database.wipeIfClosed(VaultFile.DECOY)).thenReturn(true) + `when`(vault.database.wipeIfClosed(VaultKind.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).wipeIfClosed(VaultKind.DECOY) verify(vault.database, never()).wipe() assertFalse(vault.security.hasDuressPin()) assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, vault.security.unlockWithPin(pin(DURESS_PIN))) @@ -536,7 +534,7 @@ class DuressPinTest { fun settingTheDuressPinWipesTheTypedPin() { val vault = Vault(withDuressPin = false) `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) - `when`(vault.database.wipeIfClosed(VaultFile.DECOY)).thenReturn(true) + `when`(vault.database.wipeIfClosed(VaultKind.DECOY)).thenReturn(true) assertEquals(DuressPinChange.FAILED, vault.security.configureDuressPin(pin(DURESS_PIN))) assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) @@ -544,33 +542,33 @@ class DuressPinTest { assertEquals(DuressPinChange.SET_NEW_VAULT, vault.security.configureDuressPin(typed)) assertTrue(typed.all { it == '\u0000' }) - verify(vault.database).wipeIfClosed(VaultFile.DECOY) + verify(vault.database).wipeIfClosed(VaultKind.DECOY) assertTrue(vault.security.hasDuressPin()) } @Test fun aDecoyCanNotOpenWhileAnEraseIsPending() { val vault = Vault() - `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.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)) + verify(vault.database, never()).unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.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 prefs = InMemoryKeyValueStore() val seed = SeedPhraseManager(store) - val database: SecureDatabaseManager = mock(SecureDatabaseManager::class.java) + val database: VaultDatabase = mock(VaultDatabase::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)) + assertTrue(seed.initializeVault(seed.generateMnemonic())) val bootstrap = seed.copyBootstrapKey()!! assertTrue(auth.configurePin(pin(PIN), bootstrap)) if (withDuressPin) { @@ -581,10 +579,10 @@ class DuressPinTest { }, ) } - prefs.edit() - .putBoolean("onboarding_shown_v3", true) - .putLong(BackupManager.LAST_EXPORT_KEY, 1L) - .commit() + prefs.commit { + putBoolean("onboarding_shown_v3", true) + putLong(BackupFlags.LAST_EXPORT_KEY, 1L) + } } } diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/MonotonicClockTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/MonotonicClockTest.kt new file mode 100644 index 00000000..729f1cce --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/MonotonicClockTest.kt @@ -0,0 +1,18 @@ +package com.pasich.encly.core.security + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The platform-free clock the unit tests use for the PIN lockout: it never goes back. */ +class MonotonicClockTest { + @Test + fun itNeverGoesBackAndAdvances() { + val first = JvmMonotonicClock.elapsedRealtime() + Thread.sleep(20) + val second = JvmMonotonicClock.elapsedRealtime() + + assertTrue("$first -> $second", second >= first + 15) + assertEquals(-1, JvmMonotonicClock.bootCount()) + } +} diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLengthTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLengthTest.kt new file mode 100644 index 00000000..23cf8d10 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLengthTest.kt @@ -0,0 +1,125 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.testutil.FakeLockoutClock +import com.pasich.encly.testutil.FakePinFactor +import com.pasich.encly.testutil.tempVaultFile +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A PIN is 6 to 12 digits, for the PIN, the wipe PIN and the duress PIN alike. The KDF is the same whatever + * the length, so PINs made before this option derive exactly the keys they did (the golden chain test pins that). + */ +class PinLengthTest { + private val store = VaultStore(tempVaultFile()) + private val factor = FakePinFactor() + private val manager = AuthenticationManager(store, factor, FakeLockoutClock()) + private val dek = ByteArray(32) { (it * 5).toByte() } + + @Test + fun theLimitsAreSixAndTwelve() { + assertEquals(6, MIN_PIN_LENGTH) + assertEquals(12, MAX_PIN_LENGTH) + } + + @Test + fun everyLengthFromSixToTwelveMakesAPinThatUnlocks() { + for (length in MIN_PIN_LENGTH..MAX_PIN_LENGTH) { + val digits = "9876543210987".take(length) + assertTrue("length $length", manager.configurePin(digits.toCharArray(), dek)) + + val unlocked = manager.unlockWithPin(digits.toCharArray()) + + assertArrayEquals("length $length", dek, (unlocked as PinUnlock.Success).dek) + assertTrue(manager.verifyPinAuth(digits.toCharArray())) + } + } + + @Test + fun shorterAndLongerPinsAreRefusedAndNothingIsWritten() { + for (digits in listOf("", "1", "12345", "1234567890123", "12345678901234567890")) { + assertFalse(digits, manager.configurePin(digits.toCharArray(), dek)) + } + for (typed in listOf("12345a", "123 456", "١٢٣٤٥٦", "12.3456", "1234567890-1")) { + assertFalse(typed, manager.configurePin(typed.toCharArray(), dek)) + } + + assertFalse(manager.hasPinSlot()) + } + + @Test + fun aPinIsNotItsOwnPrefixOrExtension() { + assertTrue(manager.configurePin("12345678".toCharArray(), dek)) + + assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("123456".toCharArray())) + assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("123456789".toCharArray())) + assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("0123456".toCharArray())) + assertTrue(manager.unlockWithPin("12345678".toCharArray()) is PinUnlock.Success) + } + + @Test + fun aMalformedLengthCountsAsAFailedAttemptLikeAWrongPin() { + assertTrue(manager.configurePin("123456".toCharArray(), dek)) + + repeat(AuthenticationManager.MAX_ATTEMPTS) { + assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("12345".toCharArray())) + } + + assertEquals(AuthenticationManager.FIRST_LOCKOUT_MS, manager.remainingLockoutMillis()) + } + + @Test + fun thePinWipePinAndDuressPinMayAllHaveDifferentLengths() { + assertTrue(manager.configurePin("1234567".toCharArray(), dek)) + assertEquals(WipePinChange.SET, manager.configureWipePin("876543210987".toCharArray())) + assertEquals(DuressPinChange.SET_NEW_VAULT, manager.configureDuressPin("246810".toCharArray(), dek) { true }) + + val duress = manager.unlockWithPin("246810".toCharArray()) + val real = manager.unlockWithPin("1234567".toCharArray()) + + assertTrue(duress is PinUnlock.Decoy) + assertArrayEquals(dek, (real as PinUnlock.Success).dek) + assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("24681".toCharArray())) + assertEquals(32, (duress as PinUnlock.Decoy).dek.size) + // Typed last, as the wipe PIN erases the vault. + assertTrue(manager.unlockWithPin("876543210987".toCharArray()) is PinUnlock.Erased) + } + + @Test + fun theSlotsOfPinsOfDifferentLengthsAreTheSameSize() { + assertTrue(manager.configurePin("123456".toCharArray(), dek)) + val short = store.getBytes("pin.slot")!!.size + val wipeShort = store.getBytes("pin.wipe.slot")!!.size + + assertTrue(manager.configurePin("123456789012".toCharArray(), dek)) + assertEquals(WipePinChange.SET, manager.configureWipePin("12345678".toCharArray())) + + assertEquals(short, store.getBytes("pin.slot")!!.size) + assertEquals(wipeShort, store.getBytes("pin.wipe.slot")!!.size) + } + + @Test + fun theWipeAndDuressPinsFollowTheSameLengthRule() { + assertTrue(manager.configurePin("123456".toCharArray(), dek)) + + assertEquals(WipePinChange.FAILED, manager.configureWipePin("12345".toCharArray())) + assertEquals(WipePinChange.FAILED, manager.configureWipePin("1234567890123".toCharArray())) + assertEquals(DuressPinChange.FAILED, manager.configureDuressPin("12345".toCharArray(), dek) { true }) + assertEquals(DuressPinChange.FAILED, manager.configureDuressPin("1234567890123".toCharArray(), dek) { true }) + + assertEquals(WipePinChange.SET, manager.configureWipePin("1234567".toCharArray())) + assertEquals(DuressPinChange.SET_NEW_VAULT, manager.configureDuressPin("12345678".toCharArray(), dek) { true }) + } + + @Test + fun aPinIsStillRefusedAsWipeOrDuressPinWhenItIsTheSamePinWhateverItsLength() { + assertTrue(manager.configurePin("12345678".toCharArray(), dek)) + + assertEquals(WipePinChange.SAME_AS_PIN, manager.configureWipePin("12345678".toCharArray())) + assertEquals(DuressPinChange.SAME_AS_PIN, manager.configureDuressPin("12345678".toCharArray(), dek) { true }) + assertEquals(WipePinChange.SET, manager.configureWipePin("123456789".toCharArray())) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/PinLockoutTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLockoutTest.kt similarity index 99% rename from app/src/test/java/com/pasich/encly/core/security/PinLockoutTest.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLockoutTest.kt index 75f10faf..296ff838 100644 --- a/app/src/test/java/com/pasich/encly/core/security/PinLockoutTest.kt +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLockoutTest.kt @@ -23,7 +23,7 @@ class PinLockoutTest { @Test fun onlySixDigitsAndAFullLengthKeyMakeAPinSlot() { assertFalse(manager.configurePin(pin("12345"), dek)) - assertFalse(manager.configurePin(pin("1234567"), dek)) + assertFalse(manager.configurePin(pin("1234567890123"), dek)) assertFalse(manager.configurePin(pin("12345a"), dek)) assertFalse(manager.configurePin(pin(PIN), ByteArray(DEK_LENGTH - 1))) diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/RecoveryFollowUpTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/RecoveryFollowUpTest.kt new file mode 100644 index 00000000..b44e82e4 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/RecoveryFollowUpTest.kt @@ -0,0 +1,167 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.testutil.FakeLockoutClock +import com.pasich.encly.testutil.FakePinFactor +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.testutil.anyByteArray +import com.pasich.encly.testutil.tempVaultFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +/** + * SecurityManager over real slot stores and a mocked database: what a recovery-phrase unlock asks of the user + * afterwards (keep the PIN, or set a new one), and a vault file that still holds a dev build's password entry. + */ +class RecoveryFollowUpTest { + private val store = VaultStore(tempVaultFile()) + private val prefs = InMemoryKeyValueStore() + private val seed = SeedPhraseManager(store) + private val factor = FakePinFactor() + private val clock = FakeLockoutClock() + private val auth = AuthenticationManager(store, factor, clock) + private val database = mock(VaultDatabase::class.java) + private val biometric = mock(BiometricManager::class.java) + private val manager = SecurityManager(prefs, store, seed, database, auth, biometric) {} + private lateinit var phrase: CharArray + + @Before + fun setUp() { + `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + phrase = seed.generateMnemonic() + assertTrue(manager.initializeNewVault(phrase.copyOf())) + assertTrue(manager.configurePin(PIN.toCharArray())) + assertTrue(manager.finishInitialSetup()) + manager.lock() + `when`(database.hasEncryptedDatabase()).thenReturn(true) + } + + // --- after the recovery phrase --------------------------------------------------------------------- + + @Test + fun aWorkingPinIsOfferedToBeKeptAfterRecovery() { + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + + assertEquals(RecoveryStage.CHOOSE, manager.recoveryStage()) + assertTrue(manager.canResetPinWithoutCurrent()) + + assertTrue(manager.keepUnlockMethodsAfterRecovery()) + + assertEquals(RecoveryStage.NONE, manager.recoveryStage()) + assertFalse(manager.canResetPinWithoutCurrent()) + manager.lock() + assertEquals("the kept PIN still unlocks", VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + } + + @Test + fun aNewPinCanBeChosenInsteadWithoutTheOldOne() { + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + assertEquals(RecoveryStage.CHOOSE, manager.recoveryStage()) + + assertEquals(PinChange.SET, manager.changePin(NEW_PIN.toCharArray())) + + assertEquals(RecoveryStage.NONE, manager.recoveryStage()) + manager.lock() + assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithPin(PIN.toCharArray())) + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(NEW_PIN.toCharArray())) + } + + @Test + fun aMissingPinForcesANewOneAfterRecovery() { + auth.wipe() + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + + assertEquals(RecoveryStage.MUST_SET_PIN, manager.recoveryStage()) + assertFalse("a forced reset can not be skipped", manager.keepUnlockMethodsAfterRecovery()) + assertEquals(RecoveryStage.MUST_SET_PIN, manager.recoveryStage()) + assertTrue(manager.canResetPinWithoutCurrent()) + assertEquals(PinChange.SET, manager.changePin(NEW_PIN.toCharArray())) + assertEquals(RecoveryStage.NONE, manager.recoveryStage()) + } + + @Test + fun aLostDeviceKeyForcesANewPinAfterRecovery() { + openWithPin() + manager.lock() + factor.lost = true + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + + assertEquals(RecoveryStage.MUST_SET_PIN, manager.recoveryStage()) + assertEquals(PinChange.SET, manager.changePin(NEW_PIN.toCharArray())) + manager.lock() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(NEW_PIN.toCharArray())) + } + + @Test + fun aRunningLockoutForcesANewPinAfterRecoveryAndTheNewPinEndsIt() { + repeat(AuthenticationManager.MAX_ATTEMPTS) { manager.unlockWithPin(WRONG_PIN.toCharArray()) } + assertTrue(manager.pinLockoutRemainingMillis() > 0L) + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + + assertEquals(RecoveryStage.MUST_SET_PIN, manager.recoveryStage()) + assertEquals(PinChange.SET, manager.changePin(NEW_PIN.toCharArray())) + assertEquals(0L, manager.pinLockoutRemainingMillis()) + } + + @Test + fun aPinUnlockOwesNothingAndLockingEndsTheChoice() { + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + assertEquals(RecoveryStage.NONE, manager.recoveryStage()) + assertFalse(manager.keepUnlockMethodsAfterRecovery()) + manager.lock() + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + assertEquals(RecoveryStage.CHOOSE, manager.recoveryStage()) + manager.lock() + assertEquals(RecoveryStage.NONE, manager.recoveryStage()) + assertFalse(manager.canResetPinWithoutCurrent()) + } + + @Test + fun aWrongPhraseOwesNothing() { + assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithSeed(seed.generateMnemonic())) + + assertEquals(RecoveryStage.NONE, manager.recoveryStage()) + assertNull(manager.copyBackupRootKey()) + } + + // --- a dev build's password entry ------------------------------------------------------------------- + + @Test + fun aLeftoverPasswordEntryIsIgnoredAndTheNextPinWriteDropsIt() { + store.edit { + putBytes("pin.password.slot", ByteArray(LEFTOVER_SLOT_SIZE) { 7 }) + putBoolean("pin.password.notice", true) + } + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + assertTrue(store.contains("pin.password.slot")) + assertEquals(PinChange.SET, manager.changePin(NEW_PIN.toCharArray())) + + assertFalse(store.contains("pin.password.slot")) + assertFalse(store.contains("pin.password.notice")) + manager.lock() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(NEW_PIN.toCharArray())) + } + + private fun openWithPin() { + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + } + + private companion object { + const val PIN = "135790" + const val NEW_PIN = "246813" + const val DURESS = "908070" + const val WRONG_PIN = "000111" + const val LEFTOVER_SLOT_SIZE = 77 + } +} diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt new file mode 100644 index 00000000..ad2bb119 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt @@ -0,0 +1,529 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.testutil.FakeLockoutClock +import com.pasich.encly.testutil.FakePinFactor +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.testutil.anyByteArray +import com.pasich.encly.testutil.tempVaultFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.Mockito +import org.mockito.Mockito.clearInvocations +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.File + +/** + * SecurityManager over real slot stores and a mocked database: where first-run setup, startup + * routing and session locking send the user, and what a locked or half-set-up vault refuses. + */ +class SecurityManagerLifecycleTest { + private lateinit var file: File + private lateinit var store: VaultStore + private lateinit var prefs: InMemoryKeyValueStore + private lateinit var seed: SeedPhraseManager + private lateinit var auth: AuthenticationManager + private val pinFactor = FakePinFactor() + private val lockoutClock = FakeLockoutClock() + private lateinit var database: VaultDatabase + private lateinit var biometric: BiometricManager + private var slotPresent = false + private var enrollResult = true + private var enrollCalls = 0 + private var disableCalls = 0 + private var prompt: BiometricManager.BiometricCallback? = null + private lateinit var manager: SecurityManager + + @Before + fun setUp() { + file = tempVaultFile() + store = VaultStore(file) + prefs = InMemoryKeyValueStore() + seed = SeedPhraseManager(store) + auth = AuthenticationManager(store, pinFactor, lockoutClock) + database = mock(VaultDatabase::class.java) + biometric = mock(BiometricManager::class.java) { call -> + when (call.method.name) { + "hasSlot" -> slotPresent + + "disable" -> { + disableCalls++ + null + } + + "enroll" -> { + enrollCalls++ + @Suppress("UNCHECKED_CAST") + (call.getArgument(2) as (Boolean) -> Unit)(enrollResult) + null + } + + "authenticate" -> { + prompt = call.getArgument(2) + null + } + + else -> Mockito.RETURNS_DEFAULTS.answer(call) + } + } + manager = SecurityManager(prefs, store, seed, database, auth, biometric) {} + `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + } + + /** First-run setup up to and including the committed onboarding, with a recovery phrase. */ + private fun committedVault(withRecoveryPhrase: Boolean = true): CharArray { + val phrase = seed.generateMnemonic() + assertTrue(manager.initializeNewVault(if (withRecoveryPhrase) phrase.copyOf() else null)) + assertTrue(manager.configurePin(PIN.toCharArray())) + assertTrue(manager.finishInitialSetup()) + manager.lock() + `when`(database.hasEncryptedDatabase()).thenReturn(true) + return phrase + } + + // --- startup routing ------------------------------------------------------------------ + + @Test + fun aDamagedStateFileRoutesToTheDamagedVaultScreen() { + file.writeBytes(ByteArray(50) { 1 }) + + assertEquals(InitialStatus.LOSS_CRYPTO, manager.resolveInitialStatus()) + assertEquals(InitialStatus.LOSS_CRYPTO, manager.securityStatus) + } + + @Test + fun anEncryptedDatabaseWithoutVaultMetadataIsALegacyVault() { + `when`(database.hasEncryptedDatabase()).thenReturn(true) + + assertEquals(InitialStatus.LEGACY_VAULT, manager.resolveInitialStatus()) + } + + @Test + fun aCommittedVaultWithoutItsDatabaseRoutesToDatabaseLoss() { + committedVault() + `when`(database.hasEncryptedDatabase()).thenReturn(false) + + assertEquals(InitialStatus.LOSS_DATABASE, manager.resolveInitialStatus()) + } + + @Test + fun aCommittedVaultAsksForTheLockScreen() { + committedVault() + + assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) + assertTrue(manager.isLockable()) + } + + @Test + fun aVaultWithoutAPinSlotAndWithoutARecoveryPhraseHasNoWayIn() { + committedVault(withRecoveryPhrase = false) + auth.wipe() + + assertEquals(InitialStatus.LOSS_CRYPTO, manager.resolveInitialStatus()) + } + + @Test + fun aVaultWithoutAPinSlotStillAsksForTheRecoveryPhrase() { + committedVault() + auth.wipe() + + assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) + } + + @Test + fun anOpenVaultStaysOpenWhenTheActivityAsksAgain() { + committedVault() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + `when`(database.isDatabaseUnlocked()).thenReturn(true) + + assertEquals(InitialStatus.MAIN, manager.resolveInitialStatus()) + } + + // --- first-run setup -------------------------------------------------------------------- + + @Test + fun aFreshVaultIsNotLockableBeforeOnboardingIsCommitted() { + assertTrue(manager.initializeNewVault(null)) + assertTrue(manager.configurePin(PIN.toCharArray())) + + assertFalse(manager.isLockable()) + assertTrue(manager.isOnboardingShow()) + } + + @Test + fun onboardingCanNotBeCommittedWithoutAPinSlot() { + assertTrue(manager.initializeNewVault(null)) + + assertFalse(manager.setOnboardingShown()) + assertFalse(manager.finishInitialSetup()) + assertTrue(manager.isOnboardingShow()) + } + + @Test + fun theInitialVaultIsNotOpenedWithoutAPinSlot() { + assertTrue(manager.initializeNewVault(null)) + + assertFalse(manager.openInitialVault()) + verify(database, never()).unlockDatabase(anyByteArray(), anyBoolean()) + } + + @Test + fun aCommittedVaultCanNotBeRecreatedOverItsData() { + committedVault() + + assertFalse(manager.initializeNewVault(null)) + assertTrue(manager.hasRecoverySeed()) + } + + @Test + fun anInvalidRecoveryPhraseDoesNotStartAVault() { + assertFalse(manager.initializeNewVault("not a real phrase".toCharArray())) + assertFalse(manager.isValidRecoveryPhrase("not a real phrase".toCharArray())) + } + + @Test + fun committingSetupDropsTheBootstrapKeySoNothingCanBeEnrolledFromIt() { + committedVault() + var enrolled: Boolean? = null + + manager.enrollBiometric(object : BiometricHost {}) { enrolled = it } + + assertEquals(false, enrolled) + assertEquals(0, enrollCalls) + } + + // --- unlocking and locking ------------------------------------------------------------ + + @Test + fun aDatabaseThatDoesNotOpenFailsThePinUnlockWithoutOpeningASession() { + committedVault() + `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(false) + + assertEquals(VaultUnlockResult.DB_ERROR, manager.unlockWithPin(PIN.toCharArray())) + assertNull(manager.copyBackupRootKey()) + assertFalse(manager.canResetPinWithoutCurrent()) + } + + @Test + fun aWrongPinIsAnInvalidCredential() { + committedVault() + + assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithPin(WRONG_PIN.toCharArray())) + } + + @Test + fun aWrongRecoveryPhraseIsAnInvalidCredential() { + committedVault() + val other = seed.generateMnemonic() + + assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithSeed(other)) + assertFalse(manager.verifySeed(other)) + } + + @Test + fun theRecoveryPhraseOpensTheVaultAndLetsThePinBeResetOnce() { + val phrase = committedVault() + assertTrue(manager.verifySeed(phrase.copyOf())) + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + assertTrue(manager.canResetPinWithoutCurrent()) + + assertEquals(PinChange.SET, manager.changePin(NEW_PIN.toCharArray())) + assertFalse("a new PIN ends the recovery privilege", manager.canResetPinWithoutCurrent()) + } + + @Test + fun aPinUnlockDoesNotGrantTheForgottenPinPrivilege() { + committedVault() + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + + assertFalse(manager.canResetPinWithoutCurrent()) + } + + @Test + fun lockingEndsTheSessionAndWithdrawsTheKeysItHeld() { + val phrase = committedVault() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + assertTrue(manager.hasBackupKey()) + assertNotNull("the open session can read the backup root", manager.copyBackupRootKey()) + + clearInvocations(database) + + manager.lock() + + assertEquals(InitialStatus.AUTH, manager.securityStatus) + assertNull(manager.copyBackupRootKey()) + assertFalse(manager.addRecoverySeed(seed.generateMnemonic())) + assertFalse(manager.replaceRecoverySeed(seed.generateMnemonic())) + assertFalse(manager.createBackupKey(phrase.copyOf())) + assertFalse(manager.canResetPinWithoutCurrent()) + verify(database).reset() + } + + @Test + fun aKeyOfTheWrongSizeNeverOpensTheDatabase() { + assertFalse(manager.unlockWithRawKey(ByteArray(31))) + assertFalse(manager.unlockWithRawKey(ByteArray(33), allowCreate = true)) + + verify(database, never()).unlockDatabase(anyByteArray(), anyBoolean()) + } + + @Test + fun theLockoutSurfacesThroughTheManager() { + committedVault() + repeat(LOCKOUT_AFTER) { manager.unlockWithPin(WRONG_PIN.toCharArray()) } + + assertTrue(manager.pinLockoutRemainingMillis() > 0) + assertEquals(VaultUnlockResult.LOCKED_OUT, manager.unlockWithPin(PIN.toCharArray())) + } + + @Test + fun verifyingThePinCountsAndAcceptsOnlyTheRealOne() { + committedVault() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + + assertTrue(manager.verifyPin(PIN.toCharArray())) + assertFalse(manager.verifyPin(WRONG_PIN.toCharArray())) + } + + // --- biometric plumbing ----------------------------------------------------------------- + + @Test + fun enrollingRecordsTheResultOfTheBiometricSlot() { + committedVault() + manager.unlockWithPin(PIN.toCharArray()) + slotPresent = true + var result: Boolean? = null + + manager.enrollBiometric(object : BiometricHost {}) { result = it } + + assertEquals(true, result) + assertTrue(manager.isBiometricEnabled()) + assertTrue(manager.getSettingsAuth().isBiometricEnabled) + } + + @Test + fun aFailedEnrollmentLeavesBiometricsOff() { + committedVault() + manager.unlockWithPin(PIN.toCharArray()) + slotPresent = true + enrollResult = false + var result: Boolean? = null + + manager.enrollBiometric(object : BiometricHost {}) { result = it } + + assertEquals(false, result) + assertFalse(manager.isBiometricEnabled()) + } + + @Test + fun disablingBiometricsClearsTheSlotAndTheFlag() { + committedVault() + manager.unlockWithPin(PIN.toCharArray()) + auth.markBiometricEnabled(true) + slotPresent = true + assertTrue(manager.isBiometricEnabled()) + disableCalls = 0 + + manager.disableBiometric() + + assertEquals(1, disableCalls) + assertFalse(manager.isBiometricEnabled()) + } + + @Test + fun theFlagAloneIsNotEnoughWithoutTheKeystoreSlot() { + committedVault() + auth.markBiometricEnabled(true) + slotPresent = false + + assertFalse(manager.isBiometricEnabled()) + } + + @Test + fun theFingerprintConfirmationMapsEveryOutcomeToYesOrNo() { + committedVault() + manager.unlockWithPin(PIN.toCharArray()) + val results = mutableListOf() + + manager.confirmBiometric(object : BiometricHost {}) { results += it } + val callback = checkNotNull(prompt) + callback.onFailed() + assertEquals("a single bad scan is not an answer", emptyList(), results) + callback.onSuccess() + callback.onError(7, "locked") + callback.onCancelled() + + assertEquals(listOf(true, false, false), results) + } + + // --- a lost flags store, and a setup killed at each step --------------------------------- + + /** A new process over the same files: nothing in memory (no bootstrap key) survives. */ + private fun restarted(vaultStore: VaultStore = VaultStore(file), flags: KeyValueStore = prefs): SecurityManager { + val seeds = SeedPhraseManager(vaultStore) + val authentication = AuthenticationManager(vaultStore, pinFactor, lockoutClock) + return SecurityManager(flags, vaultStore, seeds, database, authentication, biometric) {} + } + + @Test + fun aSetupKilledBeforeAnythingWasWrittenRestartsOnboarding() { + assertEquals(InitialStatus.ONBOARDING, restarted().resolveInitialStatus()) + } + + @Test + fun aSetupKilledAfterTheSeedWasWrittenRestartsOnboarding() { + assertTrue(manager.initializeNewVault(null)) + + val next = restarted() + + assertEquals(InitialStatus.ONBOARDING, next.resolveInitialStatus()) + assertTrue(next.initializeNewVault(null)) + } + + @Test + fun aSetupKilledAfterTheRecoverySlotWasWrittenRestartsOnboarding() { + assertTrue(manager.initializeNewVault(seed.generateMnemonic())) + + val next = restarted() + + assertEquals(InitialStatus.ONBOARDING, next.resolveInitialStatus()) + assertTrue(next.initializeNewVault(null)) + } + + @Test + fun aSetupKilledAfterThePinSlotButBeforeTheDatabaseRestartsOnboarding() { + assertTrue(manager.initializeNewVault(seed.generateMnemonic())) + assertTrue(manager.configurePin(PIN.toCharArray())) + + val next = restarted() + + assertEquals(InitialStatus.ONBOARDING, next.resolveInitialStatus()) + assertTrue(next.initializeNewVault(null)) + } + + @Test + fun aSetupKilledAfterTheDatabaseWasCreatedIsACommittedVaultAndKeepsIt() { + assertTrue(manager.initializeNewVault(null)) + assertTrue(manager.configurePin(PIN.toCharArray())) + assertTrue(manager.openInitialVault()) + `when`(database.hasEncryptedDatabase()).thenReturn(true) + clearInvocations(database) + + val next = restarted() + + assertEquals(InitialStatus.AUTH, next.resolveInitialStatus()) + assertFalse(next.initializeNewVault(null)) + verify(database, never()).wipe() + } + + @Test + fun theSetupStillRunningInThisProcessMayStartOverEvenWithItsDatabase() { + assertTrue(manager.initializeNewVault(null)) + assertTrue(manager.configurePin(PIN.toCharArray())) + assertTrue(manager.openInitialVault()) + `when`(database.hasEncryptedDatabase()).thenReturn(true) + + assertTrue(manager.initializeNewVault(null)) + } + + @Test + fun aLostFlagOnACommittedVaultIsRepairedAndTheVaultIsNeverRecreated() { + committedVault() + prefs.commit { clear() } + clearInvocations(database) + + val next = restarted() + + assertEquals(InitialStatus.AUTH, next.resolveInitialStatus()) + assertFalse(next.isOnboardingShow()) + assertFalse(next.initializeNewVault(null)) + verify(database, never()).wipe() + assertEquals(VaultUnlockResult.SUCCESS, next.unlockWithPin(PIN.toCharArray())) + } + + @Test + fun aLostFlagOnACommittedVaultIsRefusedEvenBeforeStartupRoutingRan() { + committedVault() + prefs.commit { clear() } + clearInvocations(database) + + assertFalse(restarted().initializeNewVault(null)) + verify(database, never()).wipe() + assertTrue(manager.hasRecoverySeed()) + } + + @Test + fun aLostFlagOnAVaultWithOnlyARecoverySlotIsStillCommitted() { + committedVault() + auth.wipe() + prefs.commit { clear() } + + assertEquals(InitialStatus.AUTH, restarted().resolveInitialStatus()) + } + + @Test + fun aFlagsStoreThatCanNotBeWrittenStillLocksACommittedVault() { + committedVault() + val flagless = object : KeyValueStore { + override fun getString(key: String, default: String?) = default + override fun getBoolean(key: String, default: Boolean) = default + override fun getLong(key: String, default: Long) = default + override fun contains(key: String) = false + override fun edit(block: KeyValueStore.Editor.() -> Unit) = Unit + override fun commit(block: KeyValueStore.Editor.() -> Unit) = false + } + val next = restarted(flags = flagless) + + assertEquals(InitialStatus.AUTH, next.resolveInitialStatus()) + assertEquals(VaultUnlockResult.SUCCESS, next.unlockWithPin(PIN.toCharArray())) + assertTrue("backgrounding and Lock now still close the vault", next.isLockable()) + } + + @Test + fun aLostFlagAndALostDatabaseHasNothingToProtectAndRestartsOnboarding() { + committedVault() + prefs.commit { clear() } + `when`(database.hasEncryptedDatabase()).thenReturn(false) + + assertEquals(InitialStatus.ONBOARDING, restarted().resolveInitialStatus()) + } + + @Test + fun aVaultFileThatCanNotBeReadRightNowIsNeitherDamagedNorOnboarding() { + committedVault() + prefs.commit { clear() } + val io = object : VaultFileIo by JvmVaultFileIo(file) { + var failReads = true + override fun read(): ByteArray = if (failReads) throw java.io.IOException("locked") else file.readBytes() + } + val locked = restarted(VaultStore(io)) + clearInvocations(database) + + assertEquals(InitialStatus.STORAGE_UNREADABLE, locked.resolveInitialStatus()) + assertFalse(locked.initializeNewVault(null)) + verify(database, never()).wipe() + + io.failReads = false + + assertEquals(InitialStatus.AUTH, locked.resolveInitialStatus()) + assertFalse(file.readBytes().isEmpty()) + } + + private companion object { + const val PIN = "135790" + const val NEW_PIN = "246813" + const val WRONG_PIN = "000111" + const val LOCKOUT_AFTER = 5 + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerTest.kt index 83dd3637..3172f01d 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerTest.kt @@ -1,7 +1,6 @@ package com.pasich.encly.core.security -import com.pasich.encly.data.database.SecureDatabaseManager -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.anyCharArray import com.pasich.encly.testutil.tempVaultFile @@ -21,9 +20,9 @@ import java.io.File class SecurityManagerTest { - private lateinit var prefs: InMemorySharedPreferences + private lateinit var prefs: InMemoryKeyValueStore private lateinit var seed: SeedPhraseManager - private lateinit var database: SecureDatabaseManager + private lateinit var database: VaultDatabase private lateinit var auth: AuthenticationManager private lateinit var biometric: BiometricManager private lateinit var file: File @@ -32,9 +31,9 @@ class SecurityManagerTest { @Before fun setUp() { - prefs = InMemorySharedPreferences() + prefs = InMemoryKeyValueStore() seed = mock(SeedPhraseManager::class.java) - database = mock(SecureDatabaseManager::class.java) + database = mock(VaultDatabase::class.java) auth = mock(AuthenticationManager::class.java) biometric = mock(BiometricManager::class.java) file = tempVaultFile() @@ -243,6 +242,7 @@ class SecurityManagerTest { `when`(seed.unlockWithSeed(phrase)).thenReturn(ByteArray(32) { 5 }) `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) `when`(auth.changePin(anyCharArray(), anyByteArray())).thenReturn(PinChange.SET) + `when`(auth.recoveryFollowUp()).thenReturn(RecoveryFollowUp.MUST_SET_PIN) assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase)) assertTrue(manager.canResetPinWithoutCurrent()) @@ -258,6 +258,7 @@ class SecurityManagerTest { `when`(seed.unlockWithSeed(phrase)).thenReturn(ByteArray(32) { 5 }) `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) `when`(auth.unlockWithPin("123456".toCharArray())).thenReturn(PinUnlock.Success(ByteArray(32) { 5 })) + `when`(auth.recoveryFollowUp()).thenReturn(RecoveryFollowUp.KEEP_OR_CHANGE) manager.unlockWithSeed(phrase) manager.lock() @@ -298,7 +299,7 @@ class SecurityManagerTest { fun wipingADamagedVaultStartsOverWithAClearStore() { file.writeBytes(ByteArray(64) { 3 }) val onDamagedFile = SecurityManager(prefs, VaultStore(file), seed, database, auth, biometric) - prefs.edit().putBoolean(ONBOARDING_KEY, true).commit() + prefs.commit { putBoolean(ONBOARDING_KEY, true) } assertTrue(onDamagedFile.wipeAndReset()) @@ -345,7 +346,7 @@ class SecurityManagerTest { org.mockito.AdditionalMatchers.aryEq(value.toCharArray()) ?: CharArray(0) private fun commitVault() { - prefs.edit().putBoolean(ONBOARDING_KEY, true).commit() + prefs.commit { putBoolean(ONBOARDING_KEY, true) } `when`(seed.verificationKeyData()).thenReturn(true) `when`(seed.hasStoredSeed()).thenReturn(true) `when`(database.hasEncryptedDatabase()).thenReturn(true) diff --git a/app/src/test/java/com/pasich/encly/core/security/SeedPhraseManagerTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SeedPhraseManagerTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/core/security/SeedPhraseManagerTest.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SeedPhraseManagerTest.kt index 486e77c6..6af42ae5 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SeedPhraseManagerTest.kt +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SeedPhraseManagerTest.kt @@ -25,7 +25,7 @@ class SeedPhraseManagerTest { @Test fun recoverySeedUnwrapsTheBootstrapDek() { - val phrase = manager.generateMnemonic().chars + val phrase = manager.generateMnemonic() assertTrue(manager.initializeVault(phrase.copyOf())) val bootstrap = manager.copyBootstrapKey()!! @@ -35,9 +35,9 @@ class SeedPhraseManagerTest { @Test fun wrongSeedNeverUnwraps() { - assertTrue(manager.initializeVault(manager.generateMnemonic().chars)) + assertTrue(manager.initializeVault(manager.generateMnemonic())) - assertNull(manager.unlockWithSeed(manager.generateMnemonic().chars)) + assertNull(manager.unlockWithSeed(manager.generateMnemonic())) } @Test @@ -62,7 +62,7 @@ class SeedPhraseManagerTest { @Test fun backupSealedInTheVaultOpensWithOnlyTheWordsOnAFreshInstall() { - val phrase = manager.generateMnemonic().chars + val phrase = manager.generateMnemonic() assertTrue(manager.initializeVault(phrase.copyOf())) val dek = manager.copyBootstrapKey()!! assertTrue(manager.hasBackupKey()) @@ -77,7 +77,7 @@ class SeedPhraseManagerTest { @Test fun backupKeyNeedsTheVaultKeyAndDiffersFromIt() { - assertTrue(manager.initializeVault(manager.generateMnemonic().chars)) + assertTrue(manager.initializeVault(manager.generateMnemonic())) val dek = manager.copyBootstrapKey()!! assertNull(manager.unwrapBackupRoot(ByteArray(32))) @@ -91,7 +91,7 @@ class SeedPhraseManagerTest { assertFalse(manager.hasBackupKey()) assertNull(manager.unwrapBackupRoot(dek)) - val phrase = manager.generateMnemonic().chars + val phrase = manager.generateMnemonic() assertTrue(manager.addRecoverySeed(phrase.copyOf(), dek)) assertTrue(manager.hasRecoverySeed()) @@ -99,16 +99,16 @@ class SeedPhraseManagerTest { assertArrayEquals(dek, manager.unlockWithSeed(phrase.copyOf())) assertArrayEquals(BackupKeys.rootFromMnemonic(phrase.copyOf()), manager.unwrapBackupRoot(dek)) // A second seed is refused: it would silently replace the one the user wrote down. - assertFalse(manager.addRecoverySeed(manager.generateMnemonic().chars, dek)) + assertFalse(manager.addRecoverySeed(manager.generateMnemonic(), dek)) } @Test fun backupKeyIsOnlyRecreatedFromThisVaultsOwnWords() { - val phrase = manager.generateMnemonic().chars + val phrase = manager.generateMnemonic() assertTrue(manager.initializeVault(phrase.copyOf())) val dek = manager.copyBootstrapKey()!! - assertFalse(manager.createBackupKey(manager.generateMnemonic().chars, dek)) + assertFalse(manager.createBackupKey(manager.generateMnemonic(), dek)) assertTrue(manager.createBackupKey(phrase.copyOf(), dek)) } @@ -116,11 +116,11 @@ class SeedPhraseManagerTest { @Test fun replacingThePhraseMovesTheVaultToTheNewWordsOnly() { - val old = manager.generateMnemonic().chars + val old = manager.generateMnemonic() assertTrue(manager.initializeVault(old.copyOf())) val dek = manager.copyBootstrapKey()!! val oldRoot = manager.unwrapBackupRoot(dek)!! - val new = manager.generateMnemonic().chars + val new = manager.generateMnemonic() assertTrue(manager.replaceRecoverySeed(new.copyOf(), dek)) @@ -137,11 +137,11 @@ class SeedPhraseManagerTest { @Test fun backupsMadeBeforeAReplacementStillOpenOnlyWithTheOldWords() { - val old = manager.generateMnemonic().chars + val old = manager.generateMnemonic() assertTrue(manager.initializeVault(old.copyOf())) val dek = manager.copyBootstrapKey()!! val before = BackupCipher.seal("old".toByteArray(), BackupSecret.RecoveryRoot(manager.unwrapBackupRoot(dek)!!)) - val new = manager.generateMnemonic().chars + val new = manager.generateMnemonic() assertTrue(manager.replaceRecoverySeed(new.copyOf(), dek)) val after = BackupCipher.seal("new".toByteArray(), BackupSecret.RecoveryRoot(manager.unwrapBackupRoot(dek)!!)) @@ -155,16 +155,16 @@ class SeedPhraseManagerTest { fun replacingNeedsAnExistingPhraseAndAValidNewOne() { assertTrue(manager.initializeVault(null)) val dek = manager.copyBootstrapKey()!! - assertFalse(manager.replaceRecoverySeed(manager.generateMnemonic().chars, dek)) + assertFalse(manager.replaceRecoverySeed(manager.generateMnemonic(), dek)) - assertTrue(manager.addRecoverySeed(manager.generateMnemonic().chars, dek)) + assertTrue(manager.addRecoverySeed(manager.generateMnemonic(), dek)) assertFalse(manager.replaceRecoverySeed("not a valid phrase".toCharArray(), dek)) - assertFalse(manager.replaceRecoverySeed(manager.generateMnemonic().chars, ByteArray(16))) + assertFalse(manager.replaceRecoverySeed(manager.generateMnemonic(), ByteArray(16))) } @Test fun theRecoveryKeyAndTheBackupRootNormaliseThePhraseTheSameWay() { - val phrase = manager.generateMnemonic().chars + val phrase = manager.generateMnemonic() assertTrue(manager.initializeVault(phrase.copyOf())) val dek = manager.copyBootstrapKey()!! val messy = (" " + String(phrase).uppercase().replace(" ", " \t ") + "\n").toCharArray() diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockFlushTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockFlushTest.kt new file mode 100644 index 00000000..986bb978 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockFlushTest.kt @@ -0,0 +1,117 @@ +package com.pasich.encly.core.security + +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +/** A lock lets the open editors write their pending changes first, and never waits for them for long. */ +@OptIn(ExperimentalCoroutinesApi::class) +class SessionLockFlushTest { + private val owner = mock(LifecycleOwner::class.java) + private val security = mock(SecurityManager::class.java) + private val scope = TestScope(StandardTestDispatcher()) + private val events = mutableListOf() + private lateinit var manager: SessionLockManager + + @Before + fun setUp() { + `when`(security.isLockable()).thenReturn(true) + `when`(security.isDatabaseUnlocked()).thenReturn(true) + doAnswer { events += "closed" }.`when`(security).lock() + manager = SessionLockManager(security).apply { lockScope = CoroutineScope(scope.coroutineContext) } + manager.onStart(owner) + } + + @Test + fun theVaultIsClosedAfterThePendingSaveFinished() { + manager.registerPreLockFlush { + delay(200) + events += "saved" + } + + manager.lockNow() + assertFalse(manager.locked.value) + scope.advanceTimeBy(250) + scope.runCurrent() + + assertEquals(listOf("saved", "closed"), events) + assertTrue(manager.locked.value) + } + + @Test + fun aSaveThatNeverFinishesDoesNotHoldTheLockPastTheBound() { + manager.registerPreLockFlush { awaitCancellation() } + + manager.lockNow() + scope.advanceTimeBy(LOCK_FLUSH_TIMEOUT_MS - 1) + scope.runCurrent() + verify(security, never()).lock() + + scope.advanceTimeBy(2) + scope.runCurrent() + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun aFailingSaveStillLocks() { + manager.registerPreLockFlush { error("disk") } + + manager.lockNow() + scope.runCurrent() + + assertEquals(listOf("closed"), events) + assertTrue(manager.locked.value) + } + + @Test + fun theDeviceLockPathWaitsOnlyTheSameBound() { + manager.registerPreLockFlush { awaitCancellation() } + + manager.onStop(owner) + scope.advanceTimeBy(LOCK_FLUSH_TIMEOUT_MS + 1) + scope.runCurrent() + + verify(security).lock() + } + + @Test + fun twoLocksWhileWaitingCloseTheVaultOnce() { + manager.registerPreLockFlush { delay(100) } + + manager.lockNow() + manager.lockNow() + scope.advanceTimeBy(150) + scope.runCurrent() + + assertEquals(listOf("closed"), events) + } + + @Test + fun anUnregisteredFlushIsNotWaitedFor() { + val unregister = manager.registerPreLockFlush { events += "saved" } + unregister() + + manager.lockNow() + + assertEquals(listOf("closed"), events) + assertTrue(manager.locked.value) + } +} diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockManagerTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockManagerTest.kt new file mode 100644 index 00000000..7afb7bba --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockManagerTest.kt @@ -0,0 +1,585 @@ +package com.pasich.encly.core.security + +import androidx.lifecycle.LifecycleOwner +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class SessionLockManagerTest { + + private val owner = mock(LifecycleOwner::class.java) + private lateinit var security: SecurityManager + private lateinit var manager: SessionLockManager + private val screenshots = ScreenshotAllowance() + + @Before + fun setUp() { + security = mock(SecurityManager::class.java) + `when`(security.isLockable()).thenReturn(true) + manager = SessionLockManager(security, screenshots = screenshots) + manager.onStart(owner) + } + + @Test + fun backgroundingAnOpenVaultLocksIt() { + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.onStop(owner) + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun backgroundingWithoutAnOpenVaultDoesNothing() { + manager.onStop(owner) + + verify(security, never()).lock() + assertFalse(manager.locked.value) + } + + @Test + fun backgroundingBeforeAnyLockIsConfiguredDoesNothing() { + `when`(security.isLockable()).thenReturn(false) + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.onStop(owner) + + verify(security, never()).lock() + } + + @Test + fun lockNowClosesAnOpenVault() { + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.lockNow() + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun lockNowBeforeOnboardingIsCommittedDoesNothing() { + `when`(security.isLockable()).thenReturn(false) + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.lockNow() + + verify(security, never()).lock() + assertFalse(manager.locked.value) + } + + @Test + fun eachPublishedUnlockStartsANewSessionGenerationAndALockDoesNot() { + `when`(security.isDatabaseUnlocked()).thenReturn(true) + val first = manager.sessionGeneration + + manager.lockNow() + assertEquals(first, manager.sessionGeneration) + assertTrue(manager.onUnlocked()) + assertEquals(first + 1, manager.sessionGeneration) + + // An unlock that lands in the background is closed again, never published. + manager.onStop(owner) + assertFalse(manager.onUnlocked()) + assertEquals(first + 1, manager.sessionGeneration) + } + + @Test + fun unlockInTheForegroundIsPublished() { + `when`(security.isDatabaseUnlocked()).thenReturn(true) + manager.onStop(owner) + manager.onStart(owner) + + assertTrue(manager.onUnlocked()) + + assertFalse(manager.locked.value) + } + + @Test + fun unlockThatFinishesAfterBackgroundingIsClosedAgain() { + // PBKDF2 still running: ON_STOP sees no open database and does nothing... + manager.onStop(owner) + verify(security, never()).lock() + + // ...then the vault opens while the app is in the background. + val published = manager.onUnlocked() + + assertFalse(published) + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun unlockRacingOnStopStaysLocked() { + // The database opened just before ON_STOP: ON_STOP closes it, and the late + // onUnlocked() must not reopen the UI. + `when`(security.isDatabaseUnlocked()).thenReturn(true) + manager.onStop(owner) + + assertFalse(manager.onUnlocked()) + + verify(security, times(2)).lock() + assertTrue(manager.locked.value) + } + + @Test + fun unlockBeforeTheProcessEverStartedIsNotPublished() { + val fresh = SessionLockManager(security) + + assertFalse(fresh.onUnlocked()) + assertTrue(fresh.locked.value) + } + + // --- system file picker (backup export/import) -------------------------------------- + + private var now = 0L + private var pendingTimer: (() -> Unit)? = null + + private fun withFakeTime() { + manager.clock = { now } + manager.timer = { _, action -> + pendingTimer = action + val cancel: () -> Unit = { pendingTimer = null } + cancel + } + } + + @Test + fun openingTheFilePickerKeepsTheVaultOpen() { + withFakeTime() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + now += 1_000 + manager.onStop(owner) + now += 30_000 + manager.onStart(owner) + + verify(security, never()).lock() + assertFalse(manager.locked.value) + } + + @Test + fun theAllowanceIsSingleUseAndShortLived() { + withFakeTime() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + now += 6_000 // the picker never opened in time + manager.onStop(owner) + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun stayingAwayFromThePickerTooLongLocksOnReturn() { + withFakeTime() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + manager.onStop(owner) + now += 6 * 60_000 + manager.onStart(owner) + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun leavingThePickerForAnotherAppLocksAfterTheTimeout() { + withFakeTime() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + manager.onStop(owner) + pendingTimer!!.invoke() + + verify(security).lock() + assertTrue(manager.locked.value) + } + + // --- picker grace: capped, monotonic, ended by screen-off / keyguard / return ------- + + private val watcher = FakeDeviceLock() + + private fun withWatcher() { + manager = SessionLockManager(security, watcher) + manager.onStart(owner) + withFakeTime() + } + + @Test + fun thePickerGraceIsCappedAtAMinute() { + withFakeTime() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + manager.onStop(owner) + now += 61_000 + manager.onStart(owner) + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun deepSleepCountsAgainstTheGraceEvenIfTheTimerNeverFired() { + // Handler timers stop in deep sleep; elapsedRealtime does not. The return re-checks. + withFakeTime() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + manager.onStop(owner) + now += 3 * 60 * 60_000L // three hours, screen off, timer never ran + manager.onStart(owner) + + verify(security).lock() + } + + @Test + fun theScreenTurningOffDuringThePickerLocksAtOnce() { + withWatcher() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + manager.onStop(owner) + watcher.screenOff() + + verify(security).lock() + assertTrue(manager.locked.value) + assertFalse("the receiver is gone once the grace ended", watcher.watching) + } + + @Test + fun aKeyguardShownMeanwhileLocksOnReturn() { + withWatcher() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + manager.onStop(owner) + now += 10_000 + watcher.locked = true + manager.onStart(owner) + + verify(security).lock() + } + + @Test + fun aQuickReturnFromThePickerKeepsTheVaultAndStopsWatching() { + withWatcher() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + manager.onStop(owner) + assertTrue(watcher.watching) + now += 10_000 + manager.onStart(owner) + + verify(security, never()).lock() + assertFalse(watcher.watching) + assertNull(pendingTimer) + } + + @Test + fun thePickerReturningEndsTheAllowance() { + withFakeTime() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.allowSystemPicker() + manager.endSystemPicker() // e.g. the picker could not open + manager.onStop(owner) // Home, within the launch window + + verify(security).lock() + } + + @Test + fun startupWithACommittedVaultStartsLocked() { + manager.requireUnlock() + + assertTrue(manager.locked.value) + assertTrue(manager.onUnlocked()) + assertFalse(manager.locked.value) + } + + @Test + fun anAbandonedUnlockIsClosedAgain() { + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + manager.onUnlockAbandoned() + + verify(security).lock() + assertTrue(manager.locked.value) + } + + // --- auto-lock delay ------------------------------------------------------------------ + + private fun withDelay(millis: Long) { + manager = SessionLockManager( + security, + watcher, + autoLock = object : AutoLockPolicy { + override val delayMillis: Long = millis + }, + screenshots = screenshots, + ) + manager.onStart(owner) + withFakeTime() + `when`(security.isDatabaseUnlocked()).thenReturn(true) + } + + @Test + fun comingBackWithinTheAutoLockDelayKeepsTheVaultOpen() { + withDelay(15_000) + + manager.onStop(owner) + now += 10_000 + manager.onStart(owner) + + verify(security, never()).lock() + assertFalse(manager.locked.value) + assertFalse(watcher.watching) + } + + @Test + fun theAutoLockDelayRunningOutLocksInTheBackground() { + withDelay(15_000) + + manager.onStop(owner) + pendingTimer!!.invoke() + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun comingBackAfterTheAutoLockDelayLocks() { + withDelay(30_000) + + manager.onStop(owner) + now += 31_000 // deep sleep: the timer never fired + manager.onStart(owner) + + verify(security).lock() + 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) + + manager.onStop(owner) + watcher.screenOff() + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun anImmediateAutoLockLocksOnLeaving() { + withDelay(0) + + manager.onStop(owner) + + verify(security).lock() + assertTrue(manager.locked.value) + } + + // --- the screenshot allowance ends with every lock ------------------------------------ + + @Test + fun leavingTheAppWithTheAllowanceOnEndsItWhenTheVaultLocks() { + withDelay(0) + screenshots.allow() + + manager.onStop(owner) + + assertFalse(screenshots.allowed.value) + } + + @Test + fun theAllowanceSurvivesAShortTripOutOfTheApp() { + withDelay(15_000) + screenshots.allow() + + manager.onStop(owner) + now += 10_000 + manager.onStart(owner) + + assertTrue(screenshots.allowed.value) + } + + @Test + fun theAutoLockTimerEndsTheAllowanceInTheBackground() { + withDelay(15_000) + screenshots.allow() + + manager.onStop(owner) + pendingTimer!!.invoke() + + assertFalse(screenshots.allowed.value) + } + + @Test + fun comingBackAfterTheDelayEndsTheAllowance() { + withDelay(30_000) + screenshots.allow() + + manager.onStop(owner) + now += 31_000 + manager.onStart(owner) + + assertFalse(screenshots.allowed.value) + } + + @Test + fun theScreenTurningOffEndsTheAllowance() { + withDelay(AutoLockDelay.MINUTES_30.millis) + screenshots.allow() + + manager.onStop(owner) + watcher.screenOff() + + assertFalse(screenshots.allowed.value) + } + + @Test + fun lockNowEndsTheAllowance() { + `when`(security.isDatabaseUnlocked()).thenReturn(true) + screenshots.allow() + + manager.lockNow() + + assertFalse(screenshots.allowed.value) + } + + @Test + fun aStartThatFindsTheVaultClosedStartsWithoutTheAllowance() { + screenshots.allow() + + manager.requireUnlock() + + assertFalse(screenshots.allowed.value) + } + + @Test + fun anUnlockThatFinishesInTheBackgroundEndsTheAllowance() { + manager.onStop(owner) + screenshots.allow() + + manager.onUnlocked() + + assertFalse(screenshots.allowed.value) + } + + @Test + fun aLockThatCameWhileTheUnlockWasRunningIsNotLostToIt() { + // The PIN is being checked: nothing is open yet, so the OS lock finds nothing to close... + val started = manager.lockRequests() + manager.lockNow() + verify(security, never()).lock() + + // ...and the unlock that finishes afterwards must not be published. + `when`(security.isDatabaseUnlocked()).thenReturn(true) + assertFalse(manager.onUnlocked(started)) + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun anUnlockWithNoLockInBetweenIsPublished() { + `when`(security.isDatabaseUnlocked()).thenReturn(true) + val started = manager.lockRequests() + + assertTrue(manager.onUnlocked(started)) + assertFalse(manager.locked.value) + } + + @Test + fun aLockRequestBeforeOnboardingIsCommittedIsNotCounted() { + `when`(security.isLockable()).thenReturn(false) + val started = manager.lockRequests() + + manager.lockNow() + + assertEquals(started, manager.lockRequests()) + } + + @Test + fun anUnlockWhileTheDeviceIsLockedIsClosedAgain() { + val deviceLock = FakeDeviceLock().apply { locked = true } + val guarded = SessionLockManager(security, deviceLock = deviceLock) + guarded.onStart(owner) + `when`(security.isDatabaseUnlocked()).thenReturn(true) + + assertFalse(guarded.onUnlocked()) + + verify(security).lock() + } + + @Test + fun theManagerReportsWhetherTheDeviceIsLockedFromTheWatcherAtTheMoment() { + val deviceLock = FakeDeviceLock() + val guarded = SessionLockManager(security, deviceLock = deviceLock) + + assertFalse(guarded.isDeviceLocked()) + deviceLock.locked = true + assertTrue(guarded.isDeviceLocked()) + deviceLock.locked = false + assertFalse(guarded.isDeviceLocked()) + } + + private class FakeDeviceLock : DeviceLockWatcher { + var locked = false + var watching = false + private var onScreenOff: (() -> Unit)? = null + + override fun isDeviceLocked(): Boolean = locked + + override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit { + watching = true + this.onScreenOff = onScreenOff + return { + watching = false + this.onScreenOff = null + } + } + + fun screenOff() { + onScreenOff?.invoke() + } + } +} diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/Utf8Test.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/Utf8Test.kt new file mode 100644 index 00000000..9d4a2170 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/Utf8Test.kt @@ -0,0 +1,44 @@ +package com.pasich.encly.core.security + +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import java.nio.CharBuffer +import kotlin.random.Random + +/** + * The PIN reaches its KDF through [Utf8.encode]. It was the JDK encoder; the hand-coded one must + * give the same bytes for every input, unpaired surrogates (which both turn into `?`) included. + */ +class Utf8Test { + private fun jdk(chars: CharArray): ByteArray { + val buffer = Charsets.UTF_8.encode(CharBuffer.wrap(chars)) + return ByteArray(buffer.remaining()).also { buffer.get(it) } + } + + private fun check(chars: CharArray) = assertArrayEquals(String(chars), jdk(chars), Utf8.encode(chars)) + + @Test + fun commonTextEncodesLikeTheJdk() { + listOf("", "1234", "pin éè", "пін", "汉字", "😀 ok", "\u0000") + .forEach { check(it.toCharArray()) } + } + + @Test + fun unpairedSurrogatesEncodeLikeTheJdk() { + listOf("\uD83D", "\uDE00", "a\uD83Db", "\uD83D😀", "\uDE00\uD83D", "\uD83D\uD83D", "x\uDE00\uDE00") + .forEach { check(it.toCharArray()) } + } + + @Test + fun randomCharactersEncodeLikeTheJdk() { + val random = Random(42) + repeat(2_000) { + check(CharArray(random.nextInt(0, 12)) { random.nextInt(0, 0x10000).toChar() }) + } + } + + @Test + fun everyBmpCharacterEncodesLikeTheJdk() { + for (code in 0..0xFFFF) check(charArrayOf(code.toChar())) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/VaultStoreFormatTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/VaultStoreFormatTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/core/security/VaultStoreFormatTest.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/VaultStoreFormatTest.kt diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/VaultStoreLegacyFormatTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/VaultStoreLegacyFormatTest.kt new file mode 100644 index 00000000..b32bea56 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/VaultStoreLegacyFormatTest.kt @@ -0,0 +1,107 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.testutil.tempVaultFile +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** + * The slot file is written and read by hand-coded big-endian and modified-UTF-8 routines. The + * format must stay the one `DataOutputStream` wrote, byte for byte: a file an earlier build wrote + * that no longer parses would read as damage, and damage locks the vault. + */ +class VaultStoreLegacyFormatTest { + private val file = tempVaultFile() + + private val keys = listOf( + "plain.key", + "café", + "nul\u0000inside", + "汉字", + "emoji😀", + "bio.slot", + "", + ) + + private fun entries() = keys.mapIndexed { index, key -> key to ByteArray(index * 3) { (it + index).toByte() } } + + /** What the build before the hand-coded codec wrote for [entries]. */ + private fun legacyFile(entries: List>): ByteArray { + val buffer = ByteArrayOutputStream() + DataOutputStream(buffer).use { out -> + out.write("ENCLYVS3".toByteArray(Charsets.US_ASCII)) + out.writeInt(entries.size) + entries.forEach { (key, value) -> + out.writeUTF(key) + out.writeInt(value.size) + out.write(value) + } + } + val body = buffer.toByteArray() + return body + MessageDigest.getInstance("SHA-256").digest(body) + } + + @Test + fun theFileIsByteIdenticalToWhatDataOutputStreamWrote() { + val entries = entries() + val store = VaultStore(file) + + assertTrue(store.edit { entries.forEach { (key, value) -> putBytes(key, value) } }) + + assertArrayEquals(legacyFile(entries), file.readBytes()) + } + + @Test + fun aFileDataOutputStreamWroteIsReadBack() { + val entries = entries() + file.writeBytes(legacyFile(entries)) + + val store = VaultStore(file) + + assertFalse(store.isCorrupt()) + assertEquals(entries.map { it.first }.toSet(), store.keys()) + entries.forEach { (key, value) -> assertArrayEquals(key, value, store.getBytes(key)) } + } + + @Test + fun typedValuesAreBigEndianLikeByteBuffer() { + VaultStore(file).edit { + putInt("i", -0x12345678) + putLong("l", -0x123456789ABCDEFL) + putBoolean("b", true) + } + + val reread = VaultStore(file) + + assertEquals(-0x12345678, reread.getInt("i", 0)) + assertEquals(-0x123456789ABCDEFL, reread.getLong("l", 0L)) + assertTrue(reread.getBoolean("b", false)) + assertArrayEquals(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(-0x12345678).array(), reread.getBytes("i")) + assertArrayEquals( + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(-0x123456789ABCDEFL).array(), + reread.getBytes("l"), + ) + } + + @Test + fun aMalformedKeyEncodingIsDamage() { + val out = ByteArrayOutputStream() + DataOutputStream(out).apply { + write("ENCLYVS3".toByteArray(Charsets.US_ASCII)) + writeInt(1) + writeShort(2) + write(byteArrayOf(0xC3.toByte(), 0x41)) // a lead byte followed by a non-continuation + writeInt(0) + } + val body = out.toByteArray() + file.writeBytes(body + MessageDigest.getInstance("SHA-256").digest(body)) + + assertTrue(VaultStore(file).isCorrupt()) + } +} diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/VaultStoreTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/VaultStoreTest.kt new file mode 100644 index 00000000..fcf2fcc5 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/VaultStoreTest.kt @@ -0,0 +1,235 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.testutil.tempVaultFile +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** The slot file: atomic replacement, damage detection, and the way out of damage. */ +class VaultStoreTest { + private val file = tempVaultFile() + + @Test + fun valuesSurviveAReload() { + val store = VaultStore(file) + assertTrue( + store.edit { + putBytes("a", byteArrayOf(1, 2, 3)) + putInt("i", 42) + putLong("l", Long.MAX_VALUE) + putBoolean("b", true) + }, + ) + + val reloaded = VaultStore(file) + + assertArrayEquals(byteArrayOf(1, 2, 3), reloaded.getBytes("a")) + assertEquals(42, reloaded.getInt("i", 0)) + assertEquals(Long.MAX_VALUE, reloaded.getLong("l", 0)) + assertTrue(reloaded.getBoolean("b", false)) + assertFalse(reloaded.isCorrupt()) + } + + @Test + fun aMissingFileIsAnEmptyStoreNotADamagedOne() { + val store = VaultStore(file) + + assertFalse(store.isCorrupt()) + assertNull(store.getBytes("a")) + assertEquals(7, store.getInt("i", 7)) + } + + @Test + fun valuesAreCopiedInAndOut() { + val store = VaultStore(file) + val value = byteArrayOf(1, 2, 3) + store.edit { putBytes("a", value) } + value.fill(0) + store.getBytes("a")!!.fill(0) + + assertArrayEquals(byteArrayOf(1, 2, 3), store.getBytes("a")) + } + + @Test + fun oneEditIsOneWrite() { + val store = VaultStore(file) + store.edit { + putInt("x", 1) + putInt("y", 1) + } + store.edit { + putInt("x", 2) + remove("y") + putInt("z", 3) + } + + val reloaded = VaultStore(file) + assertEquals(2, reloaded.getInt("x", 0)) + assertFalse(reloaded.contains("y")) + assertEquals(3, reloaded.getInt("z", 0)) + } + + @Test + fun removePrefixDropsOnlyThatGroup() { + val store = VaultStore(file) + store.edit { + putInt("pin.a", 1) + putInt("pin.b", 1) + putInt("recovery.a", 1) + } + store.edit { removePrefix("pin.") } + + assertFalse(store.contains("pin.a")) + assertFalse(store.contains("pin.b")) + assertTrue(store.contains("recovery.a")) + } + + @Test + fun aCrashBeforeTheRenameKeepsTheOldStateIntact() { + val store = VaultStore(file) + store.edit { putInt("x", 1) } + store.beforeRename = { throw CrashBeforeRename() } + + runCatching { store.edit { putInt("x", 2) } } + + // What the next process start sees: the old file, whole; the leftover temp is ignored. + val restarted = VaultStore(file) + assertFalse(restarted.isCorrupt()) + assertEquals(1, restarted.getInt("x", 0)) + assertFalse("the temp file is cleaned up", File(file.path + ".tmp").exists()) + } + + @Test + fun aHalfWrittenTempFileIsNeverRead() { + VaultStore(file).edit { putInt("x", 1) } + File(file.path + ".tmp").writeBytes(byteArrayOf(9, 9, 9)) + + val store = VaultStore(file) + + assertEquals(1, store.getInt("x", 0)) + assertFalse(store.isCorrupt()) + } + + @Test + fun aFailedWriteChangesNothing() { + val store = VaultStore(file) + store.edit { putInt("x", 1) } + val dir = file.parentFile!! + dir.setWritable(false) + try { + assertFalse(store.edit { putInt("x", 2) }) + } finally { + dir.setWritable(true) + } + + assertEquals(1, store.getInt("x", 0)) + assertEquals(1, VaultStore(file).getInt("x", 0)) + } + + @Test + fun aDamagedFileReadsAsEmptyAndRefusesWrites() { + VaultStore(file).edit { putBytes("pin.slot", ByteArray(64) { 5 }) } + val bytes = file.readBytes() + bytes[bytes.size / 2] = (bytes[bytes.size / 2].toInt() xor 0x40).toByte() + file.writeBytes(bytes) + + val store = VaultStore(file) + + assertTrue(store.isCorrupt()) + assertNull(store.getBytes("pin.slot")) + assertFalse(store.edit { putInt("x", 1) }) + assertTrue("the damaged file is kept until the user wipes", file.exists()) + } + + @Test + fun aTruncatedOrForeignFileIsDamaged() { + VaultStore(file).edit { putInt("x", 1) } + file.writeBytes(file.readBytes().copyOf(20)) + assertTrue(VaultStore(file).isCorrupt()) + + file.writeText("") + assertTrue(VaultStore(file).isCorrupt()) + } + + @Test + fun clearIsTheWayOutOfDamage() { + file.writeBytes(ByteArray(100) { 1 }) + val store = VaultStore(file) + assertTrue(store.isCorrupt()) + + assertTrue(store.clear()) + + assertFalse(store.isCorrupt()) + assertFalse(file.exists()) + assertTrue(store.edit { putInt("x", 1) }) + assertEquals(1, VaultStore(file).getInt("x", 0)) + } + + @Test + fun anIoErrorOnReadIsNotDamageAndIsTriedAgain() { + val io = FlakyReadIo() + VaultStore(io).edit { putInt("x", 1) } + io.writes = 0 + io.failReads = true + val store = VaultStore(io) + + assertTrue(store.isUnreadable()) + assertFalse("an I/O error is not damage", store.isCorrupt()) + assertEquals(0, store.getInt("x", 0)) + assertFalse("writes are refused, the file is not replaced", store.edit { putInt("x", 2) }) + assertEquals(0, io.writes) + + io.failReads = false + + assertFalse(store.isUnreadable()) + assertFalse(store.isCorrupt()) + assertEquals(1, store.getInt("x", 0)) + assertTrue(store.edit { putInt("x", 3) }) + assertEquals(3, VaultStore(io).getInt("x", 0)) + } + + @Test + fun aFileThatReadsButFailsTheDigestIsStillDamaged() { + val io = FlakyReadIo() + VaultStore(io).edit { putInt("x", 1) } + io.bytes!![io.bytes!!.size / 2] = (io.bytes!![io.bytes!!.size / 2].toInt() xor 0x40).toByte() + + val store = VaultStore(io) + + assertFalse(store.isUnreadable()) + assertTrue(store.isCorrupt()) + assertFalse(store.edit { putInt("x", 2) }) + } + + private class FlakyReadIo : VaultFileIo { + var bytes: ByteArray? = null + var failReads = false + var writes = 0 + + override fun exists(): Boolean = bytes != null + + override fun read(): ByteArray = if (failReads) { + throw java.io.IOException("sharing violation") + } else { + bytes!!.copyOf() + } + + override fun deleteTemp() = Unit + + override fun replace(bytes: ByteArray, beforeRename: (() -> Unit)?) { + writes++ + this.bytes = bytes.copyOf() + } + + override fun delete(): Boolean { + bytes = null + return true + } + } + + private class CrashBeforeRename : RuntimeException() +} diff --git a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/WipePinTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/WipePinTest.kt index c29c112a..0b5eba65 100644 --- a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/WipePinTest.kt @@ -1,10 +1,9 @@ package com.pasich.encly.core.security -import com.pasich.encly.data.backup.BackupManager -import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.core.backup.BackupFlags import com.pasich.encly.testutil.FakeLockoutClock import com.pasich.encly.testutil.FakePinFactor -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.tempVaultFile import com.pasich.encly.testutil.tempVaultStore @@ -126,7 +125,7 @@ class WipePinTest { @Test fun theWipePinErasesEverySlotAndTheOldPinIsWrongAfterwards() { val seed = SeedPhraseManager(store) - assertTrue(seed.initializeVault(seed.generateMnemonic().chars)) + assertTrue(seed.initializeVault(seed.generateMnemonic())) val oldDek = seed.copyBootstrapKey()!! assertTrue(auth.configurePin(pin(PIN), oldDek)) store.edit { @@ -406,7 +405,7 @@ class WipePinTest { verify(vault.database).wipeIfClosed() verify(vault.biometric).disable() - assertFalse(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) + assertFalse(vault.prefs.contains(BackupFlags.LAST_EXPORT_KEY)) assertFalse(vault.factor.hasKey(PinKeySlot.A)) // The empty database is created by the next unlock, with the new DEK. assertEquals(WipeStage.DATABASE, vault.auth.pendingWipe()) @@ -454,7 +453,7 @@ class WipePinTest { vault.security.completePendingWipe() assertNull(vault.auth.pendingWipe()) assertFalse(vault.factor.hasKey(PinKeySlot.A)) - assertFalse(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) + assertFalse(vault.prefs.contains(BackupFlags.LAST_EXPORT_KEY)) // Onboarding stays committed: a restart asks for the PIN of the empty vault. `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) @@ -481,7 +480,7 @@ class WipePinTest { // A new process: every file read back from disk. val store = VaultStore(file) val auth = AuthenticationManager(store, factor, clock) - val database: SecureDatabaseManager = mock(SecureDatabaseManager::class.java) + val database: VaultDatabase = mock(VaultDatabase::class.java) `when`(database.hasEncryptedDatabase()).thenReturn(true) `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenAnswer { keys += (it.arguments[0] as ByteArray).copyOf() to (it.arguments[1] as Boolean) @@ -510,7 +509,7 @@ class WipePinTest { verify(vault.database, never()).wipe() assertEquals(epoch, vault.security.eraseEpoch()) - assertTrue(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) + assertTrue(vault.prefs.contains(BackupFlags.LAST_EXPORT_KEY)) } @Test @@ -672,9 +671,9 @@ class WipePinTest { val store = this@WipePinTest.store val factor = this@WipePinTest.factor val auth = this@WipePinTest.auth - val prefs = InMemorySharedPreferences() + val prefs = InMemoryKeyValueStore() val seed = SeedPhraseManager(store) - val database: SecureDatabaseManager = mock(SecureDatabaseManager::class.java) + val database: VaultDatabase = mock(VaultDatabase::class.java) val biometric: BiometricManager = mock(BiometricManager::class.java) /** How often the My Notes hand-off's staging directory was swept. */ @@ -682,14 +681,14 @@ class WipePinTest { val security = SecurityManager(prefs, store, seed, database, auth, biometric) { sweeps++ } init { - assertTrue(seed.initializeVault(seed.generateMnemonic().chars)) + assertTrue(seed.initializeVault(seed.generateMnemonic())) val bootstrap = seed.copyBootstrapKey()!! assertTrue(auth.configurePin(pin(PIN), bootstrap)) if (withWipePin) assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) - prefs.edit() - .putBoolean("onboarding_shown_v3", true) - .putLong(BackupManager.LAST_EXPORT_KEY, 1L) - .commit() + prefs.commit { + putBoolean("onboarding_shown_v3", true) + putLong(BackupFlags.LAST_EXPORT_KEY, 1L) + } } /** The same files read by a new process. */ diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/FakePinFactor.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/FakePinFactor.kt new file mode 100644 index 00000000..6db5cfe2 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/FakePinFactor.kt @@ -0,0 +1,71 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.VaultStore +import java.io.File +import java.nio.file.Files +import java.security.SecureRandom +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * A [PinHardwareFactor] with software HMAC keys standing in for the Keystore ones, one per + * [PinKeySlot]. [lost] and [failing] simulate an invalidated key and a transient Keystore error + * (for every slot), [failingSlots] one for some slots only; [failingReset] a key that cannot be + * generated. [delete] removes a key the way the system can, so [ensureKey] then reports a new + * one. [calls] counts MACs, i.e. PIN guesses that reached the "hardware". + */ +internal class FakePinFactor : PinHardwareFactor { + private val keys = mutableMapOf() + var lost = false + var failing = false + var failingReset = false + + /** Slots whose MAC fails like a transient Keystore error, while the others still work. */ + var failingSlots: Set = emptySet() + var calls = 0 + private set + var resets = 0 + private set + + fun hasKey(slot: PinKeySlot): Boolean = slot in keys + + override fun ensureKey(slot: PinKeySlot): Boolean { + val missing = slot !in keys + if (missing) reset(slot) + return missing + } + + override fun reset(slot: PinKeySlot) { + keys.remove(slot) + if (failingReset) throw PinFactorException(lost = false) + keys[slot] = ByteArray(32).also { SecureRandom().nextBytes(it) } + lost = false + resets++ + } + + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { + if (failing || slot in failingSlots) throw PinFactorException(lost = false) + val current = keys[slot] + if (lost || current == null) throw PinFactorException(lost = true) + calls++ + return Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(current, "HmacSHA256")) + doFinal(data) + } + } + + override fun delete(slot: PinKeySlot) { + keys.remove(slot) + } +} + +/** A [VaultStore] on a fresh temp file (deleted when the JVM exits). */ +internal fun tempVaultStore(): VaultStore = VaultStore(tempVaultFile()) + +internal fun tempVaultFile(): File { + val dir = Files.createTempDirectory("vault").toFile().apply { deleteOnExit() } + return File(dir, "vault_state.bin").apply { deleteOnExit() } +} diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/MockitoKotlin.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/MockitoKotlin.kt new file mode 100644 index 00000000..7d514b6c --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/MockitoKotlin.kt @@ -0,0 +1,14 @@ +package com.pasich.encly.testutil + +import org.mockito.ArgumentMatchers + +/* + * Mockito matchers return null, which Kotlin rejects for non-null parameters. These register + * the matcher and hand Kotlin a harmless placeholder instead. + */ + +internal fun anyByteArray(): ByteArray = ArgumentMatchers.any(ByteArray::class.java) ?: ByteArray(0) + +internal fun eqValue(value: T): T = ArgumentMatchers.eq(value) ?: value + +internal fun anyCharArray(): CharArray = ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0) diff --git a/desktopApp/.gitignore b/desktopApp/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/desktopApp/.gitignore @@ -0,0 +1 @@ +/build diff --git a/desktopApp/README.md b/desktopApp/README.md new file mode 100644 index 00000000..7b4cdd38 --- /dev/null +++ b/desktopApp/README.md @@ -0,0 +1,198 @@ +# :desktopApp + +Encly for Windows, macOS and Linux: Compose Multiplatform on the JVM around `:shared`, with the same security +model as the Android app (same vault format, same PIN and recovery slots, same encrypted database). It is the +counterpart of `:app`: its own dependency graph, its own implementations of the platform interfaces, and a window. + +``` +./gradlew :desktopApp:run # run it +./gradlew :desktopApp:desktopTest # tests (headless) +./gradlew :desktopApp:packageDeb # Linux .deb (on Linux) +./gradlew :desktopApp:packageMsi # Windows .msi (on Windows, needs WiX 3) +./gradlew :desktopApp:packageDmg # macOS .dmg (on macOS) +``` + +The version is the one in `version.properties`, shared with the Android app. A package can only be built on its own +OS (`jpackage` does not cross-build); CI builds all three, and a Linux Flatpak, in the `desktop-package` job: signed when +the signing secrets exist, unsigned otherwise (see "Signing the desktop installers"). + +## Where the data is + +One private directory per OS user. It is created `rwx------` where POSIX permissions exist. + +| OS | Directory | +|---|---| +| Windows | `%LOCALAPPDATA%\Encly` (local, never the roaming profile) | +| macOS | `~/Library/Application Support/Encly` | +| Linux | `$XDG_DATA_HOME/encly`, else `~/.local/share/encly` | + +| File | What it is | +|---|---| +| `vault_state_v3.bin` | key slots and PIN lockout (every secret in it is already an AES-GCM envelope) | +| `*.db` | the encrypted vault databases (SQLCipher) | +| `encly_flags_v3.properties` | non-secret flags: onboarding done, auto-lock delay, language, window size and place | +| `my_notes_settings.preferences_pb` | appearance and editor settings | +| `cache/` | the cache; the My Notes hand-off staging in it is swept at every start | +| `.instance.lock` | keeps a second Encly of the same user from opening the same vault | + +The flags are a small file written atomically (temp file, fsync, rename), not `java.util.prefs`, which would put them +in the registry (Windows) or a plist outside this directory (macOS). + +The device-bound half of the PIN key is not in this directory: it lives in the OS secret store (Windows DPAPI, +the macOS login Keychain, the freedesktop Secret Service). See `SECURITY.md`, "Desktop". On Windows the blob of +DPAPI sits in `%LOCALAPPDATA%\Encly` too: the roaming `%APPDATA%` is copied to profile servers and to every machine +the user signs in to, which would take the vault and the PIN's device secret off this device. Windows has no POSIX +modes; the folder keeps the ACL it inherits from `%LOCALAPPDATA%` (the user, SYSTEM and Administrators), which is the +same boundary DPAPI gives the secret. Encly does not rewrite ACLs. + +## Security parity with Android + +| Android | Desktop | Not available | +|---|---|---| +| `FLAG_SECURE` from the first frame | the window is excluded from screenshots and recordings from the first frame and again whenever the theme asserts it: Windows `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` (Windows 10 2004+; older: `WDA_MONITOR`, a black window), macOS `NSWindow.sharingType = none` | Linux: X11 and Wayland have no such call. macOS: best effort, newer ScreenCaptureKit tools can ignore the flag on macOS 15 and later | +| lock when the app goes to the background, after the auto-lock delay | the same, when the window loses the focus or is minimised (its own file dialogs do not count) | | +| lock at once when the screen turns off | lock at once when the OS locks the session: macOS (the window server's session state, polled every 1.5 s), Windows (the input desktop, polled), Linux (logind `Lock` and `PrepareForSleep`, and the screen-saver `ActiveChanged` of GNOME, KDE, Cinnamon, MATE over D-Bus). A sleep without a session lock locks the vault too: on macOS and Windows it is seen when the machine wakes (the clock that counts sleep runs ahead of the one that does not), at the next 1.5 s poll | | +| (none) | idle lock: no keyboard or mouse input for the auto-lock delay, but at least 5 minutes, locks a window that stays in front; the time is measured on the OS uptime, so time asleep counts | | +| clipboard marked sensitive, cleared after a minute | cleared after a minute unless something else was copied since, also for what the editor copies, and on quit. Windows: the text is written with `ExcludeClipboardContentFromMonitorProcessing`, `CanIncludeInClipboardHistory` = 0 and `CanUploadToCloudClipboard` = 0 (no Win+V history, no cloud sync); macOS: with `org.nspasteboard.ConcealedType` and `TransientType` (clipboard managers skip it) | Linux: no marker exists in AWT; a clipboard manager may keep a copy | +| strict keyboard privacy | not offered (a desktop has no on-screen keyboard that learns) | | +| PIN lockout on `elapsedRealtime` and boot count | the OS uptime (including sleep) and the boot id (Linux `boot_id`, macOS `kern.bootsessionuuid`), so restarting the app does not shorten a lockout; Windows has no boot id, the uptime comparison covers it | | +| biometrics | none yet | Windows Hello, Touch ID and fprintd are not wired in | + +A second Encly of the same user exits at start (a file lock in the data directory). A backup file given to it is not +handed to the first one; drop it on the open window. + +## The window + +- minimum size 720 x 520; size, place and maximised state are remembered and clamped to the connected screens, for each window slot (the first window, the second, ...) on its own; +- menu bar (the native one on macOS): File (new note, new window, export as PDF, print, import, backup, lock now, + settings, exit), View (theme, language), Window, Help (about, licences, FAQ). The shortcuts use Cmd on macOS and + Ctrl elsewhere; new window, print and PDF (Cmd/Ctrl+Shift+N, +P, +Shift+P) are menu chords like import and backup. There is no Edit + menu on purpose: the text fields handle cut, copy, paste and undo themselves, and a menu item with the same + shortcut would take the key from them on macOS. The menu titles "File", "View", "Window", "Help", "Exit" and + "Minimize" are English for now; the items use the app's translated strings; +- menu items ask the UI to open a screen through `NavigationRequests` (`:shared`, `platform`), which `EnclyApp` + collects. They are ignored while the vault is locked, so no item skips the lock screen; +- several windows ("New window", and "Open in new window" on a note's right-click menu): all show the one vault + behind the one `SessionLockManager`, so a lock (idle, Lock now, the OS) locks every window and an unlock on any lock + screen opens the others (`LockScreen` follows it, switched on by `LocalSessionSharedAcrossWindows`). Each window has + its own `EnclyApp`, `AppCommandRouter`, navigation, `NavigationRequests`, drop router and file dialogs + (`WindowServices`); the graph, the lock, the idle timer and the OS hooks (About, Preferences, open file) are the + process's. Each window has its own menu bar and its own commands, so on a Mac, where one menu bar shows the active + window's menu, an item acts on the window in front. The app counts as in front while any window is active and not + every one is minimised, and focus moving between its own windows is not leaving (`WindowForeground`), so it does not + start the auto-lock. Closing a window saves its editors; closing the last one (or Quit) waits for the last saves, + locks and closes the database, and ends the app. A further window is refused until the vault exists (onboarding); +- a note has one editor at a time across the windows (`NoteEditRegistry`): the editor saves the whole note, so a second + editor would overwrite the first one's work. The window that opens a note another window is editing shows it + read-only, with a notice, and writes nothing from it; +- export as PDF and print (File menu, the note's right-click menu and the editor's More sheet) are plaintext exports: + they go through the Markdown export's warning and PIN (the print warning also names the system print spooler, which + may keep temporary copies), then the save dialog or the system print dialog. The PDF is written with Apache PDFBox, + built in memory with the app's Source Sans 3 (Latin and Cyrillic: all nine languages) embedded, and written to the + file the user picked and nowhere else; it is not touched on the way by the network or a temp file; +- files dropped on the window, or a backup opened from the file manager, go to the same flows as the file dialogs: + a `.enclybak` answers the restore or import-backup picker, notes (`.md`, `.zip`, `.txt`, `.json`, `.jex`, `.html`) + and folders answer the import screen's pickers; with none open, the matching screen is opened first; +- file dialogs are the OS's: AWT's native dialogs on macOS and Windows, the freedesktop portal on Linux (the desktop's + own dialog, with the AWT dialog as fallback); +- the theme follows the OS light and dark mode; a language change recomposes the app (it returns to the home screen); +- the app icon is the brand mark. + +## Packaging + +`compose.desktop.application.nativeDistributions` in `build.gradle.kts`: MSI, DMG and DEB, package name `Encly`, +bundle id `com.pasich.encly`, a fixed Windows upgrade UUID, per-user MSI install, and the `.enclybak` file +association with its own type `application/vnd.encly.backup` (never `application/octet-stream`). + +### Signing the desktop installers + +The `desktop-package` job (`.github/workflows/android.yml`) signs the installers when the repository has the +secrets below, and builds them unsigned when it has none. No certificate, password or identity is in the repository; +the secrets are read from GitHub Actions secrets only in the steps that need them. + +| Secret | Value | +|---|---| +| `ENCLY_MACOS_CERT_P12_BASE64` | the "Developer ID Application" certificate with its private key, exported as `.p12` and base64-encoded (`base64 -i cert.p12`) | +| `ENCLY_MACOS_CERT_PASSWORD` | the password of that `.p12` | +| `ENCLY_MACOS_SIGN_IDENTITY` | the identity's full name, as `security find-identity -v -p codesigning` prints it: `Developer ID Application: Name (TEAMID)` | +| `ENCLY_MACOS_NOTARY_APPLE_ID` | the Apple ID that submits to the notary service | +| `ENCLY_MACOS_NOTARY_PASSWORD` | an app-specific password of that Apple ID (appleid.apple.com), not the account password | +| `ENCLY_MACOS_NOTARY_TEAM_ID` | the 10-character Apple team ID | +| `ENCLY_WINDOWS_CERT_PFX_BASE64` | a code-signing certificate with its private key as `.pfx`, base64-encoded | +| `ENCLY_WINDOWS_CERT_PASSWORD` | the password of that `.pfx` | + +Optional repository variable (not a secret): `ENCLY_WINDOWS_TIMESTAMP_URL`, an RFC 3161 timestamp server; +the default is `http://timestamp.digicert.com`. + +How a run decides: + +- **Per OS, and per group.** macOS has two groups: the certificate (`ENCLY_MACOS_CERT_*`, `ENCLY_MACOS_SIGN_IDENTITY`) + and the notarization (`ENCLY_MACOS_NOTARY_*`). No secrets of an OS: that installer is built unsigned, with a notice. + Some secrets of a group set and others not, or notarization without the certificate: the job fails. A half-signed + release is never built silently. +- **Pull requests never sign**, so the code of a pull request does not run next to a certificate. Pushes to `main` + and manual runs (`workflow_dispatch`) do. +- **Artifact names.** A signed installer is uploaded as `encly-desktop-`, an unsigned one as + `encly-desktop--unsigned`. The Linux packages (`.deb`, Flatpak) are never code-signed here and always say + `-unsigned`. + +macOS. The certificate is imported into a temporary keychain with a random, masked password, which the job deletes +at its end (also when it fails). Gradle (`nativeDistributions.macOS` in `build.gradle.kts`, switched on by +`ENCLY_MACOS_SIGN_IDENTITY` and `ENCLY_MACOS_KEYCHAIN`) signs the app image with the hardened runtime and the +entitlements of `packaging/macos/entitlements.plist` (`allow-jit`, `allow-unsigned-executable-memory` and +`disable-library-validation`: the JVM generates code, and loads native libraries it extracts at run time). Then +`notarizeDmg` submits the DMG with `notarytool`, waits for the verdict, and staples the ticket. The job then mounts +the DMG and checks the signature with `codesign --verify`, and the stapled ticket and Gatekeeper's verdict +(`stapler validate`, `spctl`) when it was notarized. + +Windows. `signtool` (from the Windows SDK on the runner) signs the MSI with the PFX, SHA-256 and a timestamp, and +`signtool verify /pa` checks it. Only the MSI is signed, not the executables inside it. To sign with Azure Trusted +Signing instead of a PFX, replace the "Sign the MSI (Windows)" step with the +Azure sign action and an OIDC login; the rest of the job does not change. + +To sign a build on your own Mac, set the same variables for Gradle: +`ENCLY_MACOS_SIGN_IDENTITY` (and `ENCLY_MACOS_KEYCHAIN` for a keychain other than the login one), and, for +notarization, `ENCLY_MACOS_NOTARY_APPLE_ID`, `ENCLY_MACOS_NOTARY_PASSWORD`, `ENCLY_MACOS_NOTARY_TEAM_ID`; then run +`./gradlew :desktopApp:packageDmg :desktopApp:notarizeDmg`. + +### Flatpak (Linux) + +`packaging/flatpak/` holds the manifest `io.github.pasichdev.Encly.yml`, the desktop entry, the AppStream metainfo, +the MIME type of `.enclybak` and `build.sh`, which stages the jpackage app image +(`:desktopApp:createDistributable`: the same launcher, JVM arguments and bundled Java runtime as the `.deb`), checks +the metainfo with `appstreamcli validate` and the desktop entry with `desktop-file-validate`, runs `flatpak-builder` +against the freedesktop `Platform`/`Sdk` 25.08 and writes `build/flatpak/encly--.flatpak`. CI builds +it on Ubuntu and uploads it as `encly-desktop-flatpak-unsigned`. Locally (on Linux with `flatpak` and +`flatpak-builder`): `desktopApp/packaging/flatpak/build.sh`, then +`flatpak install --user build/flatpak/encly-*.flatpak` and `flatpak run io.github.pasichdev.Encly`. + +The sandbox permissions are the minimum the app needs, and there is no network: + +| Permission | Why | +|---|---| +| (none for network) | the app makes no network connection | +| (none for files) | files are chosen with the FileChooser portal and arrive through the document portal; there is no `--filesystem` | +| `--socket=x11`, `--share=ipc` | the bundled Java runtime draws through X11 only (XWayland on a Wayland session) | +| `--device=dri` | OpenGL rendering of Compose (Skiko) | +| `--talk-name=org.freedesktop.secrets` | the device-bound half of the PIN key lives in the Secret Service | +| `--talk-name=org.freedesktop.ScreenSaver`, `org.gnome.ScreenSaver`, `org.cinnamon.ScreenSaver`, `org.mate.ScreenSaver` | lock when the desktop's screen saver locks the session | +| `--system-talk-name=org.freedesktop.login1` | lock on logind's `Lock` and on suspend | + +The Flatpak keeps its data in `~/.var/app/io.github.pasichdev.Encly/data/encly`, not in `~/.local/share/encly`: a vault +of a `.deb` or tarball install is a different vault there. The bundle is not signed and is not on Flathub; a Flathub +submission needs a build from source (Gradle offline with pinned dependencies) and screenshots in the metainfo. + +## No network + +Like the Android app, the desktop app makes no network connection and has no update check; updates come from the +installer, a store or a package manager. Nothing in the module opens a socket: the only IPC is D-Bus over a local Unix +socket on Linux (the Secret Service, logind, the file chooser portal), and the OS calls are JNA calls into system +libraries. Opening a link hands it to the default browser (only `https`, `http` and `mailto`). + +## Tests and coverage + +`desktopTest` runs headless: the graph builds with a temporary directory and fakes in place of the OS, a Compose UI test +reaches the onboarding screen, and the platform services are tested with fakes (clipboard wipe timing on a manual +timer, atomic key-value writes, window state, drop routing, lifecycle and idle lock). Kover gates line coverage at 85 %. +Excluded: `Main` (the entry point), `nativeos` (JNA and D-Bus glue that needs the real OS) and `window.WindowHost` (needs +a real AWT window). diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 00000000..23515cc6 --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,210 @@ +import dev.zacsweers.metro.gradle.OptionalBindingBehavior +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import java.util.Properties + +// The desktop application (Windows, macOS, Linux): Compose Multiplatform on the JVM around :shared, with the +// desktop implementations of everything :app binds on Android (the Metro graph, the platform services, the OS +// secret store behind the PIN, the window, the menus). No network code: see README.md. +plugins { + id("encly.kmp-desktop-app") + id("encly.quality") + alias(libs.plugins.metro) +} + +// Same version file as the Android app, so one bump releases both. +val versionProps = Properties().apply { + rootProject.file("version.properties").inputStream().use { load(it) } +} +val appVersionName = listOf("VERSION_MAJOR", "VERSION_MINOR", "VERSION_PATCH") + .joinToString(".") { versionProps.getProperty(it).trim() } + +// Minimum line coverage (%) of the module, enforced by koverVerify. The exclusions are listed below. +val desktopMinCoverage = 85 + +// The same as in :app. A Kotlin default on an injected constructor parameter stays a plain default: the +// parameter is always taken from the graph, and a missing binding is an error instead of silently falling +// back to the default (e.g. a lock event source that never locks). +metro { + optionalBindingBehavior.set(OptionalBindingBehavior.DISABLED) +} + +detekt { + config.setFrom(rootProject.files("config/detekt/detekt.yml"), files("config/detekt.yml")) +} + +// The version the app shows in About: a classpath resource written from version.properties. +val buildInfoDir = layout.buildDirectory.dir("generated/buildinfo") +val writeBuildInfo = tasks.register("writeDesktopBuildInfo") { + val dir = buildInfoDir + val version = appVersionName + inputs.property("version", version) + outputs.dir(dir) + doLast { + val target = dir.get().asFile + target.mkdirs() + target.resolve("encly-build.properties").writeText("version=$version\n") + } +} + +kotlin { + compilerOptions { + optIn.addAll( + "androidx.compose.ui.ExperimentalComposeUiApi", + "androidx.compose.foundation.ExperimentalFoundationApi", + "androidx.compose.material3.ExperimentalMaterial3Api", + ) + } + + sourceSets { + getByName("desktopMain") { + resources.srcDir(writeBuildInfo) + dependencies { + implementation(project(":shared")) + implementation(project(":core:keystore")) + implementation(compose.desktop.currentOs) + // Dispatchers.Main is the AWT event thread: viewModelScope and the lifecycle collectors need it. + implementation(libs.kotlinx.coroutines.swing) + // The native calls: OS lock state, capture exclusion, uptime. Ships with :core:keystore already. + implementation(libs.jna) + implementation(libs.jna.platform) + // The freedesktop portal and logind over the local D-Bus socket (AF_UNIX, no network). + implementation(libs.dbus.java.core) + implementation(libs.dbus.java.transport) + runtimeOnly(libs.slf4j.nop) + // Note to PDF and to the printer, built in memory (Apache-2.0). + implementation(libs.pdfbox) + // Arabic shaping and bidi reordering of the PDF text, which PDFBox does not do. + implementation(libs.icu4j) + } + } + getByName("desktopTest").dependencies { + implementation(kotlin("test")) + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.compose.ui.test) + } + } +} + +compose.desktop { + application { + mainClass = "com.pasich.encly.desktop.MainKt" + + // No --add-opens is needed: the macOS window is reached through the Objective-C runtime, not through + // AWT's private peer classes. Nothing here opens a network connection. + jvmArgs += listOf("-Dfile.encoding=UTF-8", "-Dapple.awt.application.name=Encly") + + nativeDistributions { + targetFormats(TargetFormat.Msi, TargetFormat.Dmg, TargetFormat.Deb) + packageName = "Encly" + packageVersion = appVersionName + description = "Offline encrypted notes and tasks" + vendor = "Encly" + copyright = "Copyright 2026 Encly contributors. Apache-2.0." + licenseFile.set(rootProject.file("LICENSE")) + + // The licence texts and notices of the libraries only the desktop app contains (PDFBox, JNA, dbus-java, + // ...): `common/licenses/` goes into the app's resources folder of every installer. + appResourcesRootDir.set(layout.projectDirectory.dir("packaging/resources")) + + // The JDK modules the app needs beyond the defaults; `suggestRuntimeModules` lists what is missing. + // java.desktop (AWT, Swing), java.prefs and java.naming come with the defaults of Compose. + modules( + "java.instrument", + "jdk.unsupported", + "java.management", + "java.sql", + "java.logging", + "jdk.security.auth", + ) + + // Encly backup files open Encly (a double click starts the restore flow). The type is Encly's own, + // never application/octet-stream, which would claim every binary file. + fileAssociation( + mimeType = "application/vnd.encly.backup", + extension = "enclybak", + description = "Encly backup", + linuxIconFile = file("packaging/icons/icon.png"), + windowsIconFile = file("packaging/icons/icon.ico"), + macOSIconFile = file("packaging/icons/icon.icns"), + ) + + windows { + iconFile.set(file("packaging/icons/icon.ico")) + menuGroup = "Encly" + menu = true + shortcut = true + perUserInstall = true + // Fixed, so a new version upgrades the installed one instead of installing beside it. + upgradeUuid = "6f1d0c52-3b0e-4b46-9a7e-2f5d7c9b4e18" + } + macOS { + iconFile.set(file("packaging/icons/icon.icns")) + bundleID = "com.pasich.encly" + dockName = "Encly" + // Hardened-runtime exceptions of the JVM, for the app and for its bundled runtime. + entitlementsFile.set(file("packaging/macos/entitlements.plist")) + runtimeEntitlementsFile.set(file("packaging/macos/entitlements.plist")) + // Signing and notarization are off unless the environment sets these (the CI does it from + // secrets; see README.md, "Signing the desktop installers"). The identity is the full name, e.g. + // "Developer ID Application: Name (TEAMID)". Without ENCLY_MACOS_KEYCHAIN the login keychain is used. + val identity = providers.environmentVariable("ENCLY_MACOS_SIGN_IDENTITY").orNull + if (!identity.isNullOrBlank()) { + signing { + sign.set(true) + this.identity.set(identity) + providers.environmentVariable("ENCLY_MACOS_KEYCHAIN").orNull + ?.takeIf { it.isNotBlank() } + ?.let { keychain.set(it) } + } + notarization { + appleID.set(providers.environmentVariable("ENCLY_MACOS_NOTARY_APPLE_ID")) + password.set(providers.environmentVariable("ENCLY_MACOS_NOTARY_PASSWORD")) + teamID.set(providers.environmentVariable("ENCLY_MACOS_NOTARY_TEAM_ID")) + } + } + } + linux { + iconFile.set(file("packaging/icons/icon.png")) + packageName = "encly" + debMaintainer = "pasichdev@users.noreply.github.com" + menuGroup = "Office" + appCategory = "Office" + } + } + } +} + +// Kover: line coverage of the module over the desktop tests. Excluded are only the entry point and the code +// that talks to the operating system and cannot run on a headless CI machine: the JNA and D-Bus glue +// (`nativeos`), the portal dialogs, and the `Window` host (needs a real AWT window). +kover { + reports { + total { + filters { + excludes { + classes( + "*ComposableSingletons*", + "*MetroFactory*", + "*MetroContributionTo*", + "*MetroMembersInjector*", + "com.pasich.encly.desktop.MainKt*", + "com.pasich.encly.desktop.nativeos.*", + "com.pasich.encly.desktop.window.WindowHost*", + ) + } + } + xml { onCheck.set(false) } + html { onCheck.set(false) } + verify { + rule("Desktop line coverage") { + bound { + minValue = desktopMinCoverage + coverageUnits = CoverageUnit.LINE + } + } + } + } + } +} diff --git a/desktopApp/config/detekt.yml b/desktopApp/config/detekt.yml new file mode 100644 index 00000000..3c47f0e5 --- /dev/null +++ b/desktopApp/config/detekt.yml @@ -0,0 +1,29 @@ +# Desktop-module detekt rules, layered on config/detekt/detekt.yml. The tests are scenarios, not production +# code, so the rules that police how production code spends time or throws do not apply to them, and their +# names are sentences in backticks. +naming: + FunctionNaming: + excludes: &tests + - '**/desktopTest/**' + +complexity: + excludes: *tests + TooManyFunctions: + # The dependency binding lists, the commands interface and the key-value store are many small members by + # design (one binding or one command each). + thresholdInClasses: 15 + thresholdInInterfaces: 15 + thresholdInObjects: 15 + LongParameterList: + # DesktopEnvironment lists everything the OS provides; it is the one place where tests swap it. + constructorThreshold: 12 + ignoreDefaultParameters: true + +style: + excludes: *tests + +performance: + excludes: *tests + +exceptions: + excludes: *tests diff --git a/desktopApp/packaging/flatpak/build.sh b/desktopApp/packaging/flatpak/build.sh new file mode 100755 index 00000000..d686ac1e --- /dev/null +++ b/desktopApp/packaging/flatpak/build.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Builds the Encly Flatpak bundle (desktopApp/build/flatpak/encly--.flatpak) on Linux. +# +# desktopApp/packaging/flatpak/build.sh +# +# Needs: flatpak, flatpak-builder, and the freedesktop Platform and Sdk 25.08 (flatpak-builder installs them +# from Flathub with --install-deps-from). For the checks below: appstreamcli (package `appstream`) and +# desktop-file-validate (package `desktop-file-utils`). Environment: +# ENCLY_FLATPAK_SKIP_GRADLE=1 the app image is already built (CI runs Gradle in its own step) +# ENCLY_FLATPAK_REQUIRE_LINT=1 a missing validator is an error, not a skipped check (CI) +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../../.." && pwd)" +app_id="io.github.pasichdev.Encly" +out="$root/desktopApp/build/flatpak" +image_parent="$root/desktopApp/build/compose/binaries/main/app" + +version="$(sed -n 's/^VERSION_MAJOR=//p' "$root/version.properties" | tr -d '[:space:]').$(sed -n 's/^VERSION_MINOR=//p' "$root/version.properties" | tr -d '[:space:]').$(sed -n 's/^VERSION_PATCH=//p' "$root/version.properties" | tr -d '[:space:]')" +arch="$(flatpak --default-arch)" + +# 1. The metainfo must describe the version being built. +if ! grep -q " (version.properties) in the metainfo" + exit 1 +fi + +# 2. Metadata checks, before the long build. +need() { + if command -v "$1" > /dev/null; then return 0; fi + if [ "${ENCLY_FLATPAK_REQUIRE_LINT:-0}" = 1 ]; then + echo "::error::$1 is required" + exit 1 + fi + echo "warning: $1 not found, skipping its check" >&2 + return 1 +} +if need appstreamcli; then appstreamcli validate --no-net "$here/$app_id.metainfo.xml"; fi +if need desktop-file-validate; then desktop-file-validate "$here/$app_id.desktop"; fi +if need xmllint; then xmllint --noout "$here/$app_id.mime.xml"; fi + +# 3. The app image (jpackage app image with its own Java runtime). +if [ "${ENCLY_FLATPAK_SKIP_GRADLE:-0}" != 1 ]; then + (cd "$root" && ./gradlew :desktopApp:createDistributable) +fi +mapfile -t images < <(find "$image_parent" -mindepth 1 -maxdepth 1 -type d) +if [ "${#images[@]}" -ne 1 ]; then + echo "::error::expected exactly one app image in $image_parent, found ${#images[@]}" + exit 1 +fi +mapfile -t launchers < <(find "${images[0]}/bin" -maxdepth 1 -type f -perm -u+x) +if [ "${#launchers[@]}" -ne 1 ]; then + echo "::error::expected exactly one launcher in ${images[0]}/bin, found ${#launchers[@]}" + exit 1 +fi + +# 4. Stage: the image as it is, and the launcher the manifest installs as /app/bin/encly. +rm -rf "$out/app" "$out/encly" +mkdir -p "$out" +cp -a "${images[0]}" "$out/app" +printf '#!/bin/sh\nexec /app/encly/bin/%s "$@"\n' "$(basename "${launchers[0]}")" > "$out/encly" +chmod 755 "$out/encly" + +# 5. Build and bundle. The bundle names the runtime repository, so installing it fetches the runtime. +flatpak-builder --user --force-clean --disable-rofiles-fuse \ + --install-deps-from=flathub \ + --state-dir="$out/state" --repo="$out/repo" "$out/build" "$here/$app_id.yml" +flatpak build-bundle --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo \ + "$out/repo" "$out/encly-$version-$arch.flatpak" "$app_id" +echo "Built $out/encly-$version-$arch.flatpak" diff --git a/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.desktop b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.desktop new file mode 100644 index 00000000..58728c88 --- /dev/null +++ b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Encly +Comment=Offline encrypted notes and tasks +Exec=encly %f +Icon=io.github.pasichdev.Encly +Terminal=false +Categories=Office; +Keywords=notes;tasks;encrypted;private;vault; +MimeType=application/vnd.encly.backup; +StartupNotify=true +StartupWMClass=com-pasich-encly-desktop-MainKt diff --git a/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml new file mode 100644 index 00000000..1b12efe9 --- /dev/null +++ b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml @@ -0,0 +1,47 @@ + + + io.github.pasichdev.Encly + Encly + Offline encrypted notes and tasks + + Encly contributors + + CC0-1.0 + Apache-2.0 + io.github.pasichdev.Encly.desktop + +

Encly is a private notes and tasks app with no account, no cloud, no ads and no network access. Your notes stay in one encrypted vault on this computer.

+
    +
  • Encrypted at rest: a SQLCipher (AES-256) database under a random key that only your PIN or your 12-word recovery phrase can unlock.
  • +
  • The device-bound half of the PIN key lives in your desktop's secret service (GNOME Keyring, KWallet).
  • +
  • Locks itself when the window loses focus, after you have been idle, and when the session locks or the computer sleeps.
  • +
  • A block editor with headings, quotes, checklists, lists and links, tags, search and tasks with priorities.
  • +
  • Encrypted backups to a single file that you save wherever you choose; Encly never uploads anything.
  • +
+
+ + Office + + + notes + tasks + encrypted + private + vault + + + application/vnd.encly.backup + + https://github.com/pasichDev/Encly + https://github.com/pasichDev/Encly/issues + https://github.com/pasichDev/Encly + + + pointing + keyboard + + + + + +
diff --git a/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.mime.xml b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.mime.xml new file mode 100644 index 00000000..d92024d2 --- /dev/null +++ b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.mime.xml @@ -0,0 +1,8 @@ + + + + + Encly backup + + + diff --git a/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.yml b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.yml new file mode 100644 index 00000000..08f87439 --- /dev/null +++ b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.yml @@ -0,0 +1,59 @@ +# Flatpak manifest for the Encly desktop app. Built by build.sh in this directory (and by the `desktop-package` +# job in .github/workflows/android.yml), not by hand: it expects the staged app image in ../../build/flatpak. +# +# The app is the jpackage app image that `:desktopApp:createDistributable` produces: the same launcher, JVM +# arguments, module list and bundled Java runtime as the .deb. The Platform runtime supplies the C libraries +# (X11, fontconfig, freetype, GL); the SDK is needed only by flatpak-builder itself. +app-id: io.github.pasichdev.Encly +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +command: encly + +finish-args: + # No --share=network: the app makes no network connection (PRIVACY.md, desktopApp/README.md). + # No --filesystem: files are picked through the FileChooser portal (the app already uses it on Linux) and + # reach the sandbox through the document portal, also the ones a file manager opens with %f. + # + # Display. The bundled Java runtime draws through X11 only (AWT has no Wayland backend), so on a Wayland + # session it runs through XWayland. `fallback-x11` would withhold X11 there and the app would not start. + - --socket=x11 + - --share=ipc + # Skiko (Compose's renderer) draws with OpenGL. + - --device=dri + # The device-bound half of the PIN key, in the Secret Service (:core:keystore). + - --talk-name=org.freedesktop.secrets + # Lock when the session locks: the screen savers' ActiveChanged signal on the session bus ... + - --talk-name=org.freedesktop.ScreenSaver + - --talk-name=org.gnome.ScreenSaver + - --talk-name=org.cinnamon.ScreenSaver + - --talk-name=org.mate.ScreenSaver + # ... and logind's Lock, Unlock and PrepareForSleep on the system bus. Without these the app still runs but + # never locks on a locked session or on suspend. + - --system-talk-name=org.freedesktop.login1 + +modules: + - name: encly + buildsystem: simple + build-commands: + - cp -a app /app/encly + - install -Dm755 encly /app/bin/encly + - install -Dm644 io.github.pasichdev.Encly.desktop /app/share/applications/io.github.pasichdev.Encly.desktop + - install -Dm644 io.github.pasichdev.Encly.metainfo.xml /app/share/metainfo/io.github.pasichdev.Encly.metainfo.xml + - install -Dm644 io.github.pasichdev.Encly.mime.xml /app/share/mime/packages/io.github.pasichdev.Encly.xml + - install -Dm644 icon.png /app/share/icons/hicolor/512x512/apps/io.github.pasichdev.Encly.png + sources: + # Staged by build.sh: the app image, and a launcher that execs its bin/. + - type: dir + path: ../../build/flatpak/app + dest: app + - type: file + path: ../../build/flatpak/encly + - type: file + path: io.github.pasichdev.Encly.desktop + - type: file + path: io.github.pasichdev.Encly.metainfo.xml + - type: file + path: io.github.pasichdev.Encly.mime.xml + - type: file + path: ../icons/icon.png diff --git a/desktopApp/packaging/icons/icon.icns b/desktopApp/packaging/icons/icon.icns new file mode 100644 index 00000000..1804d6f9 Binary files /dev/null and b/desktopApp/packaging/icons/icon.icns differ diff --git a/desktopApp/packaging/icons/icon.ico b/desktopApp/packaging/icons/icon.ico new file mode 100644 index 00000000..f3a96bf2 Binary files /dev/null and b/desktopApp/packaging/icons/icon.ico differ diff --git a/desktopApp/packaging/icons/icon.png b/desktopApp/packaging/icons/icon.png new file mode 100644 index 00000000..4a8051b1 Binary files /dev/null and b/desktopApp/packaging/icons/icon.png differ diff --git a/desktopApp/packaging/macos/entitlements.plist b/desktopApp/packaging/macos/entitlements.plist new file mode 100644 index 00000000..09404ef1 --- /dev/null +++ b/desktopApp/packaging/macos/entitlements.plist @@ -0,0 +1,19 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/desktopApp/packaging/resources/common/licenses/NotoSansArabic-OFL.txt b/desktopApp/packaging/resources/common/licenses/NotoSansArabic-OFL.txt new file mode 100644 index 00000000..d4e17055 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/NotoSansArabic-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktopApp/packaging/resources/common/licenses/THIRD-PARTY-NOTICES.txt b/desktopApp/packaging/resources/common/licenses/THIRD-PARTY-NOTICES.txt new file mode 100644 index 00000000..710153e0 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,48 @@ +Encly for the desktop: third-party software +=========================================== + +Encly itself is licensed under the Apache License 2.0 (the LICENSE file of the installer). The desktop app also +contains the following software, which is used under its own licence. The texts and notices are in this folder. +The libraries and fonts that every Encly build contains (Kotlin, Compose Multiplatform, Okio, SQLCipher, ...) are +listed in the app under About -> Licenses. + +Apache PDFBox, FontBox and PDFBox IO 3.0.5 Apache License 2.0 + Builds the PDF of a note and sends it to the printer. https://pdfbox.apache.org/ + pdfbox-LICENSE.txt, pdfbox-NOTICE.txt + +ICU for Java (ICU4J) 78.3 Unicode License v3 + Joins Arabic letters and orders mixed left-to-right and https://icu.unicode.org/ + right-to-left text in the PDF of a note. + icu4j-LICENSE.txt + +Noto Sans Arabic SIL Open Font License 1.1 + The Arabic letters in the PDF of a note (embedded in it). https://github.com/notofonts/arabic + NotoSansArabic-OFL.txt + +Apache Commons Logging 1.3.5 Apache License 2.0 + Used by PDFBox. https://commons.apache.org/proper/commons-logging/ + commons-logging-LICENSE.txt, commons-logging-NOTICE.txt + +Java Native Access (JNA) and JNA Platform 5.19.1 Apache License 2.0 (also available under LGPL 2.1 or later) + Calls the operating system: the secret store, the screen lock, https://github.com/java-native-access/jna + the clipboard, window capture protection. Encly uses it under the Apache License 2.0. + jna-LICENSE.txt, jna-AL2.0.txt, jna-LGPL2.1.txt + +dbus-java 5.1.1 (core and the native Unix socket transport) MIT License + Talks to the Secret Service, logind and the file chooser portal https://github.com/hypfvieh/dbus-java + on Linux over the local D-Bus socket. + dbus-java-LICENSE.txt + +SLF4J 2.0.17 (API and the no-op binding) MIT License + The logging facade of dbus-java; Encly binds it to nothing. https://www.slf4j.org/ + slf4j-LICENSE.txt + +Skiko (the Skia bindings under Compose Multiplatform) Apache License 2.0 + Draws the window. https://github.com/JetBrains/skiko + +JetBrains Runtime API 1.9.0, JSpecify 1.0.0 Apache License 2.0 + Window and annotation support of Compose Desktop. The text of the licence is jna-AL2.0.txt. https://jspecify.dev/ + +OpenJDK runtime (bundled by the installer) GNU General Public License v2 with the Classpath Exception + The Java runtime the app runs on. Its legal notices are in the runtime's own "legal" folder. + https://openjdk.org/legal/gplv2+ce.html diff --git a/desktopApp/packaging/resources/common/licenses/commons-logging-LICENSE.txt b/desktopApp/packaging/resources/common/licenses/commons-logging-LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/commons-logging-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/desktopApp/packaging/resources/common/licenses/commons-logging-NOTICE.txt b/desktopApp/packaging/resources/common/licenses/commons-logging-NOTICE.txt new file mode 100644 index 00000000..d6815786 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/commons-logging-NOTICE.txt @@ -0,0 +1,5 @@ +Apache Commons Logging +Copyright 2001-2025 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (https://www.apache.org/). diff --git a/desktopApp/packaging/resources/common/licenses/dbus-java-LICENSE.txt b/desktopApp/packaging/resources/common/licenses/dbus-java-LICENSE.txt new file mode 100644 index 00000000..e067fd5a --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/dbus-java-LICENSE.txt @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2005-2006 Matthew Johnson +Copyright (c) 2017-2021 David M. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/desktopApp/packaging/resources/common/licenses/icu4j-LICENSE.txt b/desktopApp/packaging/resources/common/licenses/icu4j-LICENSE.txt new file mode 100644 index 00000000..fec99457 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/icu4j-LICENSE.txt @@ -0,0 +1,568 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2026 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +---------------------------------------------------------------------- + +Third-Party Software Licenses + +This section contains third-party software notices and/or additional +terms for licensed third-party software components included within ICU +libraries. + +---------------------------------------------------------------------- + +ICU License - ICU 1.8.1 to ICU 57.1 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1995-2016 International Business Machines Corporation and others +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, and/or sell copies of the Software, and to permit persons +to whom the Software is furnished to do so, provided that the above +copyright notice(s) and this permission notice appear in all copies of +the Software and that both the above copyright notice(s) and this +permission notice appear in supporting documentation. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY +SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, use +or other dealings in this Software without prior written authorization +of the copyright holder. + +All trademarks and registered trademarks mentioned herein are the +property of their respective owners. + +---------------------------------------------------------------------- + +Chinese/Japanese Word Break Dictionary Data (cjdict.txt) + + # The Google Chrome software developed by Google is licensed under + # the BSD license. Other software included in this distribution is + # provided under other licenses, as set forth below. + # + # The BSD License + # http://opensource.org/licenses/bsd-license.php + # Copyright (C) 2006-2008, Google Inc. + # + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # Redistributions in binary form must reproduce the above + # copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided with + # the distribution. + # Neither the name of Google Inc. nor the names of its + # contributors may be used to endorse or promote products derived from + # this software without specific prior written permission. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + # + # + # The word list in cjdict.txt are generated by combining three word lists + # listed below with further processing for compound word breaking. The + # frequency is generated with an iterative training against Google web + # corpora. + # + # * Libtabe (Chinese) + # - https://sourceforge.net/project/?group_id=1519 + # - Its license terms and conditions are shown below. + # + # * IPADIC (Japanese) + # - http://chasen.aist-nara.ac.jp/chasen/distribution.html + # - Its license terms and conditions are shown below. + # + # ---------COPYING.libtabe ---- BEGIN-------------------- + # + # /* + # * Copyright (c) 1999 TaBE Project. + # * Copyright (c) 1999 Pai-Hsiang Hsiao. + # * All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the TaBE Project nor the names of its + # * contributors may be used to endorse or promote products derived + # * from this software without specific prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # /* + # * Copyright (c) 1999 Computer Systems and Communication Lab, + # * Institute of Information Science, Academia + # * Sinica. All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the Computer Systems and Communication Lab + # * nor the names of its contributors may be used to endorse or + # * promote products derived from this software without specific + # * prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # Copyright 1996 Chih-Hao Tsai @ Beckman Institute, + # University of Illinois + # c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4 + # + # ---------------COPYING.libtabe-----END-------------------------------- + # + # + # ---------------COPYING.ipadic-----BEGIN------------------------------- + # + # Copyright 2000, 2001, 2002, 2003 Nara Institute of Science + # and Technology. All Rights Reserved. + # + # Use, reproduction, and distribution of this software is permitted. + # Any copy of this software, whether in its original form or modified, + # must include both the above copyright notice and the following + # paragraphs. + # + # Nara Institute of Science and Technology (NAIST), + # the copyright holders, disclaims all warranties with regard to this + # software, including all implied warranties of merchantability and + # fitness, in no event shall NAIST be liable for + # any special, indirect or consequential damages or any damages + # whatsoever resulting from loss of use, data or profits, whether in an + # action of contract, negligence or other tortuous action, arising out + # of or in connection with the use or performance of this software. + # + # A large portion of the dictionary entries + # originate from ICOT Free Software. The following conditions for ICOT + # Free Software applies to the current dictionary as well. + # + # Each User may also freely distribute the Program, whether in its + # original form or modified, to any third party or parties, PROVIDED + # that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear + # on, or be attached to, the Program, which is distributed substantially + # in the same form as set out herein and that such intended + # distribution, if actually made, will neither violate or otherwise + # contravene any of the laws and regulations of the countries having + # jurisdiction over the User or the intended distribution itself. + # + # NO WARRANTY + # + # The program was produced on an experimental basis in the course of the + # research and development conducted during the project and is provided + # to users as so produced on an experimental basis. Accordingly, the + # program is provided without any warranty whatsoever, whether express, + # implied, statutory or otherwise. The term "warranty" used herein + # includes, but is not limited to, any warranty of the quality, + # performance, merchantability and fitness for a particular purpose of + # the program and the nonexistence of any infringement or violation of + # any right of any third party. + # + # Each user of the program will agree and understand, and be deemed to + # have agreed and understood, that there is no warranty whatsoever for + # the program and, accordingly, the entire risk arising from or + # otherwise connected with the program is assumed by the user. + # + # Therefore, neither ICOT, the copyright holder, or any other + # organization that participated in or was otherwise related to the + # development of the program and their respective officials, directors, + # officers and other employees shall be held liable for any and all + # damages, including, without limitation, general, special, incidental + # and consequential damages, arising out of or otherwise in connection + # with the use or inability to use the program or any product, material + # or result produced or otherwise obtained by using the program, + # regardless of whether they have been advised of, or otherwise had + # knowledge of, the possibility of such damages at any time during the + # project or thereafter. Each user will be deemed to have agreed to the + # foregoing by his or her commencement of use of the program. The term + # "use" as used herein includes, but is not limited to, the use, + # modification, copying and distribution of the program and the + # production of secondary products from the program. + # + # In the case where the program, whether in its original form or + # modified, was distributed or delivered to or received by a user from + # any person, organization or entity other than ICOT, unless it makes or + # grants independently of ICOT any specific warranty to the user in + # writing, such person, organization or entity, will also be exempted + # from and not be held liable to the user for any such damages as noted + # above as far as the program is concerned. + # + # ---------------COPYING.ipadic-----END---------------------------------- + +---------------------------------------------------------------------- + +Lao Word Break Dictionary Data (laodict.txt) + + # Copyright (C) 2016 and later: Unicode, Inc. and others. + # License & terms of use: http://www.unicode.org/copyright.html + # Copyright (c) 2015 International Business Machines Corporation + # and others. All Rights Reserved. + # + # Project: https://github.com/rober42539/lao-dictionary + # Dictionary: https://github.com/rober42539/lao-dictionary/laodict.txt + # License: https://github.com/rober42539/lao-dictionary/LICENSE.txt + # (copied below) + # + # This file is derived from the above dictionary version of Nov 22, 2020 + # ---------------------------------------------------------------------- + # Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, this + # list of conditions and the following disclaimer. Redistributions in binary + # form must reproduce the above copyright notice, this list of conditions and + # the following disclaimer in the documentation and/or other materials + # provided with the distribution. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # OF THE POSSIBILITY OF SUCH DAMAGE. + # -------------------------------------------------------------------------- + +---------------------------------------------------------------------- + +Burmese Word Break Dictionary Data (burmesedict.txt) + + # Copyright (c) 2014 International Business Machines Corporation + # and others. All Rights Reserved. + # + # This list is part of a project hosted at: + # github.com/kanyawtech/myanmar-karen-word-lists + # + # -------------------------------------------------------------------------- + # Copyright (c) 2013, LeRoy Benjamin Sharon + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions + # are met: Redistributions of source code must retain the above + # copyright notice, this list of conditions and the following + # disclaimer. Redistributions in binary form must reproduce the + # above copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided + # with the distribution. + # + # Neither the name Myanmar Karen Word Lists, nor the names of its + # contributors may be used to endorse or promote products derived + # from this software without specific prior written permission. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS + # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + # SUCH DAMAGE. + # -------------------------------------------------------------------------- + +---------------------------------------------------------------------- + +Time Zone Database + + ICU uses the public domain data and code derived from Time Zone +Database for its time zone support. The ownership of the TZ database +is explained in BCP 175: Procedure for Maintaining the Time Zone +Database section 7. + + # 7. Database Ownership + # + # The TZ database itself is not an IETF Contribution or an IETF + # document. Rather it is a pre-existing and regularly updated work + # that is in the public domain, and is intended to remain in the + # public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do + # not apply to the TZ Database or contributions that individuals make + # to it. Should any claims be made and substantiated against the TZ + # Database, the organization that is providing the IANA + # Considerations defined in this RFC, under the memorandum of + # understanding with the IETF, currently ICANN, may act in accordance + # with all competent court orders. No ownership claims will be made + # by ICANN or the IETF Trust on the database or the code. Any person + # making a contribution to the database or code waives all rights to + # future claims in that contribution or in the TZ Database. + +---------------------------------------------------------------------- + +Google double-conversion + +Copyright 2006-2011, the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +---------------------------------------------------------------------- + +JSON parsing library (nlohmann/json) + +File: vendor/json/upstream/single_include/nlohmann/json.hpp (only for ICU4C) + +MIT License + +Copyright (c) 2013-2022 Niels Lohmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---------------------------------------------------------------------- + +File: aclocal.m4 (only for ICU4C) +Section: pkg.m4 - Macros to locate and utilise pkg-config. + + +Copyright © 2004 Scott James Remnant . +Copyright © 2012-2015 Dan Nicholson + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA +02111-1307, USA. + +As a special exception to the GNU General Public License, if you +distribute this file as part of a program that contains a +configuration script generated by Autoconf, you may include it under +the same distribution terms that you use for the rest of that +program. + + +(The condition for the exception is fulfilled because +ICU4C includes a configuration script generated by Autoconf, +namely the `configure` script.) + +---------------------------------------------------------------------- + +File: config.guess (only for ICU4C) + + +This file is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, see . + +As a special exception to the GNU General Public License, if you +distribute this file as part of a program that contains a +configuration script generated by Autoconf, you may include it under +the same distribution terms that you use for the rest of that +program. This Exception is an additional permission under section 7 +of the GNU General Public License, version 3 ("GPLv3"). + + +(The condition for the exception is fulfilled because +ICU4C includes a configuration script generated by Autoconf, +namely the `configure` script.) + +---------------------------------------------------------------------- + +File: install-sh (only for ICU4C) + + +Copyright 1991 by the Massachusetts Institute of Technology + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of M.I.T. not be used in advertising or +publicity pertaining to distribution of the software without specific, +written prior permission. M.I.T. makes no representations about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. + +---------------------------------------------------------------------- + +File: sorttable.js (only for ICU4J) + +The MIT Licence, for code from kryogenix.org + +Code downloaded from the Browser Experiments section of kryogenix.org is +licenced under the so-called MIT licence. The licence is below. + +Copyright (c) 1997-date Stuart Langridge + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/desktopApp/packaging/resources/common/licenses/jna-AL2.0.txt b/desktopApp/packaging/resources/common/licenses/jna-AL2.0.txt new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/jna-AL2.0.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/desktopApp/packaging/resources/common/licenses/jna-LGPL2.1.txt b/desktopApp/packaging/resources/common/licenses/jna-LGPL2.1.txt new file mode 100644 index 00000000..3b204400 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/jna-LGPL2.1.txt @@ -0,0 +1,458 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS diff --git a/desktopApp/packaging/resources/common/licenses/jna-LICENSE.txt b/desktopApp/packaging/resources/common/licenses/jna-LICENSE.txt new file mode 100644 index 00000000..9d95afbe --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/jna-LICENSE.txt @@ -0,0 +1,26 @@ +SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later + +Java Native Access (JNA) is licensed under the LGPL, version 2.1 +or later, or (from version 4.0 onward) the Apache License, +version 2.0. + +You can freely decide which license you want to apply to the project. + +You may obtain a copy of the LGPL License at: + +http://www.gnu.org/licenses/licenses.html + +A copy is also included in the downloadable source code package +containing JNA, in file "LGPL2.1", under the same directory +as this file. + +You may obtain a copy of the Apache License at: + +http://www.apache.org/licenses/ + +A copy is also included in the downloadable source code package +containing JNA, in file "AL2.0", under the same directory +as this file. + +Commercial support may be available, please e-mail +twall[at]users[dot]sf[dot]net. diff --git a/desktopApp/packaging/resources/common/licenses/pdfbox-LICENSE.txt b/desktopApp/packaging/resources/common/licenses/pdfbox-LICENSE.txt new file mode 100644 index 00000000..c11c1630 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/pdfbox-LICENSE.txt @@ -0,0 +1,1061 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +EXTERNAL COMPONENTS + +Apache PDFBox includes a number of components with separate copyright notices +and license terms. Your use of these components is subject to the terms and +conditions of the following licenses. + +Contributions made to the original PDFBox project: + + Copyright (c) 2002-2007, www.pdfbox.org + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of pdfbox; nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. + +Adobe Font Metrics (AFM) for PDF Core 14 Fonts + + This file and the 14 PostScript(R) AFM files it accompanies may be used, + copied, and distributed for any purpose and without charge, with or without + modification, provided that all copyright notices are retained; that the + AFM files are not distributed without this file; that all modifications + to this file or any of the AFM files are prominently noted in the modified + file(s); and that this paragraph is not modified. Adobe Systems has no + responsibility or obligation to support the use of the AFM files. + +CMaps for PDF Fonts (http://opensource.adobe.com/wiki/display/cmap/Downloads) + + Copyright 1990-2009 Adobe Systems Incorporated. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + Neither the name of Adobe Systems Incorporated nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + THE POSSIBILITY OF SUCH DAMAGE. + +Glyphlist (http://www.adobe.com/devnet/opentype/archives/glyph.html) + + Copyright (c) 1997,1998,2002,2007 Adobe Systems Incorporated + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this documentation file to use, copy, publish, distribute, + sublicense, and/or sell copies of the documentation, and to permit + others to do the same, provided that: + - No modification, editing or other alteration of this document is + allowed; and + - The above copyright notice and this permission notice shall be + included in all copies of the documentation. + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this documentation file, to create their own derivative works + from the content of this document to use, copy, publish, distribute, + sublicense, and/or sell the derivative works, and to permit others to do + the same, provided that the derived work is not represented as being a + copy or version of this document. + + Adobe shall not be liable to any party for any loss of revenue or profit + or for indirect, incidental, special, consequential, or other similar + damages, whether based on tort (including without limitation negligence + or strict liability), contract or other legal or equitable grounds even + if Adobe has been advised or had reason to know of the possibility of + such damages. The Adobe materials are provided on an "AS IS" basis. + Adobe specifically disclaims all express, statutory, or implied + warranties relating to the Adobe materials, including but not limited to + those concerning merchantability or fitness for a particular purpose or + non-infringement of any third party rights regarding the Adobe + materials. + +Liberation Fonts (https://fedorahosted.org/liberation-fonts) + + Digitized data copyright (c) 2010 Google Corporation + with Reserved Font Arimo, Tinos and Cousine. + Copyright (c) 2012 Red Hat, Inc. + with Reserved Font Name Liberation. + + This Font Software is licensed under the SIL Open Font License, + Version 1.1. + + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + + PREAMBLE The goals of the Open Font License (OFL) are to stimulate + worldwide development of collaborative font projects, to support the font + creation efforts of academic and linguistic communities, and to provide + a free and open framework in which fonts may be shared and improved in + partnership with others. + + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. + The fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply to + any document created using the fonts or their derivatives. + + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. + This may include source files, build scripts and documentation. + + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + + "Original Version" refers to the collection of Font Software components + as distributed by the Copyright Holder(s). + + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting ? in part or in whole ? + any of the components of the Original Version, by changing formats or + by porting the Font Software to a new environment. + + "Author" refers to any designer, engineer, programmer, technical writer + or other person who contributed to the Font Software. + + PERMISSION & CONDITIONS + + Permission is hereby granted, free of charge, to any person obtaining a + copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + + 1) Neither the Font Software nor any of its individual components,in + Original or Modified Versions, may be sold by itself. + + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + + 5) The Font Software, modified or unmodified, in part or in whole, must + be distributed entirely under this license, and must not be distributed + under any other license. The requirement for fonts to remain under + this license does not apply to any document created using the Font + Software. + + TERMINATION + This license becomes null and void if any of the above conditions are not met. + + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER + DEALINGS IN THE FONT SOFTWARE. + +Twelvemonkeys (https://github.com/haraldk/TwelveMonkeys/) + + Copyright (c) 2008-2016, Harald Kuhr + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + o Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + o Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + o Neither the name "TwelveMonkeys" nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Lohit-Bengali, Lohit-Devanagari, Lohit-Gujarati fonts (https://pagure.io/lohit): + + Copyright 2011-15 Lohit Fonts Project contributors + + This Font Software is licensed under the SIL Open Font License, Version 1.1. + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + + + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + + PREAMBLE + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font creation + efforts of academic and linguistic communities, and to provide a free and + open framework in which fonts may be shared and improved in partnership + with others. + + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply + to any document created using the fonts or their derivatives. + + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + + "Original Version" refers to the collection of Font Software components as + distributed by the Copyright Holder(s). + + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting -- in part or in whole -- any of the components of the + Original Version, by changing formats or by porting the Font Software to a + new environment. + + "Author" refers to any designer, engineer, programmer, technical + writer or other person who contributed to the Font Software. + + PERMISSION & CONDITIONS + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + + 5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + + TERMINATION + This license becomes null and void if any of the above conditions are + not met. + + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. + +Font Awesome (http://fontawesome.com): + https://fontawesome.com/license/free + CC BY 4.0 License + https://creativecommons.org/licenses/by/4.0/ + https://creativecommons.org/licenses/by/4.0/legalcode.txt + Modification made: + shapes gathered by printing the original .svg files (not included) + into PDF and using the coordinates. + + Attribution 4.0 International + + ======================================================================= + + Creative Commons Corporation ("Creative Commons") is not a law firm and + does not provide legal services or legal advice. Distribution of + Creative Commons public licenses does not create a lawyer-client or + other relationship. Creative Commons makes its licenses and related + information available on an "as-is" basis. Creative Commons gives no + warranties regarding its licenses, any material licensed under their + terms and conditions, or any related information. Creative Commons + disclaims all liability for damages resulting from their use to the + fullest extent possible. + + Using Creative Commons Public Licenses + + Creative Commons public licenses provide a standard set of terms and + conditions that creators and other rights holders may use to share + original works of authorship and other material subject to copyright + and certain other rights specified in the public license below. The + following considerations are for informational purposes only, are not + exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + + ======================================================================= + + Creative Commons Attribution 4.0 International Public License + + By exercising the Licensed Rights (defined below), You accept and agree + to be bound by the terms and conditions of this Creative Commons + Attribution 4.0 International Public License ("Public License"). To the + extent this Public License may be interpreted as a contract, You are + granted the Licensed Rights in consideration of Your acceptance of + these terms and conditions, and the Licensor grants You such rights in + consideration of benefits the Licensor receives from making the + Licensed Material available under these terms and conditions. + + + Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + + Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + + Section 3 -- License Conditions. + + Your exercise of the Licensed Rights is expressly made subject to the + following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + + Section 4 -- Sui Generis Database Rights. + + Where the Licensed Rights include Sui Generis Database Rights that + apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + + For the avoidance of doubt, this Section 4 supplements and does not + replace Your obligations under this Public License where the Licensed + Rights include other Copyright and Similar Rights. + + + Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + + Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + + Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + + Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + + ======================================================================= + + Creative Commons is not a party to its public + licenses. Notwithstanding, Creative Commons may elect to apply one of + its public licenses to material it publishes and in those instances + will be considered the “Licensor.” The text of the Creative Commons + public licenses is dedicated to the public domain under the CC0 Public + Domain Dedication. Except for the limited purpose of indicating that + material is shared under a Creative Commons public license or as + otherwise permitted by the Creative Commons policies published at + creativecommons.org/policies, Creative Commons does not authorize the + use of the trademark "Creative Commons" or any other trademark or logo + of Creative Commons without its prior written consent including, + without limitation, in connection with any unauthorized modifications + to any of its public licenses or any other arrangements, + understandings, or agreements concerning use of licensed material. For + the avoidance of doubt, this paragraph does not form part of the + public licenses. + + Creative Commons may be contacted at creativecommons.org. + + +CMYK ICC Profile (https://github.com/saucecontrol/Compact-ICC-Profiles): + CC0 1.0 Universal + + Statement of Purpose + + The laws of most jurisdictions throughout the world automatically confer + exclusive Copyright and Related Rights (defined below) upon the creator and + subsequent owner(s) (each and all, an "owner") of an original work of + authorship and/or a database (each, a "Work"). + + Certain owners wish to permanently relinquish those rights to a Work for the + purpose of contributing to a commons of creative, cultural and scientific + works ("Commons") that the public can reliably and without fear of later + claims of infringement build upon, modify, incorporate in other works, reuse + and redistribute as freely as possible in any form whatsoever and for any + purposes, including without limitation commercial purposes. These owners may + contribute to the Commons to promote the ideal of a free culture and the + further production of creative, cultural and scientific works, or to gain + reputation or greater distribution for their Work in part through the use and + efforts of others. + + For these and/or other purposes and motivations, and without any expectation + of additional consideration or compensation, the person associating CC0 with a + Work (the "Affirmer"), to the extent that he or she is an owner of Copyright + and Related Rights in the Work, voluntarily elects to apply CC0 to the Work + and publicly distribute the Work under its terms, with knowledge of his or her + Copyright and Related Rights in the Work and the meaning and intended legal + effect of CC0 on those rights. + + 1. Copyright and Related Rights. A Work made available under CC0 may be + protected by copyright and related or neighboring rights ("Copyright and + Related Rights"). Copyright and Related Rights include, but are not limited + to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + + 2. Waiver. To the greatest extent permitted by, but not in contravention of, + applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and + unconditionally waives, abandons, and surrenders all of Affirmer's Copyright + and Related Rights and associated claims and causes of action, whether now + known or unknown (including existing as well as future claims and causes of + action), in the Work (i) in all territories worldwide, (ii) for the maximum + duration provided by applicable law or treaty (including future time + extensions), (iii) in any current or future medium and for any number of + copies, and (iv) for any purpose whatsoever, including without limitation + commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes + the Waiver for the benefit of each member of the public at large and to the + detriment of Affirmer's heirs and successors, fully intending that such Waiver + shall not be subject to revocation, rescission, cancellation, termination, or + any other legal or equitable action to disrupt the quiet enjoyment of the Work + by the public as contemplated by Affirmer's express Statement of Purpose. + + 3. Public License Fallback. Should any part of the Waiver for any reason be + judged legally invalid or ineffective under applicable law, then the Waiver + shall be preserved to the maximum extent permitted taking into account + Affirmer's express Statement of Purpose. In addition, to the extent the Waiver + is so judged Affirmer hereby grants to each affected person a royalty-free, + non transferable, non sublicensable, non exclusive, irrevocable and + unconditional license to exercise Affirmer's Copyright and Related Rights in + the Work (i) in all territories worldwide, (ii) for the maximum duration + provided by applicable law or treaty (including future time extensions), (iii) + in any current or future medium and for any number of copies, and (iv) for any + purpose whatsoever, including without limitation commercial, advertising or + promotional purposes (the "License"). The License shall be deemed effective as + of the date CC0 was applied by Affirmer to the Work. Should any part of the + License for any reason be judged legally invalid or ineffective under + applicable law, such partial invalidity or ineffectiveness shall not + invalidate the remainder of the License, and in such case Affirmer hereby + affirms that he or she will not (i) exercise any of his or her remaining + Copyright and Related Rights in the Work or (ii) assert any associated claims + and causes of action with respect to the Work, in either case contrary to + Affirmer's express Statement of Purpose. + + 4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + + For more information, please see + http://creativecommons.org/publicdomain/zero/1.0/ diff --git a/desktopApp/packaging/resources/common/licenses/pdfbox-NOTICE.txt b/desktopApp/packaging/resources/common/licenses/pdfbox-NOTICE.txt new file mode 100644 index 00000000..a6e12ae9 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/pdfbox-NOTICE.txt @@ -0,0 +1,46 @@ +== Apache PDFBox 3.0.5 == +Apache PDFBox +Copyright 2002-2025 The Apache Software Foundation + + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). +Based on source code contributed to the original PDFBox project. +Copyright (c) 2002-2007, www.pdfbox.org + +Includes the Adobe Glyph List +Copyright 1997, 1998, 2002, 2007, 2010 Adobe Systems Incorporated. + +Includes the Zapf Dingbats Glyph List +Copyright 2002, 2010 Adobe Systems Incorporated. + +Includes the Bidi Mirroring Glyph Property (BidiMirroring-8.0.0.txt) +Copyright 1991-2015 Unicode, Inc. + +Includes parts of TwelveMonkeys ImageIO +Copyright 2008-2016 Harald Kuhr + +Includes the CMYK ICC-profile (CGATS001Compat-v2-micro.icc) +https://github.com/saucecontrol/Compact-ICC-Profiles +Released to the public domain under the Creative Commons CC0 license by Clinton Ingram, saucecontrol + +== Apache FontBox 3.0.5 == +Apache FontBox +Copyright 2008-2025 The Apache Software Foundation + + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). +Based on source code contributed to the original FontBox project. +Copyright (c) 2006-2007, www.fontbox.org + +Includes the Script Property (Scripts-10.0.0.txt) +Copyright 2017 Unicode, Inc. + +== Apache PDFBox IO 3.0.5 == +Apache PDFBox io +Copyright 2002-2025 The Apache Software Foundation + + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). diff --git a/desktopApp/packaging/resources/common/licenses/slf4j-LICENSE.txt b/desktopApp/packaging/resources/common/licenses/slf4j-LICENSE.txt new file mode 100644 index 00000000..1a3d0532 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/slf4j-LICENSE.txt @@ -0,0 +1,24 @@ +Copyright (c) 2004-2022 QOS.ch Sarl (Switzerland) +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/DesktopApp.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/DesktopApp.kt new file mode 100644 index 00000000..671d9844 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/DesktopApp.kt @@ -0,0 +1,163 @@ +package com.pasich.encly.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation.NavHostController +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.desktop.di.DesktopAppGraph +import com.pasich.encly.desktop.platform.DesktopLicenses +import com.pasich.encly.desktop.platform.SensitiveClipboardBoundary +import com.pasich.encly.desktop.platform.ToastHost +import com.pasich.encly.desktop.window.DesktopShortcuts +import com.pasich.encly.desktop.window.WindowServices +import com.pasich.encly.platform.AppWindow +import com.pasich.encly.platform.LocalAppInfo +import com.pasich.encly.platform.LocalAppLocales +import com.pasich.encly.platform.LocalAppWindow +import com.pasich.encly.platform.LocalDocumentPickers +import com.pasich.encly.platform.LocalExternalLinks +import com.pasich.encly.platform.LocalExtraLicenses +import com.pasich.encly.platform.LocalKeyboardPrivacyOffered +import com.pasich.encly.platform.LocalNavigationRequests +import com.pasich.encly.platform.LocalNoteDocumentRenderer +import com.pasich.encly.platform.LocalNoteEditClaims +import com.pasich.encly.platform.LocalNoteWindows +import com.pasich.encly.platform.LocalSensitiveClipboard +import com.pasich.encly.platform.LocalSessionSharedAcrossWindows +import com.pasich.encly.platform.LocalToaster +import com.pasich.encly.presentation.designsystem.EnclyShortcuts +import com.pasich.encly.presentation.navigation.EnclyApp +import com.pasich.encly.presentation.viewmodel.SelectedTagHolder +import com.pasich.encly.ui.theme.AppTheme +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException + +/** + * The whole app inside a window: what `MainActivity` does on Android. It resolves the vault's start state off + * the UI thread, gives the shared UI its platform services, and draws [EnclyApp] and the in-window messages. + * It needs no real window ([appWindow] says what the window can do), so tests compose it headless. [shortcuts] is + * the window's one key map, the same the menu bar's accelerators are built from. [services] are the window's own + * (its menu requests, dropped files and file dialogs); the vault, the lock and the rest are the [graph]'s, shared by + * every window of the process. + */ +@Composable +fun DesktopApp( + graph: DesktopAppGraph, + appWindow: AppWindow, + modifier: Modifier = Modifier, + shortcuts: EnclyShortcuts = remember(graph) { DesktopShortcuts.map(graph.environment.os) }, + services: WindowServices = remember(graph) { WindowServices.create(graph.environment, graph.lifecycle) }, +) { + var startStatus by remember(graph) { mutableStateOf(null) } + LaunchedEffect(graph) { + val status = withContext(Dispatchers.IO) { + // Never throws: unreadable vault state routes to the damaged-vault screen. + val resolved = graph.securityManager.resolveInitialStatus() + try { + graph.settingsRepository.loadThemeSettings(existingInstall = !graph.securityManager.isOnboardingShow()) + } catch (_: IOException) { + // An unreadable settings file: the theme starts from its defaults. + } + resolved + } + // A committed vault that is not open starts locked, before the first frame. + if (status == InitialStatus.AUTH) graph.sessionLockManager.requireUnlock() + startStatus = status + // Files the app was started with (or is given) wait for this. + services.startStatus.complete(status) + } + + DesktopHostServices(graph, appWindow, services) { + // There is no Activity to recreate when the language changes: a new key makes every screen read its + // strings again. + val revision by graph.locales.revision.collectAsState() + // The place the user is at survives the rebuild: the replaced controller's back stack goes to the new one + // (same entries, so the same ViewModels), instead of the window starting over from the launch-time status. + val controllerOfWindow = remember { NavControllerOfWindow() } + val navState = remember(revision) { controllerOfWindow.controller?.saveState() } + Box(modifier.fillMaxSize()) { + key(revision) { + EnclyApp( + restoredNavState = navState, + onNavController = { controllerOfWindow.controller = it }, + startStatus = startStatus, + securityManager = graph.securityManager, + sessionLockManager = graph.sessionLockManager, + shortcuts = shortcuts, + // The menu bar registers these accelerators itself, so the keyboard host leaves them to it. + declinedCommands = DesktopShortcuts.menuOwned, + ) + } + AppTheme { + ToastHost(services.toaster, Modifier.align(Alignment.BottomCenter).padding(16.dp)) + } + } + } +} + +/** The navigation controller the window's app currently runs on, to carry its back stack across a rebuild. */ +private class NavControllerOfWindow { + var controller: NavHostController? = null +} + +/** Hands the shared UI what it needs from the desktop, once, around its whole content. */ +@Composable +fun DesktopHostServices( + graph: DesktopAppGraph, + appWindow: AppWindow, + services: WindowServices, + content: @Composable () -> Unit, +) { + val viewModelStoreOwner = remember { + object : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + } + DisposableEffect(viewModelStoreOwner) { onDispose { viewModelStoreOwner.viewModelStore.clear() } } + // The tag filter is this window's own: the ViewModels of the window are created with it. + val selectedTag = remember(graph) { SelectedTagHolder(graph.sessionLockManager) } + DisposableEffect(selectedTag) { onDispose { selectedTag.close() } } + val viewModelFactory = remember(graph, selectedTag) { graph.viewModelFactory.forWindow(selectedTag) } + CompositionLocalProvider( + LocalViewModelStoreOwner provides viewModelStoreOwner, + LocalLifecycleOwner provides graph.lifecycle, + LocalMetroViewModelFactory provides viewModelFactory, + LocalAppWindow provides appWindow, + LocalExternalLinks provides graph.externalLinks, + LocalExtraLicenses provides DesktopLicenses.notices, + LocalKeyboardPrivacyOffered provides false, + LocalToaster provides services.toaster, + LocalSensitiveClipboard provides graph.clipboard, + LocalAppLocales provides graph.locales, + LocalAppInfo provides graph.appInfo, + LocalDocumentPickers provides services.documentPickers, + LocalNavigationRequests provides services.navigation, + LocalNoteDocumentRenderer provides graph.noteDocuments, + LocalNoteWindows provides services.noteWindows, + LocalNoteEditClaims provides services.claims, + // Every window shows the same vault behind the same lock: a lock screen follows an unlock made elsewhere. + LocalSessionSharedAcrossWindows provides true, + ) { + SensitiveClipboardBoundary(graph.clipboardWiper, content) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Main.kt new file mode 100644 index 00000000..e2d3fce9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Main.kt @@ -0,0 +1,124 @@ +package com.pasich.encly.desktop + +import androidx.compose.ui.window.application +import com.pasich.encly.core.keystore.DesktopPinFactor +import com.pasich.encly.core.keystore.SecretStoreUnavailableException +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.desktop.di.DesktopAppGraph +import com.pasich.encly.desktop.nativeos.NativeBackupExclusion +import com.pasich.encly.desktop.platform.DesktopEnvironment +import com.pasich.encly.desktop.storage.AppDirectories +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.desktop.window.EnclyWindows +import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.launch +import java.awt.GraphicsEnvironment +import java.awt.Taskbar +import java.io.IOException +import java.nio.file.Path +import javax.imageio.ImageIO +import javax.swing.JOptionPane +import javax.swing.UIManager +import kotlin.system.exitProcess + +private const val APP_NAME = "Encly" + +/** + * Starts Encly on the desktop: the private data directory, the OS secret store, the dependency graph and the + * window. See `desktopApp/README.md` for what is stored where and what the app promises. + */ +fun main(args: Array) { + System.setProperty("apple.awt.application.name", APP_NAME) + val os = DesktopOs.current() + useNativeLookAndFeel(os) + + val dataDirectory = try { + AppDirectories.createPrivate(AppDirectories.systemDataDirectory()) + } catch (e: IOException) { + fail("Encly cannot create its data folder: ${e.message}") + } + // Kept out of the OS's backups (Time Machine) every start, in case the attribute was lost with a restored folder. + NativeBackupExclusion.forOs(os).exclude(dataDirectory) + // Held until the process ends: the OS releases the lock with it. + val instance = SingleInstanceLock.tryAcquire(dataDirectory) + ?: fail("Encly is already running.", exitCode = 0) + + val pinFactor = createPinFactor(dataDirectory) + val environment = DesktopEnvironment.system(os, dataDirectory, pinFactor) + val graph = createGraphFactory().create(environment) + startServices(graph) + setDockIcon() + + val launchFiles = LaunchFiles.fromArguments(args) + application(exitProcessOnExit = true) { + EnclyWindows(graph, launchFiles) { + shutdown(graph) + instance.close() + exitApplication() + } + } +} + +/** The PIN's device-bound key lives in the OS secret store; without one there is no PIN, so the app cannot run. */ +private fun createPinFactor(dataDirectory: Path): PinHardwareFactor = try { + DesktopPinFactor.create(dataDirectory) +} catch (e: SecretStoreUnavailableException) { + fail("Encly needs the operating system's secret store, which is not available: ${e.message}") +} + +/** The long-lived parts that run for the whole process: the lock sources and the first sweep. */ +private fun startServices(graph: DesktopAppGraph) { + graph.locales.syncDefaultLocale() + // SessionLockManager follows the window: it is told when the app is left and when it is back. + graph.lifecycle.lifecycle.addObserver(graph.sessionLockManager) + // The OS locking the screen locks the vault, whether Encly is in front or not. + graph.deviceLockWatcher.onLocked { graph.sessionLockManager.lockNow() } + // A file dialog of the portal lives in another process and stays on screen over any lock (the OS's, the idle + // lock, "Lock now"): close it when the vault closes. + graph.applicationScope.launch { + graph.sessionLockManager.locked.collect { locked -> if (locked) graph.environment.fileDialogs.cancelAll() } + } + graph.deviceLockWatcher.start() + // A My Notes hand-off (plaintext) that a killed process left in the cache; off the UI thread. + graph.applicationScope.launch { graph.handoffStagingSweeper.sweep() } +} + +/** + * Closes the vault and empties the clipboard of what Encly put there before the process ends. The windows are + * closed and their editors have saved by now (see `EnclyWindows`). + */ +private fun shutdown(graph: DesktopAppGraph) { + graph.sessionLockManager.lockNow() + // First-run setup has no lockable vault yet but may hold the database open: it is closed all the same. + if (graph.securityManager.isDatabaseUnlocked()) graph.securityManager.lock() + graph.clipboardWiper.wipeIfStillHeld() + graph.deviceLockWatcher.close() + graph.lifecycle.destroy() +} + +/** The window menu bar and file chooser of Windows look like Windows; elsewhere the defaults are kept. */ +private fun useNativeLookAndFeel(os: DesktopOs) { + if (os != DesktopOs.WINDOWS) return + runCatching { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) } +} + +/** The Dock icon when running unpackaged (a packaged macOS app gets it from its bundle). */ +private fun setDockIcon() { + if (GraphicsEnvironment.isHeadless() || !Taskbar.isTaskbarSupported()) return + runCatching { + val taskbar = Taskbar.getTaskbar() + if (taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) { + Thread.currentThread().contextClassLoader.getResourceAsStream("encly-icon.png")?.use { + taskbar.iconImage = ImageIO.read(it) + } + } + } +} + +private fun fail(message: String, exitCode: Int = 1): Nothing { + System.err.println(message) + if (!GraphicsEnvironment.isHeadless()) { + runCatching { JOptionPane.showMessageDialog(null, message, APP_NAME, JOptionPane.ERROR_MESSAGE) } + } + exitProcess(exitCode) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Startup.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Startup.kt new file mode 100644 index 00000000..fd678828 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Startup.kt @@ -0,0 +1,62 @@ +package com.pasich.encly.desktop + +import com.pasich.encly.desktop.platform.DroppedKind +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardOpenOption + +/** + * Only one Encly may run per data directory: two processes on the same encrypted database and the same key + * slots file would corrupt each other's state. The lock is an OS file lock, so it is released when the process + * ends, however it ends. A second start does not open a second window; it exits (it does not hand its files to + * the first, which would need a local socket; the file can be dropped on the open window instead). + */ +class SingleInstanceLock private constructor(private val channel: FileChannel, private val lock: FileLock) : + AutoCloseable { + override fun close() { + runCatching { lock.release() } + runCatching { channel.close() } + } + + companion object { + private const val LOCK_FILE = ".instance.lock" + + /** The lock for [dataDirectory], or null if another Encly of this user holds it. */ + @Suppress("ReturnCount") // Each failure to get the lock is a different way to say no. + fun tryAcquire(dataDirectory: Path): SingleInstanceLock? { + val channel = try { + FileChannel.open(dataDirectory.resolve(LOCK_FILE), StandardOpenOption.CREATE, StandardOpenOption.WRITE) + } catch (_: IOException) { + return null + } + val lock = try { + channel.tryLock() + } catch (_: IOException) { + null + } catch (_: java.nio.channels.OverlappingFileLockException) { + null + } + if (lock == null) { + channel.close() + return null + } + return SingleInstanceLock(channel, lock) + } + } +} + +/** The files named on the command line: what the OS passes for a double click on a backup (Windows, Linux). */ +object LaunchFiles { + fun fromArguments(args: Array): List = args.mapNotNull { argument -> + try { + Paths.get(argument).takeIf { Files.exists(it) && DroppedKind.of(it) != null } + } catch (_: InvalidPathException) { + null + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopAppGraph.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopAppGraph.kt new file mode 100644 index 00000000..8a122f07 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopAppGraph.kt @@ -0,0 +1,64 @@ +package com.pasich.encly.desktop.di + +import com.pasich.encly.core.di.AppViewModelFactory +import com.pasich.encly.core.di.ApplicationScope +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.security.AutoLockPolicy +import com.pasich.encly.core.security.HandoffStagingSweeper +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.desktop.platform.ClipboardWiper +import com.pasich.encly.desktop.platform.DesktopAppLocaleController +import com.pasich.encly.desktop.platform.DesktopEnvironment +import com.pasich.encly.desktop.platform.DesktopToaster +import com.pasich.encly.desktop.security.DesktopAppLifecycle +import com.pasich.encly.desktop.security.DesktopDeviceLockWatcher +import com.pasich.encly.domain.repository.SettingsRepository +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.ExternalLinks +import com.pasich.encly.platform.NoteDocumentRenderer +import com.pasich.encly.platform.SensitiveClipboard +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ViewModelGraph +import kotlinx.coroutines.CoroutineScope + +/** + * The one dependency graph of the desktop process: the counterpart of the Android `AppGraph`. The bindings of + * the shared code (`@ContributesTo(AppScope)` and `@ContributesIntoMap` in `:shared` and the `:core` modules) + * come in by themselves; [DesktopPlatformModule] and [DesktopBindingsModule] add what the platform provides. + * + * Unlike Android, the desktop creates every object itself, so the entry points are plain accessors: the + * window host reads what it needs from here once. What belongs to one window (its menu requests, dropped files + * and file dialogs) is not here but in `WindowServices`: the graph is the process's, shared by every window. + */ +@DependencyGraph(AppScope::class) +interface DesktopAppGraph : ViewModelGraph { + val securityManager: SecurityManager + val sessionLockManager: SessionLockManager + val settingsRepository: SettingsRepository + val autoLockPolicy: AutoLockPolicy + val handoffStagingSweeper: HandoffStagingSweeper + val flags: KeyValueStore + + @get:ApplicationScope + val applicationScope: CoroutineScope + + val environment: DesktopEnvironment + val lifecycle: DesktopAppLifecycle + val deviceLockWatcher: DesktopDeviceLockWatcher + val toaster: DesktopToaster + val viewModelFactory: AppViewModelFactory + val clipboardWiper: ClipboardWiper + val locales: DesktopAppLocaleController + val noteDocuments: NoteDocumentRenderer + val externalLinks: ExternalLinks + val clipboard: SensitiveClipboard + val appInfo: AppInfo + + @DependencyGraph.Factory + fun interface Factory { + fun create(@Provides environment: DesktopEnvironment): DesktopAppGraph + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopBindingsModule.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopBindingsModule.kt new file mode 100644 index 00000000..5d855a0c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopBindingsModule.kt @@ -0,0 +1,32 @@ +package com.pasich.encly.desktop.di + +import com.pasich.encly.core.security.AutoLock +import com.pasich.encly.core.security.AutoLockPolicy +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.data.backup.RoomVaultDataStore +import com.pasich.encly.data.backup.VaultDataStore +import com.pasich.encly.data.database.DatabaseProvider +import com.pasich.encly.data.database.SecureDatabaseManager +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo + +/** The interface bindings `:app` declares in `DatabaseModule` and `SecurityBindingsModule`, for the desktop graph. */ +@ContributesTo(AppScope::class) +@BindingContainer +interface DesktopBindingsModule { + // SecureDatabaseManager is itself a singleton, so this binding is one instance too. + @Binds + fun bindDatabaseProvider(secureDatabaseManager: SecureDatabaseManager): DatabaseProvider + + @Binds + fun bindVaultDataStore(store: RoomVaultDataStore): VaultDataStore + + @Binds + fun bindVaultLockEvents(manager: SessionLockManager): VaultLockEvents + + @Binds + fun bindAutoLockPolicy(autoLock: AutoLock): AutoLockPolicy +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopModules.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopModules.kt new file mode 100644 index 00000000..e4244f08 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopModules.kt @@ -0,0 +1,197 @@ +package com.pasich.encly.desktop.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.pasich.encly.core.common.AppStrings +import com.pasich.encly.core.common.ComposeAppStrings +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.core.security.BiometricManager +import com.pasich.encly.core.security.DeviceLockWatcher +import com.pasich.encly.core.security.HandoffStagingSweeper +import com.pasich.encly.core.security.JvmVaultFileIo +import com.pasich.encly.core.security.LockoutClock +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.VaultStore +import com.pasich.encly.data.database.DatabasePlatform +import com.pasich.encly.data.database.DesktopDatabasePlatform +import com.pasich.encly.data.handoff.HandoffStaging +import com.pasich.encly.desktop.documents.DesktopNoteDocuments +import com.pasich.encly.desktop.nativeos.AwtPrinting +import com.pasich.encly.desktop.platform.ClipboardWiper +import com.pasich.encly.desktop.platform.DesktopAppInfo +import com.pasich.encly.desktop.platform.DesktopAppLocaleController +import com.pasich.encly.desktop.platform.DesktopCacheDirectory +import com.pasich.encly.desktop.platform.DesktopDeviceCapabilities +import com.pasich.encly.desktop.platform.DesktopDocuments +import com.pasich.encly.desktop.platform.DesktopEnvironment +import com.pasich.encly.desktop.platform.DesktopExternalLinks +import com.pasich.encly.desktop.platform.DesktopSensitiveClipboard +import com.pasich.encly.desktop.platform.DesktopToaster +import com.pasich.encly.desktop.security.DesktopAppLifecycle +import com.pasich.encly.desktop.security.DesktopDeviceLockWatcher +import com.pasich.encly.desktop.security.NoBiometricManager +import com.pasich.encly.desktop.security.OsUptimeClock +import com.pasich.encly.desktop.storage.FileKeyValueStore +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.CacheDirectory +import com.pasich.encly.platform.ExternalLinks +import com.pasich.encly.platform.NoteDocumentRenderer +import com.pasich.encly.platform.PlatformDocuments +import com.pasich.encly.platform.SensitiveClipboard +import com.pasich.encly.platform.Toaster +import com.pasich.encly.utils.DeviceCapabilities +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.io.files.Path +import okio.Path.Companion.toPath + +/** File names inside the data directory. The first two are the ones the Android app uses, by the same names. */ +internal object DataFiles { + const val VAULT_STATE = "vault_state_v3.bin" + const val FLAGS = "encly_flags_v3.properties" + const val SETTINGS = "my_notes_settings.preferences_pb" + const val CACHE_DIR = "cache" +} + +/** + * Every binding the desktop makes for what `:app` binds on Android, in one place. The [DesktopEnvironment] is + * the only input of the graph: with a temporary directory and fakes in it the whole graph runs in a test. + * + * | Android (`:app`) | Desktop | + * |-------------------------------------------|----------------------------------------------------| + * | KeystorePinFactor | `environment.pinFactor` (`DesktopPinFactor`) | + * | KeystoreBiometricManager | [NoBiometricManager] | + * | HandlerMainThreadTimer | `environment.timer` (Swing timer) | + * | SystemLockoutClock | [OsUptimeClock] | + * | SystemDeviceLockWatcher | [DesktopDeviceLockWatcher] | + * | VaultStore in `no_backup` | [VaultStore] in the private data directory | + * | SharedPreferences flags | [FileKeyValueStore] | + * | AndroidDatabasePlatform | `DesktopDatabasePlatform` in the data directory | + * | preferencesDataStore | `PreferenceDataStoreFactory` file in the data dir | + * | AndroidDocuments, AndroidToaster, ... | the `Desktop*` services of `platform` | + */ +@ContributesTo(AppScope::class) +@BindingContainer +object DesktopPlatformModule { + @Provides + @SingleIn(AppScope::class) + fun provideVaultStore(environment: DesktopEnvironment): VaultStore = + VaultStore(JvmVaultFileIo(environment.dataDirectory.resolve(DataFiles.VAULT_STATE).toFile())) + + @Provides + @SingleIn(AppScope::class) + fun provideFlags(environment: DesktopEnvironment): KeyValueStore = + FileKeyValueStore(environment.dataDirectory.resolve(DataFiles.FLAGS)) + + @Provides + @SingleIn(AppScope::class) + fun provideDatabasePlatform(environment: DesktopEnvironment): DatabasePlatform = + DesktopDatabasePlatform(Path(environment.dataDirectory.toString())) + + @Provides + @SingleIn(AppScope::class) + fun provideSettingsStore(environment: DesktopEnvironment): DataStore = + PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { environment.dataDirectory.resolve(DataFiles.SETTINGS).toString().toPath() }, + ) + + @Provides + fun providePinHardwareFactor(environment: DesktopEnvironment): PinHardwareFactor = environment.pinFactor + + @Provides + @SingleIn(AppScope::class) + fun provideBiometricManager(): BiometricManager = NoBiometricManager() + + @Provides + fun provideMainThreadTimer(environment: DesktopEnvironment): MainThreadTimer = environment.timer + + @Provides + @SingleIn(AppScope::class) + fun provideLockoutClock(environment: DesktopEnvironment): LockoutClock = OsUptimeClock(environment.bootClock) + + @Provides + @SingleIn(AppScope::class) + fun provideDesktopDeviceLockWatcher(environment: DesktopEnvironment): DesktopDeviceLockWatcher = + DesktopDeviceLockWatcher(environment.lockSignals) + + @Provides + fun provideDeviceLockWatcher(watcher: DesktopDeviceLockWatcher): DeviceLockWatcher = watcher + + @Provides + @SingleIn(AppScope::class) + fun provideLifecycle(): DesktopAppLifecycle = DesktopAppLifecycle() + + @Provides + @SingleIn(AppScope::class) + fun provideAppStrings(): AppStrings = ComposeAppStrings() + + @Provides + @SingleIn(AppScope::class) + fun provideHandoffStagingSweeper(cache: CacheDirectory): HandoffStagingSweeper = + HandoffStagingSweeper { HandoffStaging.sweep(Path(cache.path, HandoffStaging.DIR_NAME)) } +} + +/** The platform services of `:shared` (`com.pasich.encly.platform`) and the host's own window helpers. */ +@ContributesTo(AppScope::class) +@BindingContainer +object DesktopServicesModule { + @Provides + @SingleIn(AppScope::class) + fun provideDocuments(): PlatformDocuments = DesktopDocuments() + + @Provides + @SingleIn(AppScope::class) + fun provideNoteDocuments(lifecycle: DesktopAppLifecycle): NoteDocumentRenderer = + DesktopNoteDocuments(AwtPrinting(lifecycle)) + + @Provides + @SingleIn(AppScope::class) + fun provideExternalLinks(environment: DesktopEnvironment): ExternalLinks = + DesktopExternalLinks(environment.linkOpener) + + @Provides + @SingleIn(AppScope::class) + fun provideToaster(): DesktopToaster = DesktopToaster() + + @Provides + fun provideToasterInterface(toaster: DesktopToaster): Toaster = toaster + + @Provides + @SingleIn(AppScope::class) + fun provideClipboardWiper(environment: DesktopEnvironment): ClipboardWiper = + ClipboardWiper(environment.systemClipboard, environment.timer, writer = environment.clipboardWriter) + + @Provides + @SingleIn(AppScope::class) + fun provideClipboard(environment: DesktopEnvironment, wiper: ClipboardWiper): SensitiveClipboard = + DesktopSensitiveClipboard(environment.systemClipboard, wiper) + + @Provides + @SingleIn(AppScope::class) + fun provideLocales(flags: KeyValueStore): DesktopAppLocaleController = DesktopAppLocaleController(flags) + + @Provides + fun provideLocaleController(controller: DesktopAppLocaleController): com.pasich.encly.platform.AppLocaleController = + controller + + @Provides + @SingleIn(AppScope::class) + fun provideAppInfo(): AppInfo = DesktopAppInfo.fromBuild() + + @Provides + @SingleIn(AppScope::class) + fun provideCacheDirectory(environment: DesktopEnvironment): CacheDirectory = + DesktopCacheDirectory(Path(environment.dataDirectory.resolve(DataFiles.CACHE_DIR).toString())) + + @Provides + fun provideDeviceCapabilities(): DeviceCapabilities = DesktopDeviceCapabilities +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt new file mode 100644 index 00000000..395b6f4f --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt @@ -0,0 +1,98 @@ +package com.pasich.encly.desktop.documents + +import com.pasich.encly.data.export.NoteDocument +import com.pasich.encly.platform.NoteDocumentRenderer +import com.pasich.encly.platform.PrintResult +import kotlinx.io.Sink +import kotlinx.io.asOutputStream +import org.apache.fontbox.ttf.TTFParser +import org.apache.pdfbox.io.IOUtils +import org.apache.pdfbox.io.RandomAccessReadBuffer +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.common.PDRectangle +import org.apache.pdfbox.pdmodel.font.PDType0Font +import org.apache.pdfbox.printing.PDFPageable +import java.awt.print.Pageable +import java.io.IOException +import java.util.Locale + +/** Hands a finished document to the system print dialog and the printer. */ +fun interface PrintLauncher { + /** + * Asks the user, then prints [pages]; [PrintResult.CANCELLED] when the dialog was closed or [mayProceed] said no + * (after the dialog, or before a page). Throws on failure. + */ + suspend fun print(pages: Pageable, mayProceed: () -> Boolean): PrintResult +} + +/** + * [NoteDocumentRenderer] of the desktop: a note as a PDF (Apache PDFBox, built entirely in memory with the + * app's own embedded fonts) written to the stream the user picked, or the same pages sent to [printer]. + * + * Nothing is written anywhere but the given stream: PDFBox is told to keep its working data in memory, the two fonts + * are embedded (so no system font is looked up, and no font cache is created), and no network is used. + */ +class DesktopNoteDocuments( + private val printer: PrintLauncher, + private val fontBytes: () -> ByteArray = ::bundledFont, + private val arabicFontBytes: () -> ByteArray = ::bundledArabicFont, + private val paper: () -> PDRectangle = ::paperOf, +) : NoteDocumentRenderer { + override fun writePdf(document: NoteDocument, out: Sink) { + build(document).use { pdf -> pdf.save(out.asOutputStream()) } + } + + override suspend fun print(document: NoteDocument, mayProceed: () -> Boolean): PrintResult { + // Rendered before the dialog opens, so a note that cannot be drawn fails before the user is asked anything. + // The document is closed when the print ends, however it ends (a lock included). + return build(document).use { pdf -> printer.print(PDFPageable(pdf), mayProceed) } + } + + /** The PDF of [note], open in memory until the caller closes it. */ + internal fun build(note: NoteDocument): PDDocument { + val pdf = PDDocument(IOUtils.createMemoryOnlyStreamCache()) + var built = false + try { + // Latin and Cyrillic first, so Arabic is only drawn with the Arabic font where the first has no glyph. + val layout = TextLayout(listOf(faceOf(pdf, fontBytes()), faceOf(pdf, arabicFontBytes()))) + PdfComposer(pdf, layout, paper()).use { it.compose(note) } + built = true + return pdf + } finally { + // A document that could not be drawn is closed here; a finished one is the caller's. + if (!built) pdf.close() + } + } + + private fun faceOf(pdf: PDDocument, bytes: ByteArray): Face { + val ttf = TTFParser().parse(RandomAccessReadBuffer(bytes)) + val lookup = ttf.unicodeCmapLookup + return Face(PDType0Font.load(pdf, ttf, true)) { point -> lookup.getGlyphId(point) > 0 } + } + + companion object { + /** Where the app's fonts are in the classpath (the shared module's resources). */ + const val FONT_RESOURCE = "composeResources/com.pasich.encly.shared.resources/font/source_sans3_variable.ttf" + + /** Where the Arabic font is in the classpath (the desktop app's resources). */ + const val ARABIC_FONT_RESOURCE = "fonts/NotoSansArabic-Regular.ttf" + + /** The embedded font: Source Sans 3, which covers Latin (with the Central European letters) and Cyrillic. */ + fun bundledFont(): ByteArray = resource(FONT_RESOURCE) + + /** The embedded font for Arabic script: Noto Sans Arabic, with the presentation forms the shaping produces. */ + fun bundledArabicFont(): ByteArray = resource(ARABIC_FONT_RESOURCE) + + private fun resource(name: String): ByteArray { + val stream = DesktopNoteDocuments::class.java.classLoader.getResourceAsStream(name) + ?: throw IOException("The document font is missing from the application") + return stream.use { it.readBytes() } + } + + private val LETTER_REGIONS = setOf("US", "CA", "MX", "CL", "CO", "VE", "PH") + + /** Letter in the countries that use it, A4 everywhere else. */ + fun paperOf(): PDRectangle = + if (Locale.getDefault().country in LETTER_REGIONS) PDRectangle.LETTER else PDRectangle.A4 + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/PdfComposer.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/PdfComposer.kt new file mode 100644 index 00000000..384f8a69 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/PdfComposer.kt @@ -0,0 +1,353 @@ +package com.pasich.encly.desktop.documents + +import com.pasich.encly.data.export.DocumentBlock +import com.pasich.encly.data.export.ListKind +import com.pasich.encly.data.export.NoteDocument +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.PDPage +import org.apache.pdfbox.pdmodel.PDPageContentStream +import org.apache.pdfbox.pdmodel.common.PDRectangle +import org.apache.pdfbox.pdmodel.graphics.state.RenderingMode +import org.apache.pdfbox.util.Matrix +import java.awt.Color +import java.io.Closeable + +/** The text sizes, margins and colours of a printed note, in PDF points (1/72 inch). */ +@Suppress("MagicNumber") // The page's geometry and palette themselves. +internal object PageStyle { + const val MARGIN = 54f + const val BODY = 11f + const val LEADING = 1.38f + const val BLOCK_GAP = 7f + const val INDENT = 18f + val HEADING_SIZES = floatArrayOf(22f, 17f, 14f, 12f) + const val TITLE_SIZE = 24f + const val META_SIZE = 9f + val INK = Color(0x1A, 0x1A, 0x1A) + val QUIET = Color(0x6B, 0x6B, 0x6B) + val RULE = Color(0xB5, 0xB5, 0xB5) + const val SYNTHETIC_BOLD = 0.035f +} + +/** + * Draws a [NoteDocument] onto PDF pages: the title, then headings, paragraphs, quotes, lists, checklists, links + * (as text) and rules, flowing onto as many pages as it needs. The embedded fonts of [layout] draw everything + * (Latin and Cyrillic, Arabic); a character none of them has is drawn as `?`, never an error. A block reads in the + * direction of its first strong character: right-to-left blocks are right-aligned, with their markers and bars on + * the right. Bold is the same font, stroked a little. Nothing here reads a file or uses the network. + */ +@Suppress("MagicNumber", "TooManyFunctions") // One page layout: proportions of a glyph and a drawing step each. +internal class PdfComposer( + private val document: PDDocument, + private val layout: TextLayout, + private val paper: PDRectangle, +) : Closeable { + private var stream: PDPageContentStream? = null + private var y = 0f + private val left = PageStyle.MARGIN + private val width = paper.width - 2 * PageStyle.MARGIN + private val right = left + width + + fun compose(note: NoteDocument) { + newPage() + paragraph(note.title, PageStyle.TITLE_SIZE, bold = true, color = PageStyle.INK) + if (note.tags.isNotEmpty()) { + paragraph(note.tags.joinToString(", "), PageStyle.META_SIZE, color = PageStyle.QUIET) + } + y -= PageStyle.BLOCK_GAP + note.blocks.forEach { block -> + draw(block) + y -= PageStyle.BLOCK_GAP + } + } + + override fun close() { + stream?.close() + stream = null + } + + private fun draw(block: DocumentBlock) { + when (block) { + is DocumentBlock.Heading -> { + val size = PageStyle.HEADING_SIZES[(block.level - 1).coerceIn(0, PageStyle.HEADING_SIZES.lastIndex)] + // A heading is never the last line of a page. + ensureRoom(size * PageStyle.LEADING + PageStyle.BODY * PageStyle.LEADING * 2) + paragraph(block.text, size, bold = true) + } + + is DocumentBlock.Paragraph -> paragraph(block.text, PageStyle.BODY) + + is DocumentBlock.Quote -> quote(block.text) + + is DocumentBlock.Link -> link(block) + + DocumentBlock.Separator -> rule() + + is DocumentBlock.Items -> items(block) + } + } + + private fun quote(text: String) { + val rtl = layout.isRtl(text) + val from = if (rtl) left else left + PageStyle.INDENT + val to = if (rtl) right - PageStyle.INDENT else right + wrap(clean(text), PageStyle.BODY, width - PageStyle.INDENT).forEach { line -> + ensureRoom(lineHeight(PageStyle.BODY)) + val top = y + text(line, rtl, Column(from, to), PageStyle.BODY, PageStyle.QUIET) + // The quote's bar runs beside every line of the block, page by page. + bar(if (rtl) right - 3f else left + 3f, top, y) + } + } + + private fun link(block: DocumentBlock.Link) { + if (block.title.isNotEmpty() && block.title != block.url) { + paragraph(block.title, PageStyle.BODY) + } + // Only the address as text: nothing in the file opens it by itself. + paragraph(block.url, PageStyle.BODY - 1f, color = PageStyle.QUIET) + } + + private fun items(block: DocumentBlock.Items) { + block.items.forEachIndexed { index, item -> + val rtl = layout.isRtl(item.text) + val from = if (rtl) left else left + PageStyle.INDENT + MARKER_ROOM + val to = if (rtl) right - PageStyle.INDENT - MARKER_ROOM else right + val lines = wrap(clean(item.text), PageStyle.BODY, width - PageStyle.INDENT - MARKER_ROOM) + ensureRoom(lineHeight(PageStyle.BODY)) + val baseline = y - PageStyle.BODY + // The marker sits at the start of the item: on the left for left-to-right text, else on the right. + val markerEdge = if (rtl) right - PageStyle.INDENT else left + PageStyle.INDENT + val side = PageStyle.BODY - 1f + when (block.kind) { + ListKind.BULLET -> + bullet(if (rtl) markerEdge - 3f else markerEdge + 3f, baseline + PageStyle.BODY * 0.32f) + + ListKind.NUMBERED -> drawNumber( + "${index + 1}.", + rtl, + if (rtl) markerEdge + 2f else markerEdge - 2f, + baseline, + ) + + ListKind.CHECKLIST -> + checkbox(if (rtl) markerEdge - side else markerEdge, baseline, item.checked) + } + lines.forEach { line -> + ensureRoom(lineHeight(PageStyle.BODY)) + text(line, rtl, Column(from, to), PageStyle.BODY, if (item.checked) PageStyle.QUIET else PageStyle.INK) + } + y -= LIST_GAP + } + } + + private fun paragraph(text: String, size: Float, bold: Boolean = false, color: Color = PageStyle.INK) { + val rtl = layout.isRtl(text) + wrap(clean(text), size, width).forEach { line -> + ensureRoom(lineHeight(size)) + text(line, rtl, Column(left, right), size, color, bold) + } + } + + /** Where text may run: from the [from] x to the [to] x. */ + private class Column(val from: Float, val to: Float) + + /** + * Draws [line] at the cursor, against the left edge or the right edge of [column] as its direction [rtl] + * says, and moves the cursor down one line. + */ + private fun text(line: String, rtl: Boolean, column: Column, size: Float, color: Color, bold: Boolean = false) { + val baseline = y - size + if (line.isNotEmpty()) { + val runs = layout.visual(line, rtl) + drawRuns(runs, if (rtl) column.to - lineWidth(runs, size) else column.from, baseline, size, color, bold) + } + y -= lineHeight(size) + } + + /** Draws a list number on [baseline] with its end at [edge]: the right end for [rtl] text, else the left end. */ + private fun drawNumber(text: String, rtl: Boolean, edge: Float, baseline: Float) { + val runs = layout.visual(text, rtl) + drawRuns( + runs, + if (rtl) edge - lineWidth(runs, PageStyle.BODY) else edge, + baseline, + PageStyle.BODY, + PageStyle.INK, + ) + } + + private fun lineWidth(runs: List, size: Float): Float = + runs.sumOf { it.face.font.getStringWidth(it.text).toDouble() }.toFloat() / FONT_UNITS * size + + private fun drawRuns( + runs: List, + x: Float, + baseline: Float, + size: Float, + color: Color, + bold: Boolean = false, + ) { + val cs = checkNotNull(stream) + cs.beginText() + cs.setNonStrokingColor(color) + if (bold) { + cs.setRenderingMode(RenderingMode.FILL_STROKE) + cs.setStrokingColor(color) + cs.setLineWidth(size * PageStyle.SYNTHETIC_BOLD) + } else { + cs.setRenderingMode(RenderingMode.FILL) + } + cs.setTextMatrix(Matrix.getTranslateInstance(x, baseline)) + // Each run moves the pen on, so the runs of different fonts follow one another. + runs.forEach { run -> + cs.setFont(run.face.font, size) + cs.showText(run.text) + } + cs.endText() + } + + private fun bullet(cx: Float, cy: Float) { + val cs = checkNotNull(stream) + cs.setNonStrokingColor(PageStyle.INK) + circle(cs, cx, cy, BULLET_RADIUS) + cs.fill() + } + + private fun checkbox(x: Float, baseline: Float, checked: Boolean) { + val cs = checkNotNull(stream) + val side = PageStyle.BODY - 1f + val bottom = baseline - 1f + cs.setStrokingColor(PageStyle.INK) + cs.setLineWidth(0.9f) + cs.addRect(x, bottom, side, side) + cs.stroke() + if (checked) { + cs.moveTo(x + side * 0.2f, bottom + side * 0.5f) + cs.lineTo(x + side * 0.43f, bottom + side * 0.22f) + cs.lineTo(x + side * 0.82f, bottom + side * 0.8f) + cs.stroke() + } + } + + private fun rule() { + ensureRoom(PageStyle.BODY) + val cs = checkNotNull(stream) + val at = y - PageStyle.BODY / 2 + cs.setStrokingColor(PageStyle.RULE) + cs.setLineWidth(0.8f) + cs.moveTo(left, at) + cs.lineTo(left + width, at) + cs.stroke() + y -= PageStyle.BODY + } + + private fun bar(x: Float, from: Float, to: Float) { + val cs = checkNotNull(stream) + cs.setStrokingColor(PageStyle.RULE) + cs.setLineWidth(2f) + cs.moveTo(x, from) + cs.lineTo(x, to) + cs.stroke() + } + + private fun circle(cs: PDPageContentStream, cx: Float, cy: Float, r: Float) { + val k = KAPPA * r + cs.moveTo(cx + r, cy) + cs.curveTo(cx + r, cy + k, cx + k, cy + r, cx, cy + r) + cs.curveTo(cx - k, cy + r, cx - r, cy + k, cx - r, cy) + cs.curveTo(cx - r, cy - k, cx - k, cy - r, cx, cy - r) + cs.curveTo(cx + k, cy - r, cx + r, cy - k, cx + r, cy) + cs.closePath() + } + + private fun lineHeight(size: Float) = size * PageStyle.LEADING + + /** Starts a new page when [needed] points do not fit below the cursor. */ + private fun ensureRoom(needed: Float) { + if (y - needed < PageStyle.MARGIN) newPage() + } + + private fun newPage() { + stream?.close() + val page = PDPage(paper) + document.addPage(page) + stream = PDPageContentStream(document, page) + y = paper.height - PageStyle.MARGIN + } + + /** [text] with tabs widened, non-breaking spaces made plain, line ends made `\n` and control characters dropped. */ + private fun clean(text: String): String = buildString(text.length) { + val flat = text.replace("\r\n", "\n").replace('\r', '\n') + flat.forEach { char -> + when { + char == '\n' -> append('\n') + char == '\t' -> append(" ") + char.code == NO_BREAK_SPACE -> append(' ') + Character.isISOControl(char) -> Unit + else -> append(char) + } + } + } + + /** [text] broken into lines that fit [maxWidth] at [size]; an empty line stays an empty line. */ + private fun wrap(text: String, size: Float, maxWidth: Float): List { + val lines = mutableListOf() + text.split('\n').forEach { paragraph -> + if (paragraph.isBlank()) { + lines += "" + return@forEach + } + var current = "" + for (word in paragraph.split(' ')) { + val candidate = if (current.isEmpty()) word else "$current $word" + if (measure(candidate, size) <= maxWidth) { + current = candidate + continue + } + if (current.isNotEmpty()) lines += current + current = "" + var rest = word + // A word wider than the line is broken where it must be. + while (measure(rest, size) > maxWidth) { + val cut = fitting(rest, size, maxWidth) + lines += rest.substring(0, cut) + rest = rest.substring(cut) + } + current = rest + } + lines += current + } + return lines.dropLastWhile { it.isEmpty() }.ifEmpty { listOf("") } + } + + private fun measure(text: String, size: Float): Float = layout.width(text) / FONT_UNITS * size + + /** + * How many chars of [word] fit in [maxWidth] (at least one code point, so the loop always ends). A combining + * mark stays with its letter. + */ + private fun fitting(word: String, size: Float, maxWidth: Float): Int { + var end = word.offsetByCodePoints(0, 1) + while (end < word.length) { + val next = word.offsetByCodePoints(end, 1) + if (measure(word.substring(0, next), size) > maxWidth) break + end = next + } + while (end < word.length && isMark(word.codePointAt(end))) end += Character.charCount(word.codePointAt(end)) + return end + } + + private fun isMark(point: Int) = Character.getType(point).let { + it == Character.NON_SPACING_MARK.toInt() || it == Character.ENCLOSING_MARK.toInt() + } + + private companion object { + const val FONT_UNITS = 1000f + const val MARKER_ROOM = 16f + const val LIST_GAP = 2f + const val BULLET_RADIUS = 1.9f + const val KAPPA = 0.5523f + const val NO_BREAK_SPACE = 0x00A0 + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/TextLayout.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/TextLayout.kt new file mode 100644 index 00000000..c4aa52ce --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/TextLayout.kt @@ -0,0 +1,108 @@ +package com.pasich.encly.desktop.documents + +import com.ibm.icu.text.ArabicShaping +import com.ibm.icu.text.Bidi +import org.apache.pdfbox.pdmodel.font.PDFont +import java.text.Normalizer + +/** One font of the fallback chain with the test for the characters it has a glyph for. */ +internal class Face(val font: PDFont, private val glyphs: (Int) -> Boolean) { + fun covers(point: Int): Boolean = glyphs(point) +} + +/** A stretch of a line drawn with one [face]; [text] is only characters the face has. */ +internal class GlyphRun(val face: Face, val text: String) + +/** + * What PDFBox does not do: choosing a font per character from a fallback chain, joining the letters of cursive + * scripts (Arabic: the contextual forms and the lam-alef ligatures, by ICU4J) and putting a line of mixed + * left-to-right and right-to-left text into reading-on-the-page order (the Unicode bidirectional algorithm, by + * ICU4J). A line is wrapped on its logical text; only the finished line is reordered. The first face is the one + * a character no face has is drawn with, as `?`. Pure computation: nothing here reads a file. + */ +internal class TextLayout(private val faces: List) { + private val shaping = ArabicShaping( + ArabicShaping.LETTERS_SHAPE or ArabicShaping.LENGTH_GROW_SHRINK or ArabicShaping.TEXT_DIRECTION_LOGICAL, + ) + + /** True when [text] reads right to left: its first strong character is Hebrew or Arabic (or a mark for it). */ + fun isRtl(text: CharSequence): Boolean = text.codePoints().toArray().asSequence().firstNotNullOfOrNull { point -> + when (Character.getDirectionality(point)) { + Character.DIRECTIONALITY_LEFT_TO_RIGHT -> false + + Character.DIRECTIONALITY_RIGHT_TO_LEFT, + Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC, + -> true + + else -> null + } + } ?: false + + /** The width of [logical] in font units of 1000 per em, as drawn. */ + fun width(logical: String): Float = + runsOf(shaping.shape(logical)).sumOf { it.face.font.getStringWidth(it.text).toDouble() }.toFloat() + + /** The runs of one [logical] line in the order they are drawn from the left, whatever its [rtl] direction. */ + fun visual(logical: String, rtl: Boolean): List { + val shaped = shaping.shape(logical) + if (shaped.isEmpty()) return emptyList() + val level = if (rtl) Bidi.DIRECTION_RIGHT_TO_LEFT else Bidi.DIRECTION_LEFT_TO_RIGHT + val reordered = Bidi(shaped, level) + .writeReordered(REORDERING) + return runsOf(reordered) + } + + private fun runsOf(text: String): List { + val runs = mutableListOf() + var face: Face? = null + val current = StringBuilder() + var index = 0 + while (index < text.length) { + val point = text.codePointAt(index) + index += Character.charCount(point) + if (isInvisible(point)) continue + val (owner, drawn) = resolve(point) + if (owner !== face && current.isNotEmpty()) { + runs += GlyphRun(checkNotNull(face), current.toString()) + current.clear() + } + face = owner + current.appendCodePoint(drawn) + } + if (current.isNotEmpty()) runs += GlyphRun(checkNotNull(face), current.toString()) + return runs + } + + /** The face and the character it draws for [point]: the base letter of a form the fonts lack, else `?`. */ + private fun resolve(point: Int): Pair { + val base = Normalizer.normalize(String(Character.toChars(point)), Normalizer.Form.NFKC) + val letter = if (base.codePointCount(0, base.length) == 1) base.codePointAt(0) else point + return faces.firstOrNull { it.covers(point) }?.let { it to point } + ?: faces.firstOrNull { it.covers(letter) }?.let { it to letter } + ?: (faces.first() to QUESTION_MARK) + } + + /** Characters that only steer shaping and reordering and have nothing to draw. */ + private fun isInvisible(point: Int) = point in ZERO_WIDTH_FROM..ZERO_WIDTH_TO || + point in BIDI_EMBEDDING_FROM..BIDI_EMBEDDING_TO || + point in BIDI_ISOLATE_FROM..BIDI_ISOLATE_TO || + point in VARIATION_FROM..VARIATION_TO || + point == ARABIC_LETTER_MARK + + private companion object { + const val REORDERING = ( + Bidi.DO_MIRRORING.toInt() or Bidi.KEEP_BASE_COMBINING.toInt() or + Bidi.REMOVE_BIDI_CONTROLS.toInt() + ) + const val QUESTION_MARK = '?'.code + const val ZERO_WIDTH_FROM = 0x200B + const val ZERO_WIDTH_TO = 0x200F + const val BIDI_EMBEDDING_FROM = 0x202A + const val BIDI_EMBEDDING_TO = 0x202E + const val BIDI_ISOLATE_FROM = 0x2066 + const val BIDI_ISOLATE_TO = 0x2069 + const val VARIATION_FROM = 0xFE00 + const val VARIATION_TO = 0xFE0F + const val ARABIC_LETTER_MARK = 0x061C + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/AwtPrinting.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/AwtPrinting.kt new file mode 100644 index 00000000..e5436415 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/AwtPrinting.kt @@ -0,0 +1,82 @@ +package com.pasich.encly.desktop.nativeos + +import com.pasich.encly.desktop.documents.PrintLauncher +import com.pasich.encly.desktop.security.DesktopAppLifecycle +import com.pasich.encly.platform.PrintResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.GraphicsEnvironment +import java.awt.print.PageFormat +import java.awt.print.Pageable +import java.awt.print.Printable +import java.awt.print.PrinterAbortException +import java.awt.print.PrinterException +import java.awt.print.PrinterJob +import java.io.IOException + +/** + * The system print dialog and printer through `java.awt.print`. The dialog is the OS's own and is modal; while it + * (and the job) is open the app counts as in front ([lifecycle]), so taking the focus away from the window is not + * mistaken for leaving the app, which would lock the vault in the middle of a print. Needs a display: it is + * outside the coverage gate, like the other code that talks to the OS. + * + * The dialog cannot be closed from outside, so a lock that comes while it is open is only seen when it returns: + * `mayProceed` is asked then, and again before every page, and a job that may not go on is cancelled. + */ +class AwtPrinting(private val lifecycle: DesktopAppLifecycle) : PrintLauncher { + override suspend fun print(pages: Pageable, mayProceed: () -> Boolean): PrintResult { + if (GraphicsEnvironment.isHeadless()) throw IOException("There is no display to print from") + lifecycle.beginOwnDialog() + try { + val job = PrinterJob.getPrinterJob() + job.setPageable(GuardedPages(pages, job, mayProceed)) + job.jobName = JOB_NAME + val confirmed = withContext(Dispatchers.Main.immediate) { job.printDialog() } + if (!confirmed || !mayProceed()) return PrintResult.CANCELLED + // Spooling a long job is not for the UI thread. + val printed = withContext(Dispatchers.IO) { printJob(job) } + return if (printed) PrintResult.PRINTED else PrintResult.CANCELLED + } finally { + // Back on the caller's thread (the UI thread), where the lifecycle is driven. + lifecycle.endOwnDialog() + } + } + + /** True when the job went to the printer, false when it was cancelled on the way. */ + private fun printJob(job: PrinterJob): Boolean = try { + job.print() + true + } catch (_: PrinterAbortException) { + false + } catch (e: PrinterException) { + throw IOException("Printing failed", e) + } + + private companion object { + // A generic name: the job's name is shown in the system's print queue. + const val JOB_NAME = "Encly note" + } +} + +/** [pages] that cancel [job] as soon as [mayProceed] says the print must stop: no further page is handed out. */ +private class GuardedPages( + private val pages: Pageable, + private val job: PrinterJob, + private val mayProceed: () -> Boolean, +) : Pageable { + override fun getNumberOfPages(): Int = pages.numberOfPages + + override fun getPageFormat(pageIndex: Int): PageFormat { + check() + return pages.getPageFormat(pageIndex) + } + + override fun getPrintable(pageIndex: Int): Printable { + check() + return pages.getPrintable(pageIndex) + } + + private fun check() { + if (!mayProceed()) job.cancel() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeBackupExclusion.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeBackupExclusion.kt new file mode 100644 index 00000000..c3b12cfa --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeBackupExclusion.kt @@ -0,0 +1,44 @@ +@file:Suppress("FunctionNaming", "ktlint:standard:function-naming", "TooGenericExceptionCaught") + +package com.pasich.encly.desktop.nativeos + +import com.pasich.encly.desktop.storage.BackupExclusion +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.desktop.storage.TimeMachineExclusion +import com.sun.jna.Library +import com.sun.jna.Native +import java.nio.file.Path + +// JNA glue: it only runs against the real OS and is excluded from the coverage gate (the attribute value it writes, +// TimeMachineExclusion, is tested). + +/** The backup exclusion of [os]; nothing to do outside macOS. */ +object NativeBackupExclusion { + fun forOs(os: DesktopOs): BackupExclusion = + if (os == DesktopOs.MACOS) MacBackupExclusion() else BackupExclusion { true } +} + +private interface MacLibC : Library { + @Suppress("LongParameterList") // The signature of setxattr(2). + fun setxattr(path: String, name: String, value: ByteArray, size: Long, position: Int, options: Int): Int +} + +/** macOS: the sticky Time Machine exclusion, an extended attribute on the directory itself. */ +private class MacBackupExclusion : BackupExclusion { + private val libc: MacLibC? = try { + Native.load("c", MacLibC::class.java) + } catch (_: Throwable) { + null + } + + override fun exclude(directory: Path): Boolean { + val lib = libc ?: return false + val value = TimeMachineExclusion.attributeValue() + val path = directory.toAbsolutePath().toString() + return try { + lib.setxattr(path, TimeMachineExclusion.ATTRIBUTE, value, value.size.toLong(), 0, 0) == 0 + } catch (_: Throwable) { + false + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeBootClock.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeBootClock.kt new file mode 100644 index 00000000..bb55cfff --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeBootClock.kt @@ -0,0 +1,110 @@ +@file:Suppress("FunctionNaming", "ktlint:standard:function-naming", "TooGenericExceptionCaught", "ReturnCount") + +package com.pasich.encly.desktop.nativeos + +import com.pasich.encly.desktop.security.BootClockSource +import com.pasich.encly.desktop.security.parseProcUptime +import com.pasich.encly.desktop.storage.DesktopOs +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.ptr.LongByReference +import com.sun.jna.win32.StdCallLibrary +import java.nio.file.Files +import java.nio.file.Paths + +// The boot clock of each OS. JNA glue: it only runs against the real OS and is excluded from the coverage gate +// (the pure parts, parseProcUptime and OsUptimeClock, are tested). + +/** Picks the native uptime reader of [os]. Never throws: a reader that cannot start answers null. */ +object NativeBootClock { + fun forOs(os: DesktopOs): BootClockSource = when (os) { + DesktopOs.LINUX -> LinuxBootClock + DesktopOs.MACOS -> MacBootClock() + DesktopOs.WINDOWS -> WindowsBootClock() + } +} + +/** /proc/uptime counts suspend (it is CLOCK_BOOTTIME); /proc/sys/kernel/random/boot_id is new on every boot. */ +private object LinuxBootClock : BootClockSource { + override fun uptimeMillis(): Long? = try { + parseProcUptime(Files.readString(Paths.get("/proc/uptime"))) + } catch (_: Exception) { + null + } + + override fun bootId(): String? = try { + Files.readString(Paths.get("/proc/sys/kernel/random/boot_id")).trim().takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } +} + +private interface MacSystem : Library { + fun mach_continuous_time(): Long + + fun mach_timebase_info(info: IntArray): Int + + fun sysctlbyname(name: String, oldp: Pointer?, oldlenp: LongByReference, newp: Pointer?, newlen: Long): Int +} + +/** mach_continuous_time keeps counting through sleep; kern.bootsessionuuid is new on every boot. */ +private class MacBootClock : BootClockSource { + private val system: MacSystem? = try { + Native.load("System", MacSystem::class.java) + } catch (_: Throwable) { + null + } + private val timebase: IntArray? = system?.let { + IntArray(2).takeIf { info -> it.mach_timebase_info(info) == 0 && info[1] != 0 } + } + + override fun uptimeMillis(): Long? { + val lib = system ?: return null + val base = timebase ?: return null + return try { + lib.mach_continuous_time() * base[0] / base[1] / NANOS_PER_MILLI + } catch (_: Throwable) { + null + } + } + + override fun bootId(): String? { + val lib = system ?: return null + return try { + val size = LongByReference(0) + if (lib.sysctlbyname(BOOT_SESSION_UUID, null, size, null, 0) != 0 || size.value <= 0) return null + val buffer = com.sun.jna.Memory(size.value) + if (lib.sysctlbyname(BOOT_SESSION_UUID, buffer, size, null, 0) != 0) return null + buffer.getString(0) + } catch (_: Throwable) { + null + } + } + + private companion object { + const val NANOS_PER_MILLI = 1_000_000L + const val BOOT_SESSION_UUID = "kern.bootsessionuuid" + } +} + +private interface WindowsKernel : StdCallLibrary { + fun GetTickCount64(): Long +} + +/** GetTickCount64 counts since boot, sleep included. Windows gives no boot id; the uptime comparison covers it. */ +private class WindowsBootClock : BootClockSource { + private val kernel: WindowsKernel? = try { + Native.load("kernel32", WindowsKernel::class.java) + } catch (_: Throwable) { + null + } + + override fun uptimeMillis(): Long? = try { + kernel?.GetTickCount64() + } catch (_: Throwable) { + null + } + + override fun bootId(): String? = null +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeCaptureExclusion.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeCaptureExclusion.kt new file mode 100644 index 00000000..5e8c44ce --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeCaptureExclusion.kt @@ -0,0 +1,175 @@ +@file:Suppress("FunctionNaming", "ktlint:standard:function-naming", "TooGenericExceptionCaught", "ReturnCount") + +package com.pasich.encly.desktop.nativeos + +import com.pasich.encly.desktop.platform.CaptureExclusion +import com.pasich.encly.desktop.storage.DesktopOs +import com.sun.jna.Callback +import com.sun.jna.FunctionMapper +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.NativeLibrary +import com.sun.jna.Pointer +import com.sun.jna.platform.win32.WinDef +import com.sun.jna.win32.StdCallLibrary +import java.awt.Window +import java.lang.reflect.Method +import java.util.Collections + +// Keeps the window out of screenshots and screen recordings, per OS. JNA glue: it only runs against a real +// window and is excluded from the coverage gate. + +/** The capture exclusion of [os]; Linux has none (see [UnavailableCaptureExclusion]). */ +object NativeCaptureExclusion { + fun forOs(os: DesktopOs): CaptureExclusion = when (os) { + DesktopOs.WINDOWS -> WindowsCaptureExclusion() + DesktopOs.MACOS -> MacCaptureExclusion() + DesktopOs.LINUX -> UnavailableCaptureExclusion + } +} + +/** + * X11 and Wayland have no way for an application to keep a window out of the compositor's screenshots and + * recordings, so nothing is done on Linux. The vault is still locked on idle, focus loss and screen lock. + */ +object UnavailableCaptureExclusion : CaptureExclusion { + override val supported: Boolean = false + + override fun exclude(window: Window): Boolean = false +} + +private interface WindowsUser32Display : StdCallLibrary { + fun SetWindowDisplayAffinity(hwnd: WinDef.HWND, affinity: Int): Boolean +} + +/** + * Windows 10 version 2004 and later: `WDA_EXCLUDEFROMCAPTURE` removes the window from every capture. Earlier + * versions only know `WDA_MONITOR`, which shows the window as a black rectangle in a capture; that is the + * fallback. + */ +private class WindowsCaptureExclusion : CaptureExclusion { + private val user32: WindowsUser32Display? = try { + Native.load("user32", WindowsUser32Display::class.java) + } catch (_: Throwable) { + null + } + + override val supported: Boolean get() = user32 != null + + override fun exclude(window: Window): Boolean { + val lib = user32 ?: return false + return try { + val hwnd = WinDef.HWND(Native.getComponentPointer(window)) + lib.SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE) || + lib.SetWindowDisplayAffinity(hwnd, WDA_MONITOR) + } catch (_: Throwable) { + false + } + } + + private companion object { + const val WDA_MONITOR = 0x01 + const val WDA_EXCLUDEFROMCAPTURE = 0x11 + } +} + +private interface ObjectiveC : Library { + fun objc_getClass(name: String): Pointer? + + fun sel_registerName(name: String): Pointer + + // objc_msgSend is not variadic in practice (and a variadic call is wrong on arm64): one declaration per + // signature, all mapped to the same native symbol by [MSG_SEND_MAPPER]. + fun objc_msgSend_ptr(receiver: Pointer, selector: Pointer): Pointer? + + fun objc_msgSend_long(receiver: Pointer, selector: Pointer): Long + + fun objc_msgSend_ptrIndex(receiver: Pointer, selector: Pointer, index: Long): Pointer? + + fun objc_msgSend_voidLong(receiver: Pointer, selector: Pointer, value: Long) +} + +private interface Dispatch : Library { + fun dispatch_async_f(queue: Pointer, context: Pointer?, work: DispatchWork) +} + +private fun interface DispatchWork : Callback { + fun invoke(context: Pointer?) +} + +private val MSG_SEND_MAPPER = FunctionMapper { _: NativeLibrary, method: Method -> + if (method.name.startsWith("objc_msgSend")) "objc_msgSend" else method.name +} + +/** + * macOS: `NSWindow.sharingType = NSWindowSharingNone` for every window of the app, so the window is left out + * of `screencapture`, screen sharing and recordings by the window server. It is reached through the + * Objective-C runtime (no access to AWT's private peer classes is needed) and applied on the AppKit main + * thread. Best effort: Apple's newer ScreenCaptureKit based tools can ignore the flag on macOS 15 and later, + * which is a limit of the OS and not of the call. + */ +private class MacCaptureExclusion : CaptureExclusion { + private val objc: ObjectiveC? = try { + Native.load("objc", ObjectiveC::class.java, mapOf(Library.OPTION_FUNCTION_MAPPER to MSG_SEND_MAPPER)) + } catch (_: Throwable) { + null + } + private val dispatch: Dispatch? = try { + Native.load("System", Dispatch::class.java) + } catch (_: Throwable) { + null + } + private val mainQueue: Pointer? = try { + NativeLibrary.getInstance("System").getGlobalVariableAddress("_dispatch_main_q") + } catch (_: Throwable) { + null + } + + // The native side calls these after dispatch_async_f returns: they must not be garbage collected before. + private val pending = Collections.synchronizedSet(HashSet()) + + override val supported: Boolean get() = objc != null && dispatch != null && mainQueue != null + + override fun exclude(window: Window): Boolean { + val runtime = objc ?: return false + val lib = dispatch ?: return false + val queue = mainQueue ?: return false + lateinit var work: DispatchWork + work = DispatchWork { + try { + hideAllWindows(runtime) + } finally { + pending -= work + } + } + pending += work + return try { + lib.dispatch_async_f(queue, null, work) + true + } catch (_: Throwable) { + pending -= work + false + } + } + + private fun hideAllWindows(runtime: ObjectiveC) { + try { + val application = runtime.objc_getClass("NSApplication") ?: return + val shared = runtime.objc_msgSend_ptr(application, runtime.sel_registerName("sharedApplication")) ?: return + val windows = runtime.objc_msgSend_ptr(shared, runtime.sel_registerName("windows")) ?: return + val count = runtime.objc_msgSend_long(windows, runtime.sel_registerName("count")) + val objectAt = runtime.sel_registerName("objectAtIndex:") + val setSharing = runtime.sel_registerName("setSharingType:") + for (index in 0 until count) { + val nsWindow = runtime.objc_msgSend_ptrIndex(windows, objectAt, index) ?: continue + runtime.objc_msgSend_voidLong(nsWindow, setSharing, NS_WINDOW_SHARING_NONE) + } + } catch (_: Throwable) { + // The window stays capturable; nothing else can be done from here. + } + } + + private companion object { + const val NS_WINDOW_SHARING_NONE = 0L + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeClipboard.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeClipboard.kt new file mode 100644 index 00000000..cc906b8e --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeClipboard.kt @@ -0,0 +1,268 @@ +@file:Suppress("FunctionNaming", "ktlint:standard:function-naming", "TooGenericExceptionCaught", "ReturnCount") + +package com.pasich.encly.desktop.nativeos + +import com.pasich.encly.desktop.platform.ClipboardWriter +import com.pasich.encly.desktop.storage.DesktopOs +import com.sun.jna.Function +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.NativeLibrary +import com.sun.jna.Pointer +import com.sun.jna.WString +import com.sun.jna.platform.win32.BaseTSD +import com.sun.jna.platform.win32.User32 +import com.sun.jna.platform.win32.WinDef +import com.sun.jna.platform.win32.WinUser +import com.sun.jna.win32.StdCallLibrary +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +// Text on the system clipboard as a clip that clipboard history, cloud sync and Universal Clipboard must not keep. +// AWT has no way to say that, so the clip is written with the OS's own calls. JNA glue: it only runs against the real +// OS and is excluded from the coverage gate (the code that chooses between it and AWT is tested with a fake). + +/** The sensitive clipboard writer of [os], or null where the OS has no such markers (Linux) or can't be set up. */ +object NativeClipboard { + fun forOs(os: DesktopOs): ClipboardWriter? = when (os) { + DesktopOs.MACOS -> MacPasteboard.create() + DesktopOs.WINDOWS -> WindowsClipboard.create() + DesktopOs.LINUX -> null + } +} + +/** + * macOS: `NSPasteboard` with the types of nspasteboard.org next to the text: `org.nspasteboard.ConcealedType` (a + * password-like clip, which clipboard managers skip) and `org.nspasteboard.TransientType` (not to be kept). The + * Objective-C runtime is called through JNA with the fixed-argument convention of `objc_msgSend`. + */ +internal class MacPasteboard private constructor(private val objc: NativeLibrary) : ClipboardWriter { + private val msgSend: Function = objc.getFunction("objc_msgSend") + private val getClass: Function = objc.getFunction("objc_getClass") + private val registerSelector: Function = objc.getFunction("sel_registerName") + + override fun write(text: String): Boolean = try { + val pasteboard = send(classNamed("NSPasteboard"), "generalPasteboard") ?: return false + send(pasteboard, "clearContents") + val marker = nsString("") + try { + val value = nsString(text) + try { + put(pasteboard, value, TEXT_TYPE) + put(pasteboard, marker, CONCEALED_TYPE) + put(pasteboard, marker, TRANSIENT_TYPE) + } finally { + release(value) + } + } finally { + release(marker) + } + true + } catch (_: Throwable) { + false + } + + /** The types on the general pasteboard now, for the real-OS test. */ + fun types(): List { + val pasteboard = send(classNamed("NSPasteboard"), "generalPasteboard") ?: return emptyList() + val types = send(pasteboard, "types") ?: return emptyList() + val count = Pointer.nativeValue(send(types, "count")) + return (0 until count).mapNotNull { index -> + val type = send(types, "objectAtIndex:", index) ?: return@mapNotNull null + send(type, "UTF8String")?.getString(0) + } + } + + private fun put(pasteboard: Pointer, value: Pointer, type: String) { + val name = nsString(type) + try { + send(pasteboard, "setString:forType:", value, name) + } finally { + release(name) + } + } + + /** An owned (+1) `NSString`, which the caller releases: no autorelease pool is needed on a thread without one. */ + private fun nsString(value: String): Pointer { + val bytes = value.toByteArray(Charsets.UTF_8) + val memory = Memory(bytes.size + 1L) + memory.write(0, bytes, 0, bytes.size) + memory.setByte(bytes.size.toLong(), 0) + val allocated = send(classNamed("NSString"), "alloc") ?: error("NSString alloc failed") + return send(allocated, "initWithUTF8String:", memory) ?: error("NSString init failed") + } + + private fun release(value: Pointer) { + send(value, "release") + } + + private fun classNamed(name: String): Pointer = getClass.invokePointer(arrayOf(name)) + + private fun send(receiver: Pointer, selector: String, vararg args: Any): Pointer? { + val call = arrayOfNulls(args.size + 2) + call[0] = receiver + call[1] = registerSelector.invokePointer(arrayOf(selector)) + args.forEachIndexed { index, arg -> call[index + 2] = if (arg is Long) com.sun.jna.NativeLong(arg) else arg } + return msgSend.invokePointer(call) + } + + companion object { + const val TEXT_TYPE = "public.utf8-plain-text" + const val CONCEALED_TYPE = "org.nspasteboard.ConcealedType" + const val TRANSIENT_TYPE = "org.nspasteboard.TransientType" + + fun create(): MacPasteboard? = try { + MacPasteboard(NativeLibrary.getInstance("objc")) + } catch (_: Throwable) { + null + } + } +} + +private interface WindowsClipboardApi : StdCallLibrary { + fun OpenClipboard(owner: WinDef.HWND?): Boolean + + fun EmptyClipboard(): Boolean + + fun SetClipboardData(format: Int, handle: Pointer?): Pointer? + + fun CloseClipboard(): Boolean + + fun RegisterClipboardFormatW(name: WString): Int + + fun IsClipboardFormatAvailable(format: Int): Boolean +} + +private interface WindowsMemoryApi : StdCallLibrary { + fun GlobalAlloc(flags: Int, bytes: BaseTSD.SIZE_T): Pointer? + + fun GlobalLock(memory: Pointer): Pointer? + + fun GlobalUnlock(memory: Pointer): Boolean + + fun GlobalFree(memory: Pointer): Pointer? +} + +/** + * Windows: the text as `CF_UNICODETEXT` plus the three registered formats that tell the system to leave the clip + * alone: `ExcludeClipboardContentFromMonitorProcessing` (no clipboard monitors), `CanIncludeInClipboardHistory` = 0 + * (no Win+V history) and `CanUploadToCloudClipboard` = 0 (no sync to the Microsoft account). The clipboard needs an + * owner window to be set at all, so a message-only window of ours owns it. + */ +internal class WindowsClipboard private constructor( + private val clipboard: WindowsClipboardApi, + private val memory: WindowsMemoryApi, + private val owner: WinDef.HWND, +) : ClipboardWriter { + private val markerFormats: List = MARKER_NAMES.map { clipboard.RegisterClipboardFormatW(WString(it)) } + + override fun write(text: String): Boolean = try { + if (!open()) { + false + } else { + try { + clipboard.EmptyClipboard() + val textSet = set(CF_UNICODETEXT, (text + "\u0000").toByteArray(Charsets.UTF_16LE)) + // The markers are DWORDs of 0; their presence is what the system looks for. + val markersSet = markerFormats.all { it != 0 && set(it, ByteArray(DWORD_SIZE)) } + textSet && markersSet + } finally { + clipboard.CloseClipboard() + } + } + } catch (_: Throwable) { + false + } + + /** Whether the clipboard now carries the marker named [name] (the real-OS test asks). */ + fun hasMarker(name: String): Boolean = clipboard.IsClipboardFormatAvailable( + markerFormats[MARKER_NAMES.indexOf(name)], + ) + + /** Another program may hold the clipboard for a moment: a few tries before giving up. */ + private fun open(): Boolean { + repeat(OPEN_TRIES) { + if (clipboard.OpenClipboard(owner)) return true + Thread.sleep(OPEN_PAUSE_MILLIS) + } + return false + } + + /** Hands [bytes] to the clipboard in global memory it then owns; true when it took them. */ + private fun set(format: Int, bytes: ByteArray): Boolean { + val handle = memory.GlobalAlloc(GMEM_MOVEABLE, BaseTSD.SIZE_T(bytes.size.toLong())) ?: return false + val target = memory.GlobalLock(handle) + if (target == null) { + memory.GlobalFree(handle) + return false + } + target.write(0, bytes, 0, bytes.size) + memory.GlobalUnlock(handle) + if (clipboard.SetClipboardData(format, handle) == null) { + memory.GlobalFree(handle) + return false + } + return true + } + + companion object { + const val EXCLUDE_FROM_MONITORS = "ExcludeClipboardContentFromMonitorProcessing" + const val CAN_INCLUDE_IN_HISTORY = "CanIncludeInClipboardHistory" + const val CAN_UPLOAD_TO_CLOUD = "CanUploadToCloudClipboard" + + private val MARKER_NAMES = listOf(EXCLUDE_FROM_MONITORS, CAN_INCLUDE_IN_HISTORY, CAN_UPLOAD_TO_CLOUD) + private const val CF_UNICODETEXT = 13 + private const val GMEM_MOVEABLE = 0x0002 + private const val DWORD_SIZE = 4 + private const val OPEN_TRIES = 5 + private const val OPEN_PAUSE_MILLIS = 20L + private const val OWNER_WINDOW_WAIT_SECONDS = 2L + + fun create(): WindowsClipboard? = try { + val clipboard = Native.load("user32", WindowsClipboardApi::class.java) + val memory = Native.load("kernel32", WindowsMemoryApi::class.java) + startOwnerWindow()?.let { WindowsClipboard(clipboard, memory, it) } + } catch (_: Throwable) { + null + } + + /** + * The window that owns what we put on the clipboard: a message-only window ("STATIC" is a system class) with + * a thread of its own that pumps its messages. Whoever empties the clipboard next sends it + * `WM_DESTROYCLIPBOARD` and waits for the answer, so a window nobody pumps would hang that program (Encly's + * own wipe included). + */ + private fun startOwnerWindow(): WinDef.HWND? { + val ready = CountDownLatch(1) + val window = AtomicReference() + Thread({ + val user32 = User32.INSTANCE + val hwnd = user32.CreateWindowEx( + 0, + "STATIC", + "EnclyClipboard", + 0, + 0, + 0, + 0, + 0, + WinUser.HWND_MESSAGE, + null, + null, + null, + ) + window.set(hwnd) + ready.countDown() + if (hwnd != null) { + val message = WinUser.MSG() + while (user32.GetMessage(message, null, 0, 0) > 0) { + user32.TranslateMessage(message) + user32.DispatchMessage(message) + } + } + }, "encly-clipboard-owner").apply { isDaemon = true }.start() + return if (ready.await(OWNER_WINDOW_WAIT_SECONDS, TimeUnit.SECONDS)) window.get() else null + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeScreenLock.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeScreenLock.kt new file mode 100644 index 00000000..a79a4f1f --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeScreenLock.kt @@ -0,0 +1,371 @@ +@file:Suppress("FunctionNaming", "ktlint:standard:function-naming", "TooGenericExceptionCaught", "ReturnCount") + +package com.pasich.encly.desktop.nativeos + +import com.pasich.encly.desktop.security.LinuxLiveLock +import com.pasich.encly.desktop.security.OsLockSignals +import com.pasich.encly.desktop.security.PollingLockSignals +import com.pasich.encly.desktop.security.SleepDetector +import com.pasich.encly.desktop.storage.DesktopOs +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.ptr.LongByReference +import com.sun.jna.win32.StdCallLibrary +import org.freedesktop.dbus.DBusMatchRule +import org.freedesktop.dbus.annotations.DBusInterfaceName +import org.freedesktop.dbus.connections.impl.DBusConnection +import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder +import org.freedesktop.dbus.interfaces.DBusInterface +import org.freedesktop.dbus.interfaces.DBusSigHandler +import org.freedesktop.dbus.interfaces.Properties +import org.freedesktop.dbus.messages.DBusSignal +import java.util.concurrent.Callable +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +// What the OS says about the screen being locked, per OS. JNA and D-Bus glue: it only runs against the real OS +// and is excluded from the coverage gate (PollingLockSignals and DesktopDeviceLockWatcher, which use it, are +// tested with fakes). + +/** The lock signals of [os]. Never throws: a source that cannot start reports nothing. */ +object NativeScreenLock { + fun forOs(os: DesktopOs): OsLockSignals = when (os) { + DesktopOs.MACOS -> PollingLockSignals( + POLL_MILLIS, + MacScreenLockProbe()::isLocked, + SleepDetector(MacSleepClocks()::read)::sleptSinceLastCheck, + ) + + DesktopOs.WINDOWS -> PollingLockSignals( + POLL_MILLIS, + WindowsScreenLockProbe()::isLocked, + SleepDetector(WindowsSleepClocks()::read)::sleptSinceLastCheck, + ) + + DesktopOs.LINUX -> LogindLockSignals() + } + + private const val POLL_MILLIS = 1_500L +} + +private interface MacCoreGraphics : Library { + fun CGSessionCopyCurrentDictionary(): Pointer? +} + +private interface MacCoreFoundation : Library { + fun CFStringCreateWithCString(alloc: Pointer?, cStr: String, encoding: Int): Pointer? + + fun CFDictionaryGetValue(dict: Pointer, key: Pointer): Pointer? + + fun CFBooleanGetValue(boolean: Pointer): Byte + + fun CFRelease(cf: Pointer) +} + +/** + * macOS: the window server's session dictionary has `CGSSessionScreenIsLocked = true` while the lock screen is + * up (also after the screen saver asks for the password). Asked by polling: the distributed notification + * `com.apple.screenIsLocked` needs an Objective-C run loop that the JVM does not have. + */ +private class MacScreenLockProbe { + private val graphics: MacCoreGraphics? = load("CoreGraphics", MacCoreGraphics::class.java) + private val foundation: MacCoreFoundation? = load("CoreFoundation", MacCoreFoundation::class.java) + private val key: Pointer? = foundation?.let { + try { + it.CFStringCreateWithCString(null, LOCKED_KEY, UTF8) + } catch (_: Throwable) { + null + } + } + + fun isLocked(): Boolean { + val cg = graphics ?: return false + val cf = foundation ?: return false + val lockedKey = key ?: return false + return try { + val session = cg.CGSessionCopyCurrentDictionary() ?: return false + try { + val value = cf.CFDictionaryGetValue(session, lockedKey) + value != null && cf.CFBooleanGetValue(value).toInt() != 0 + } finally { + cf.CFRelease(session) + } + } catch (_: Throwable) { + false + } + } + + private fun load(name: String, type: Class): T? = try { + Native.load(name, type) + } catch (_: Throwable) { + null + } + + private companion object { + const val LOCKED_KEY = "CGSSessionScreenIsLocked" + const val UTF8 = 0x08000100 + } +} + +private interface MacTime : Library { + fun mach_continuous_time(): Long + + fun mach_absolute_time(): Long + + fun mach_timebase_info(info: IntArray): Int +} + +/** macOS: `mach_continuous_time` counts the time asleep, `mach_absolute_time` does not. */ +private class MacSleepClocks { + private val time: MacTime? = try { + Native.load("System", MacTime::class.java) + } catch (_: Throwable) { + null + } + private val timebase: IntArray? = time?.let { + IntArray(2).takeIf { info -> it.mach_timebase_info(info) == 0 && info[1] != 0 } + } + + fun read(): SleepDetector.Clocks? { + val lib = time ?: return null + val base = timebase ?: return null + return try { + SleepDetector.Clocks( + counting = lib.mach_continuous_time() * base[0] / base[1] / NANOS_PER_MILLI, + awake = lib.mach_absolute_time() * base[0] / base[1] / NANOS_PER_MILLI, + ) + } catch (_: Throwable) { + null + } + } + + private companion object { + const val NANOS_PER_MILLI = 1_000_000L + } +} + +private interface WindowsTime : StdCallLibrary { + fun GetTickCount64(): Long + + fun QueryUnbiasedInterruptTime(time: LongByReference): Boolean +} + +/** Windows: `GetTickCount64` counts the time asleep, `QueryUnbiasedInterruptTime` (100 ns units) does not. */ +private class WindowsSleepClocks { + private val time: WindowsTime? = try { + Native.load("kernel32", WindowsTime::class.java) + } catch (_: Throwable) { + null + } + + fun read(): SleepDetector.Clocks? { + val lib = time ?: return null + return try { + val awake = LongByReference() + if (!lib.QueryUnbiasedInterruptTime(awake)) return null + SleepDetector.Clocks(counting = lib.GetTickCount64(), awake = awake.value / HUNDRED_NANOS_PER_MILLI) + } catch (_: Throwable) { + null + } + } + + private companion object { + const val HUNDRED_NANOS_PER_MILLI = 10_000L + } +} + +private interface WindowsUser32 : StdCallLibrary { + fun OpenInputDesktop(flags: Int, inherit: Boolean, access: Int): Pointer? + + fun SwitchDesktop(desktop: Pointer): Boolean + + fun CloseDesktop(desktop: Pointer): Boolean +} + +/** + * Windows: while the workstation is locked the input desktop is the secure Winlogon desktop, which an + * application cannot open (`OpenInputDesktop` fails) or switch to. Polled instead of a WTS session + * notification, which needs a window procedure of our own that Compose's window does not expose. + */ +private class WindowsScreenLockProbe { + private val user32: WindowsUser32? = try { + Native.load("user32", WindowsUser32::class.java) + } catch (_: Throwable) { + null + } + + fun isLocked(): Boolean { + val lib = user32 ?: return false + return try { + val desktop = lib.OpenInputDesktop(0, false, DESKTOP_SWITCHDESKTOP) ?: return true + try { + !lib.SwitchDesktop(desktop) + } finally { + lib.CloseDesktop(desktop) + } + } catch (_: Throwable) { + false + } + } + + private companion object { + const val DESKTOP_SWITCHDESKTOP = 0x0100 + } +} + +/** + * Linux: logind's `Lock`/`Unlock` signals of the session (`loginctl lock-session`, a lid close) and the + * manager's `PrepareForSleep` (suspend and hibernate) on the system bus, plus the `ActiveChanged` signal of the + * desktop screen savers on the session bus (GNOME, KDE, Cinnamon, MATE: their idle lock does not go through + * logind). Both buses are local Unix sockets. A bus that is not there is skipped. + * + * The signals only say when a lock starts. Whether the session is locked *now* is asked of the OS ([liveLocked]): + * the session's `LockedHint` and the screen savers' `GetActive`. A locker started by `Session.Lock` (swaylock, + * hyprlock, i3lock through xss-lock) never sends `Unlock`, so a flag fed by the signals alone stays "locked" after + * the user unlocked the desktop. + */ +private class LogindLockSignals : OsLockSignals { + @Volatile + private var systemBus: DBusConnection? = null + + @Volatile + private var sessionBus: DBusConnection? = null + + private val live = LinuxLiveLock( + lockedHint = ::readLockedHint, + screenSaversActive = SCREEN_SAVERS.map { saver -> { readScreenSaverActive(saver) } }, + ) + private val reader: ExecutorService by lazy { + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "encly-screen-lock-read").apply { isDaemon = true } + } + } + + override fun liveLocked(): Boolean { + // Bounded: a bus that does not answer must not hold up the unlock that asks. + val answer = reader.submit(Callable { live.isLocked() }) + return try { + answer.get(READ_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + } catch (_: Exception) { + answer.cancel(true) + false + } + } + + private fun readLockedHint(): Boolean? { + val connection = systemBus ?: return null + val session = connection.getRemoteObject(LOGIND_BUS, LOGIND_SESSION_PATH, Properties::class.java) + return session.Get(LOGIND_SESSION, "LockedHint") + } + + private fun readScreenSaverActive(saver: ScreenSaver): Boolean? { + val connection = sessionBus ?: return null + // Only a screen saver that is running: asking a name nobody owns could start its service. + if (connection.getDBusOwnerName(saver.iface) == null) return null + return saver.isActive(connection) + } + + override fun start(onChange: (locked: Boolean) -> Unit): AutoCloseable { + val connections = listOfNotNull( + subscribe({ DBusConnectionBuilder.forSystemBus() }) { connection -> + systemBus = connection + connection.on(LOGIND_SESSION, "Lock") { onChange(true) } + connection.on(LOGIND_SESSION, "Unlock") { onChange(false) } + connection.on(LOGIND_MANAGER, "PrepareForSleep") { signal -> + onChange(signal.parameters.firstOrNull() as? Boolean ?: true) + } + }, + subscribe({ DBusConnectionBuilder.forSessionBus() }) { connection -> + sessionBus = connection + SCREEN_SAVERS.map { it.iface }.forEach { iface -> + connection.on(iface, "ActiveChanged") { signal -> + onChange(signal.parameters.firstOrNull() as? Boolean ?: false) + } + } + }, + ) + return AutoCloseable { + systemBus = null + sessionBus = null + connections.forEach { runCatching(it::close) } + } + } + + private fun subscribe(builder: () -> DBusConnectionBuilder, register: (DBusConnection) -> Unit): DBusConnection? = + try { + builder().withShared(false).build().also { connection -> + try { + register(connection) + } catch (e: Exception) { + connection.close() + throw e + } + } + } catch (_: Exception) { + null + } + + private fun DBusConnection.on(iface: String, member: String, handler: (DBusSignal) -> Unit) { + addGenericSigHandler(DBusMatchRule("signal", iface, member), DBusSigHandler { handler(it) }) + } + + private companion object { + const val LOGIND_BUS = "org.freedesktop.login1" + + // logind's alias for the session of the calling process. + const val LOGIND_SESSION_PATH = "/org/freedesktop/login1/session/auto" + const val LOGIND_SESSION = "org.freedesktop.login1.Session" + const val LOGIND_MANAGER = "org.freedesktop.login1.Manager" + const val READ_TIMEOUT_MILLIS = 500L + val SCREEN_SAVERS = listOf( + screenSaver( + "org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + FreedesktopScreenSaver::class.java, + ) { + it.GetActive() + }, + screenSaver("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver", GnomeScreenSaver::class.java) { + it.GetActive() + }, + screenSaver("org.cinnamon.ScreenSaver", "/org/cinnamon/ScreenSaver", CinnamonScreenSaver::class.java) { + it.GetActive() + }, + screenSaver("org.mate.ScreenSaver", "/org/mate/ScreenSaver", MateScreenSaver::class.java) { + it.GetActive() + }, + ) + + private fun screenSaver( + iface: String, + path: String, + type: Class, + active: (I) -> Boolean, + ) = ScreenSaver(iface) { connection -> active(connection.getRemoteObject(iface, path, type)) } + } +} + +/** A desktop screen saver on the session bus: its interface (also its bus name) and how to ask it on a connection. */ +private class ScreenSaver(val iface: String, val isActive: (DBusConnection) -> Boolean) + +@DBusInterfaceName("org.freedesktop.ScreenSaver") +private interface FreedesktopScreenSaver : DBusInterface { + fun GetActive(): Boolean +} + +@DBusInterfaceName("org.gnome.ScreenSaver") +private interface GnomeScreenSaver : DBusInterface { + fun GetActive(): Boolean +} + +@DBusInterfaceName("org.cinnamon.ScreenSaver") +private interface CinnamonScreenSaver : DBusInterface { + fun GetActive(): Boolean +} + +@DBusInterfaceName("org.mate.ScreenSaver") +private interface MateScreenSaver : DBusInterface { + fun GetActive(): Boolean +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/PortalFileDialogs.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/PortalFileDialogs.kt new file mode 100644 index 00000000..5a56ac0e --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/PortalFileDialogs.kt @@ -0,0 +1,169 @@ +@file:Suppress("FunctionNaming", "ktlint:standard:function-naming", "TooGenericExceptionCaught", "ReturnCount") + +package com.pasich.encly.desktop.nativeos + +import com.pasich.encly.desktop.platform.FileDialogs +import org.freedesktop.dbus.DBusMatchRule +import org.freedesktop.dbus.DBusPath +import org.freedesktop.dbus.annotations.DBusInterfaceName +import org.freedesktop.dbus.connections.impl.DBusConnection +import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder +import org.freedesktop.dbus.interfaces.DBusInterface +import org.freedesktop.dbus.interfaces.DBusSigHandler +import org.freedesktop.dbus.messages.DBusSignal +import org.freedesktop.dbus.types.UInt32 +import org.freedesktop.dbus.types.Variant +import java.net.URI +import java.nio.file.Path +import java.nio.file.Paths +import java.security.SecureRandom +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import javax.swing.SwingUtilities + +// The freedesktop desktop portal's file chooser (xdg-desktop-portal, org.freedesktop.portal.FileChooser) over +// the local session bus: the dialog of the user's own desktop (GNOME, KDE) instead of AWT's Motif-style one, +// and the only way a sandboxed (Flatpak) app can open files. D-Bus glue: it only runs against a real desktop +// and is excluded from the coverage gate. Any failure before the dialog is shown falls back to [fallback]. + +@DBusInterfaceName("org.freedesktop.portal.Request") +internal interface PortalRequest : DBusInterface { + /** Closes the dialog the request opened; the portal then answers it as cancelled. */ + fun Close() +} + +@DBusInterfaceName("org.freedesktop.portal.FileChooser") +internal interface PortalFileChooser : DBusInterface { + @JvmSuppressWildcards + fun OpenFile(parentWindow: String, title: String, options: Map>): DBusPath + + @JvmSuppressWildcards + fun SaveFile(parentWindow: String, title: String, options: Map>): DBusPath +} + +/** A [FileDialogs] on the portal that uses [fallback] when the portal is not there or fails to start. */ +class PortalFileDialogs(private val fallback: FileDialogs) : FileDialogs { + private class Unavailable : Exception() + + // The dialogs waiting for an answer, to cancel them when the app locks. + private val waiting = CopyOnWriteArraySet>>() + + /** The app is locking: every dialog still open is closed and answered as cancelled. */ + override fun cancelAll() { + waiting.forEach { it.complete(emptyList()) } + } + + override fun save(suggestedName: String, onResult: (Path?) -> Unit) { + val options = mutableMapOf>("current_name" to Variant(suggestedName)) + request( + method = { it.SaveFile("", "", options) }, + options = options, + onUris = { paths -> onResult(paths.firstOrNull()) }, + fallback = { fallback.save(suggestedName, onResult) }, + ) + } + + override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) { + val options = mutableMapOf>("multiple" to Variant(multiple)) + request( + method = { it.OpenFile("", "", options) }, + options = options, + onUris = onResult, + fallback = { fallback.open(multiple, extensions, onResult) }, + ) + } + + override fun folder(onResult: (Path?) -> Unit) { + val options = mutableMapOf>("directory" to Variant(true)) + request( + method = { it.OpenFile("", "", options) }, + options = options, + onUris = { paths -> onResult(paths.firstOrNull()) }, + fallback = { fallback.folder(onResult) }, + ) + } + + private fun request( + method: (PortalFileChooser) -> DBusPath, + options: MutableMap>, + onUris: (List) -> Unit, + fallback: () -> Unit, + ) { + Thread({ + val outcome = try { + Result.success(ask(method, options)) + } catch (e: Exception) { + Result.failure(e) + } + SwingUtilities.invokeLater { + outcome.fold(onUris) { fallback() } + } + }, "encly-portal-dialog").apply { isDaemon = true }.start() + } + + /** Calls the portal and waits for the user's answer; throws if the portal is not available. */ + private fun ask(method: (PortalFileChooser) -> DBusPath, options: MutableMap>): List = + DBusConnectionBuilder.forSessionBus().withShared(false).build().use { connection -> + val token = "encly" + java.lang.Long.toHexString(SecureRandom().nextLong()) + val sender = connection.uniqueName.removePrefix(":").replace('.', '_') + val handle = "/org/freedesktop/portal/desktop/request/$sender/$token" + val answer = CompletableFuture>() + waiting += answer + connection.addGenericSigHandler( + DBusMatchRule("signal", "org.freedesktop.portal.Request", "Response", handle), + DBusSigHandler { signal -> answer.complete(pathsOf(signal.parameters)) }, + ) + options["handle_token"] = Variant(token) + val chooser = connection.getRemoteObject( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + PortalFileChooser::class.java, + true, + ) + try { + method(chooser) + } catch (e: Exception) { + waiting -= answer + throw Unavailable().initCause(e) + } + try { + answer.get(DIALOG_TIMEOUT_MINUTES, TimeUnit.MINUTES) + } catch (_: TimeoutException) { + // Nobody answered: a portal that lost the request would keep the app "in front" for good. + emptyList() + } finally { + waiting -= answer + closeRequest(connection, handle) + } + } + + /** Asks the portal to close the dialog of [handle] (a no-op when it is closed already). */ + private fun closeRequest(connection: DBusConnection, handle: String) { + try { + connection.getRemoteObject( + "org.freedesktop.portal.Desktop", + handle, + PortalRequest::class.java, + true, + ).Close() + } catch (_: Exception) { + // Already answered, or the portal is gone. + } + } + + private companion object { + // Long enough for a person choosing a file, short enough that a lost request frees the app. + const val DIALOG_TIMEOUT_MINUTES = 30L + } + + /** `Response(u code, a{sv} results)`: code 0 is an answer, anything else a cancel; `uris` are file: URIs. */ + private fun pathsOf(parameters: Array): List { + val code = (parameters.getOrNull(0) as? UInt32)?.toLong() ?: return emptyList() + if (code != 0L) return emptyList() + val results = parameters.getOrNull(1) as? Map<*, *> ?: return emptyList() + val uris = (results["uris"] as? Variant<*>)?.value as? List<*> ?: return emptyList() + return uris.mapNotNull { (it as? String)?.let { text -> runCatching { Paths.get(URI(text)) }.getOrNull() } } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopClipboard.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopClipboard.kt new file mode 100644 index 00000000..9e1fae0b --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopClipboard.kt @@ -0,0 +1,172 @@ +package com.pasich.encly.desktop.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.platform.SensitiveClipboard +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.Clipboard as AwtClipboard + +/** + * Writes text to the system clipboard with the OS's markers for a clip that must not be kept (see + * `nativeos/NativeClipboard.kt`). [write] answers whether it did. + */ +fun interface ClipboardWriter { + fun write(text: String): Boolean +} + +/** + * Encly's clipboard policy on the desktop, the one Android applies: what the app copies out is cleared after + * [CLEAR_AFTER_MS] unless something else was copied since. "Something else" is recognised by the clipboard's + * text: if it still holds exactly what was armed, it is Encly's clip and it is emptied; if it holds anything + * else, it is left alone. A clipboard that cannot be read (another program holds it) is emptied anyway, as on + * Android: an Encly clip must not outlive its minute. + * + * The clip is written with the OS's own calls where they exist ([ClipboardWriter]): Windows clipboard history and + * cloud sync and macOS Universal Clipboard and clipboard managers are told to leave it out. Where they do not (Linux, + * or the call failed) AWT puts it there with no marking, and a clipboard history or manager may keep a copy that this + * wipe cannot reach. + */ +class ClipboardWiper( + private val clipboard: () -> AwtClipboard?, + private val timer: MainThreadTimer, + private val clearAfterMillis: Long = CLEAR_AFTER_MS, + private val writer: ClipboardWriter? = null, +) { + private var armedText: String? = null + private var cancel: (() -> Unit)? = null + + /** + * Puts [text] on the clipboard marked so that clipboard history, cloud sync and Universal Clipboard leave it + * alone, and arms the wipe. False when the OS has no such marking or it failed: the caller then uses AWT, whose + * clip carries no marking. + */ + fun writeSensitive(text: String): Boolean { + if (writer?.write(text) != true) return false + arm(text) + return true + } + + /** [text] was just put on the clipboard: empties it after the delay if it is still there. */ + fun arm(text: String) { + cancel?.invoke() + armedText = text + cancel = timer.schedule(clearAfterMillis, ::wipeIfStillHeld) + } + + /** Empties the clipboard now if it still holds what was armed (the app is closing). */ + fun wipeIfStillHeld() { + val armed = armedText ?: return + cancel?.invoke() + cancel = null + armedText = null + val board = clipboard() ?: return + val current = readText(board) + if (current == null || current == armed) emptyClipboard(board) + } + + /** Empties the clipboard now, whatever it holds. */ + fun clearNow() { + cancel?.invoke() + cancel = null + armedText = null + clipboard()?.let(::emptyClipboard) + } + + private fun readText(board: AwtClipboard): String? = try { + if (board.isDataFlavorAvailable(DataFlavor.stringFlavor)) { + board.getData(DataFlavor.stringFlavor) as? String + } else { + // Something that is not text was copied since: not ours. + EMPTY_OTHER + } + } catch (_: Exception) { + null + } + + private fun emptyClipboard(board: AwtClipboard) { + try { + board.setContents(StringSelection(""), null) + } catch (_: IllegalStateException) { + // The clipboard is held by another program; it stays as it is. + } + } + + companion object { + const val CLEAR_AFTER_MS = 60_000L + + // A value no armed text can equal: the clipboard holds something that is not text. + private const val EMPTY_OTHER = "\u0000not-text\u0000" + } +} + +/** [SensitiveClipboard] over the system clipboard of AWT. */ +class DesktopSensitiveClipboard(private val clipboard: () -> AwtClipboard?, private val wiper: ClipboardWiper) : + SensitiveClipboard { + // The desktop shows nothing when something is copied: the app says so itself. + override val systemConfirmsCopy: Boolean = false + + override fun copy(label: String, text: String): Boolean = wiper.writeSensitive(text) || copyWithAwt(text) + + /** The fallback: AWT puts the text there with no marking. */ + private fun copyWithAwt(text: String): Boolean { + val board = clipboard() ?: return false + return try { + board.setContents(StringSelection(text), null) + wiper.arm(text) + true + } catch (_: IllegalStateException) { + false + } + } + + override fun clear() = wiper.clearNow() +} + +/** + * A [Clipboard] that arms [ClipboardWiper] for everything the Compose text fields copy (Ctrl/Cmd+C and the + * cut and copy of the context menu), so the policy covers the editor as well as the explicit copy buttons. + */ +private class SensitiveComposeClipboard(private val delegate: Clipboard, private val wiper: ClipboardWiper) : + Clipboard { + override val nativeClipboard: Any get() = delegate.nativeClipboard + + override suspend fun getClipEntry(): ClipEntry? = delegate.getClipEntry() + + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + val text = clipEntry?.let(::textOf) + // Text goes out marked, and not through the delegate first: that would put it there unmarked for a moment. + if (text != null && wiper.writeSensitive(text)) return + delegate.setClipEntry(clipEntry) + if (clipEntry == null) { + wiper.clearNow() + } else if (text != null) { + wiper.arm(text) + } + } + + @Suppress("ReturnCount") // Guards before the read. + private fun textOf(entry: ClipEntry): String? { + val transferable = entry.nativeClipEntry as? Transferable ?: return null + if (!transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) return null + return try { + transferable.getTransferData(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } + } +} + +/** Routes every Compose copy under [content] through [wiper]. */ +@Composable +fun SensitiveClipboardBoundary(wiper: ClipboardWiper, content: @Composable () -> Unit) { + val platform = LocalClipboard.current + val clipboard = remember(platform, wiper) { SensitiveComposeClipboard(platform, wiper) } + CompositionLocalProvider(LocalClipboard provides clipboard, content = content) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopDocumentPickers.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopDocumentPickers.kt new file mode 100644 index 00000000..23394d29 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopDocumentPickers.kt @@ -0,0 +1,186 @@ +package com.pasich.encly.desktop.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import com.pasich.encly.core.backup.BackupFormat +import com.pasich.encly.platform.DocumentPickers +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.NavigationRequests +import com.pasich.encly.platform.PickerLauncher +import com.pasich.encly.presentation.navigation.NavRoutes +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CopyOnWriteArrayList +import javax.swing.SwingUtilities + +/** What a dropped or opened file is, for the flow it belongs to. */ +enum class DroppedKind { + /** An Encly backup (`.enclybak`): the restore flow. */ + BACKUP, + + /** Notes to import: Markdown, plain text, a ZIP or an export of another notes app. */ + NOTES, + + /** A folder of notes to import. */ + FOLDER, + ; + + companion object { + private val NOTE_EXTENSIONS = setOf("md", "markdown", "txt", "zip", "json", "jex", "html", "htm") + + /** The kind of [path], or null for a file Encly has no use for. */ + fun of(path: Path): DroppedKind? { + if (Files.isDirectory(path)) return FOLDER + val extension = path.fileName?.toString()?.substringAfterLast('.', "")?.lowercase().orEmpty() + return when { + extension == BackupFormat.FILE_EXTENSION -> BACKUP + extension in NOTE_EXTENSIONS -> NOTES + else -> null + } + } + } +} + +/** + * Connects files that arrive without a dialog (dropped on the window, opened with a double click on a backup) + * to the screens' own pickers. A screen that asks for a file registers its callback through the picker it + * remembers ([DesktopDocumentPickers]); a dropped file is answered to the most recent screen of the right + * kind exactly as if the user had chosen it in the dialog, so the existing restore and import flows run + * unchanged. With no such screen open the matching screen is requested through [navigation] and the file is + * handed over as soon as it registers (within [PENDING_MILLIS]). + */ +class DropRouter( + private val navigation: NavigationRequests, + private val post: (() -> Unit) -> Unit = { SwingUtilities.invokeLater(it) }, + private val clock: () -> Long = { System.nanoTime() / NANOS_PER_MILLI }, +) { + private enum class Target { ONE_FILE, MANY_FILES, FOLDER } + + private class Registration(val target: Target, val accept: (List) -> Unit) + + private class Pending(val target: Target, val refs: List, val at: Long) + + private val registrations = CopyOnWriteArrayList() + private var pending: Pending? = null + + /** A screen that picks one file registers; the result closes the registration. */ + fun registerOneFile(accept: (DocumentRef?) -> Unit): AutoCloseable = + register(Target.ONE_FILE) { accept(it.firstOrNull()) } + + /** A screen that picks several files registers. */ + fun registerManyFiles(accept: (List) -> Unit): AutoCloseable = register(Target.MANY_FILES, accept) + + /** A screen that picks a folder registers. */ + fun registerFolder(accept: (DocumentRef?) -> Unit): AutoCloseable = + register(Target.FOLDER) { accept(it.firstOrNull()) } + + /** + * Routes [files]. Backups win over notes, notes over a folder; anything else is ignored. Returns whether + * something was routed. + */ + fun drop(files: List): Boolean { + val kinds = files.associateWith(DroppedKind::of) + val backup = kinds.entries.firstOrNull { it.value == DroppedKind.BACKUP } + val notes = kinds.filterValues { it == DroppedKind.NOTES }.keys + val folder = kinds.entries.firstOrNull { it.value == DroppedKind.FOLDER } + when { + backup != null -> deliver(Target.ONE_FILE, listOf(backup.key.toDocumentRef()), NavRoutes.BackupRoute) + + notes.isNotEmpty() -> + deliver(Target.MANY_FILES, notes.map { it.toDocumentRef() }, NavRoutes.ImportNotesRoute) + + folder != null -> deliver(Target.FOLDER, listOf(folder.key.toDocumentRef()), NavRoutes.ImportNotesRoute) + + else -> return false + } + return true + } + + private fun register(target: Target, accept: (List) -> Unit): AutoCloseable { + val registration = Registration(target, accept) + registrations += registration + pending?.takeIf { it.target == target }?.let { waiting -> + pending = null + if (clock() - waiting.at <= PENDING_MILLIS) post { accept(waiting.refs) } + } + return AutoCloseable { registrations -= registration } + } + + private fun deliver(target: Target, refs: List, route: NavRoutes) { + val handler = registrations.lastOrNull { it.target == target } + if (handler != null) { + post { handler.accept(refs) } + } else { + pending = Pending(target, refs, clock()) + navigation.request(route.name) + } + } + + companion object { + const val PENDING_MILLIS = 15_000L + private const val NANOS_PER_MILLI = 1_000_000L + } +} + +/** + * The document dialogs of the desktop: the OS's own dialogs ([FileDialogs]) behind the pickers the screens + * remember, and the registration of those pickers with [DropRouter] so that a dropped file answers them too. + */ +class DesktopDocumentPickers(private val dialogs: FileDialogs, private val drops: DropRouter) : DocumentPickers { + @Composable + override fun rememberCreateDocument(mimeType: String, onPick: (DocumentRef?) -> Unit): PickerLauncher { + val current by rememberUpdatedState(onPick) + return remember(dialogs) { + PickerLauncher { suggestedName -> + dialogs.save(suggestedName) { current(it?.toDocumentRef()) } + } + } + } + + @Composable + override fun rememberOpenDocument(onPick: (DocumentRef?) -> Unit): PickerLauncher> { + val current by rememberUpdatedState(onPick) + DisposableEffect(drops) { + val registration = drops.registerOneFile { current(it) } + onDispose(registration::close) + } + return remember(dialogs) { + PickerLauncher { mimeTypes -> + dialogs.open(multiple = false, extensions = FileFilters.extensionsFor(mimeTypes)) { paths -> + current(paths.firstOrNull()?.toDocumentRef()) + } + } + } + } + + @Composable + override fun rememberOpenDocuments(onPick: (List) -> Unit): PickerLauncher> { + val current by rememberUpdatedState(onPick) + DisposableEffect(drops) { + val registration = drops.registerManyFiles { current(it) } + onDispose(registration::close) + } + return remember(dialogs) { + PickerLauncher { mimeTypes -> + dialogs.open(multiple = true, extensions = FileFilters.extensionsFor(mimeTypes)) { paths -> + current(paths.map { it.toDocumentRef() }) + } + } + } + } + + @Composable + override fun rememberOpenFolder(onPick: (DocumentRef?) -> Unit): PickerLauncher { + val current by rememberUpdatedState(onPick) + DisposableEffect(drops) { + val registration = drops.registerFolder { current(it) } + onDispose(registration::close) + } + return remember(dialogs) { + PickerLauncher { dialogs.folder { current(it?.toDocumentRef()) } } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopDocuments.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopDocuments.kt new file mode 100644 index 00000000..f5ce3cae --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopDocuments.kt @@ -0,0 +1,88 @@ +package com.pasich.encly.desktop.platform + +import com.pasich.encly.data.importer.DocumentTree +import com.pasich.encly.data.importer.TreeNode +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.PlatformDocuments +import kotlinx.io.IOException +import kotlinx.io.RawSink +import kotlinx.io.RawSource +import kotlinx.io.asSink +import kotlinx.io.asSource +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardOpenOption + +/** The [DocumentRef] of a picked file or folder: the desktop names a document by its absolute path. */ +fun Path.toDocumentRef(): DocumentRef = DocumentRef(toAbsolutePath().normalize().toString()) + +/** + * [PlatformDocuments] over the file system: the documents the user picked in a native dialog or dropped on the + * window, reached by their path. Reading a missing or unreadable file is an [IOException], like a refused + * grant on Android. Folders are walked without following symbolic links, so a link inside a picked folder + * can never lead the import to a file outside it. + */ +class DesktopDocuments : PlatformDocuments { + override fun openRead(doc: DocumentRef): RawSource? = path(doc)?.let { Files.newInputStream(it).asSource() } + + // Replaces the content, as Android's "wt" mode does; creates the file if the dialog did not. + override fun openWrite(doc: DocumentRef): RawSink? = path(doc)?.let { + Files.newOutputStream( + it, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING, + ).asSink() + } + + override fun displayName(doc: DocumentRef): String? = path(doc)?.fileName?.toString() + + override fun delete(doc: DocumentRef) { + path(doc)?.let(Files::deleteIfExists) + } + + override fun openTree(root: DocumentRef, maxEntries: Int): DocumentTree { + val base = path(root) ?: throw IOException("not a folder") + return FolderTree(base, maxEntries) + } + + private fun path(doc: DocumentRef): Path? = try { + Paths.get(doc.value) + } catch (_: InvalidPathException) { + null + } + + private class FolderTree(private val root: Path, private val maxEntries: Int) : DocumentTree { + override val rootId: String = root.toString() + + override fun children(parentId: String): List { + val parent = Paths.get(parentId) + if (!Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) return emptyList() + return try { + Files.newDirectoryStream(parent).use { entries -> + entries.asSequence() + .filter { !Files.isSymbolicLink(it) } + .take(maxEntries) + .map { TreeNode(it.toString(), it.fileName.toString(), Files.isDirectory(it)) } + .toList() + } + } catch (_: IOException) { + emptyList() + } + } + + override fun open(id: String): RawSource? { + val file = Paths.get(id) + val inside = file.toAbsolutePath().normalize().startsWith(root.toAbsolutePath().normalize()) + if (!inside || Files.isSymbolicLink(file) || !Files.isRegularFile(file)) return null + return try { + Files.newInputStream(file).asSource() + } catch (_: IOException) { + null + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopEnvironment.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopEnvironment.kt new file mode 100644 index 00000000..d8d0fe16 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopEnvironment.kt @@ -0,0 +1,88 @@ +package com.pasich.encly.desktop.platform + +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.desktop.nativeos.NativeBootClock +import com.pasich.encly.desktop.nativeos.NativeCaptureExclusion +import com.pasich.encly.desktop.nativeos.NativeScreenLock +import com.pasich.encly.desktop.security.BootClockSource +import com.pasich.encly.desktop.security.OsLockSignals +import com.pasich.encly.desktop.security.SwingMainThreadTimer +import com.pasich.encly.desktop.storage.DesktopOs +import java.awt.Frame +import java.awt.Window +import java.awt.datatransfer.Clipboard +import java.nio.file.Path + +/** Keeps a window out of screenshots and screen recordings where the OS lets an application do that. */ +interface CaptureExclusion { + /** False where the OS has no such feature, or the native call could not be set up. */ + val supported: Boolean + + /** Excludes [window]; false if it was not (not supported, or the OS refused). Safe to repeat. */ + fun exclude(window: Window): Boolean +} + +/** Hands a web or mail address to the default application of the OS. */ +fun interface LinkOpener { + /** Opens [uri]; false if there is no application for it. */ + fun open(uri: java.net.URI): Boolean +} + +/** The frame the app's own dialogs belong to, set by the window host once the window exists. */ +class DialogParent { + @Volatile + var frame: Frame? = null +} + +/** + * Everything the desktop app needs from the machine that tests must replace: the places, the OS secret + * store, and the calls into the OS. [system] is the real one; the graph and the services only see this. + */ +class DesktopEnvironment( + val os: DesktopOs, + val dataDirectory: Path, + val pinFactor: PinHardwareFactor, + val lockSignals: OsLockSignals, + val bootClock: BootClockSource, + val captureExclusion: CaptureExclusion, + val fileDialogs: FileDialogs, + val linkOpener: LinkOpener, + val systemClipboard: () -> Clipboard?, + /** Writes clips marked so history and sync leave them out; null where the OS cannot (then AWT writes them). */ + val clipboardWriter: ClipboardWriter? = null, + val timer: MainThreadTimer = SwingMainThreadTimer(), + val dialogParent: DialogParent = DialogParent(), +) { + companion object { + /** + * The environment of this machine, around [dataDirectory] and the real [pinFactor]. The file dialogs + * are the OS's own: the freedesktop portal on Linux when it answers, AWT's native dialogs otherwise. + */ + fun system( + os: DesktopOs, + dataDirectory: Path, + pinFactor: PinHardwareFactor, + dialogParent: DialogParent = DialogParent(), + ): DesktopEnvironment { + val awtDialogs = AwtFileDialogs(os, dialogParent::frame) + return DesktopEnvironment( + os = os, + dataDirectory = dataDirectory, + pinFactor = pinFactor, + lockSignals = NativeScreenLock.forOs(os), + bootClock = NativeBootClock.forOs(os), + captureExclusion = NativeCaptureExclusion.forOs(os), + fileDialogs = if (os == DesktopOs.LINUX) { + com.pasich.encly.desktop.nativeos.PortalFileDialogs(awtDialogs) + } else { + awtDialogs + }, + linkOpener = AwtLinkOpener, + systemClipboard = { runCatching { java.awt.Toolkit.getDefaultToolkit().systemClipboard }.getOrNull() }, + clipboardWriter = com.pasich.encly.desktop.nativeos.NativeClipboard.forOs(os), + dialogParent = dialogParent, + ) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopFileDialogs.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopFileDialogs.kt new file mode 100644 index 00000000..51902e91 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopFileDialogs.kt @@ -0,0 +1,160 @@ +package com.pasich.encly.desktop.platform + +import com.pasich.encly.desktop.security.DesktopAppLifecycle +import com.pasich.encly.desktop.storage.DesktopOs +import java.awt.FileDialog +import java.awt.Frame +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.JFileChooser +import javax.swing.SwingUtilities + +/** + * The OS's file dialogs. Every call answers on the AWT event thread, once: with the chosen path(s), or with + * nothing when the user cancelled. + */ +interface FileDialogs { + /** "Save as" with [suggestedName]. */ + fun save(suggestedName: String, onResult: (Path?) -> Unit) + + /** "Open" one or [multiple] files; [extensions] (lower case, no dot) narrows the offer, empty offers all. */ + fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) + + /** "Choose a folder". */ + fun folder(onResult: (Path?) -> Unit) + + /** + * Closes the dialogs this can close from outside (the portal's, which lives in another process) and answers + * each as cancelled; a native modal dialog of AWT cannot be closed this way. The app locking calls it. + */ + fun cancelAll() = Unit +} + +/** Which file extensions to offer for the MIME types the screens ask for. Empty means no filter. */ +object FileFilters { + private val BY_MIME = mapOf( + "text/markdown" to setOf("md", "markdown"), + "text/x-markdown" to setOf("md", "markdown"), + "text/plain" to setOf("txt", "md"), + "application/zip" to setOf("zip"), + "application/x-zip-compressed" to setOf("zip"), + "application/json" to setOf("json"), + "text/html" to setOf("html", "htm"), + ) + + /** Types that mean "any file": a generic binary (the backup) or a wildcard. */ + private val ANYTHING = setOf("application/octet-stream", "*/*") + + fun extensionsFor(mimeTypes: Array): Set { + if (mimeTypes.isEmpty() || mimeTypes.any { it in ANYTHING }) return emptySet() + val known = mimeTypes.map(BY_MIME::get) + // One unknown type could be anything: offer everything rather than hide a file. + return if (known.any { it == null }) emptySet() else known.filterNotNull().flatten().toSet() + } +} + +/** + * The dialogs AWT provides: the real native ones on macOS and Windows, a toolkit dialog on Linux (used there + * only when the freedesktop portal does not answer). A folder is chosen with the macOS file dialog in folder + * mode, and with a Swing chooser elsewhere, since AWT's `FileDialog` cannot choose a folder on Windows or Linux. + */ +class AwtFileDialogs(private val os: DesktopOs, private val parent: () -> Frame?) : FileDialogs { + override fun save(suggestedName: String, onResult: (Path?) -> Unit) = onEventThread { + val dialog = FileDialog(parent(), "", FileDialog.SAVE).apply { file = suggestedName } + dialog.isVisible = true + onResult(dialog.pathsOrEmpty().firstOrNull()) + } + + override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) = onEventThread { + val dialog = FileDialog(parent(), "", FileDialog.LOAD).apply { + isMultipleMode = multiple + if (extensions.isNotEmpty()) { + // Windows reads a pattern from the file field; the other toolkits take a name filter. + if (os == DesktopOs.WINDOWS) file = extensions.joinToString(";") { "*.$it" } + setFilenameFilter { _, name -> extensions.any { name.lowercase().endsWith(".$it") } } + } + } + dialog.isVisible = true + onResult(dialog.pathsOrEmpty()) + } + + override fun folder(onResult: (Path?) -> Unit) = onEventThread { + onResult(if (os == DesktopOs.MACOS) macFolder() else swingFolder()) + } + + private fun macFolder(): Path? { + val property = "apple.awt.fileDialogForDirectories" + System.setProperty(property, "true") + try { + val dialog = FileDialog(parent(), "", FileDialog.LOAD) + dialog.isVisible = true + return dialog.pathsOrEmpty().firstOrNull() + } finally { + System.setProperty(property, "false") + } + } + + private fun swingFolder(): Path? { + val chooser = JFileChooser().apply { fileSelectionMode = JFileChooser.DIRECTORIES_ONLY } + return if (chooser.showOpenDialog(parent()) == JFileChooser.APPROVE_OPTION) { + chooser.selectedFile?.toPath() + } else { + null + } + } + + // Empty when the dialog was cancelled. + private fun FileDialog.pathsOrEmpty(): List = files.map { it.toPath() } + + private fun onEventThread(block: () -> Unit) { + if (SwingUtilities.isEventDispatchThread()) block() else SwingUtilities.invokeLater(block) + } +} + +/** + * Tells [lifecycle] that one of the app's own dialogs is open while [delegate] shows it, so taking the focus + * away from the window for a file chooser is not mistaken for the user leaving the app (which would lock the + * vault in the middle of an export). + */ +class GatedFileDialogs(private val delegate: FileDialogs, private val lifecycle: DesktopAppLifecycle) : FileDialogs { + override fun save(suggestedName: String, onResult: (Path?) -> Unit) = gated { end -> + delegate.save(suggestedName) { path -> + end() + onResult(path) + } + } + + override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) = gated { end -> + delegate.open(multiple, extensions) { paths -> + end() + onResult(paths) + } + } + + override fun folder(onResult: (Path?) -> Unit) = gated { end -> + delegate.folder { path -> + end() + onResult(path) + } + } + + override fun cancelAll() = delegate.cancelAll() + + /** + * Counts the dialog as open until [show] calls the `end` it is given, once; if showing it fails on the spot the + * count is given back at once, so a dialog that never appeared cannot keep the app "in front" (and unlocked) + * for good. + */ + @Suppress("TooGenericExceptionCaught") // Whatever failed to show it: the count is given back, then rethrown. + private fun gated(show: (end: () -> Unit) -> Unit) { + lifecycle.beginOwnDialog() + val ended = AtomicBoolean(false) + val end: () -> Unit = { if (ended.compareAndSet(false, true)) lifecycle.endOwnDialog() } + try { + show(end) + } catch (e: Exception) { + end() + throw e + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopHostServices.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopHostServices.kt new file mode 100644 index 00000000..52bb4453 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopHostServices.kt @@ -0,0 +1,193 @@ +package com.pasich.encly.desktop.platform + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.AppLocaleController +import com.pasich.encly.platform.AppWindow +import com.pasich.encly.platform.CacheDirectory +import com.pasich.encly.platform.ExternalLinks +import com.pasich.encly.platform.ToastDuration +import com.pasich.encly.platform.Toaster +import com.pasich.encly.utils.DeviceCapabilities +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.io.files.Path +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import java.awt.Desktop +import java.awt.Window +import java.net.URI +import java.net.URISyntaxException +import java.util.Locale +import java.util.Properties + +/** [LinkOpener] on `java.awt.Desktop`: the default browser or mail application. Looked up on first use. */ +object AwtLinkOpener : LinkOpener { + @Suppress("ReturnCount") // Guards: no desktop, no action. + override fun open(uri: URI): Boolean { + val desktop = runCatching { if (Desktop.isDesktopSupported()) Desktop.getDesktop() else null }.getOrNull() + ?: return false + val action = if (uri.scheme.equals("mailto", ignoreCase = true)) Desktop.Action.MAIL else Desktop.Action.BROWSE + if (!desktop.isSupported(action)) return false + // Opening can block on some desktops (Linux): never on the UI thread. + Thread({ + runCatching { + if (action == Desktop.Action.MAIL) desktop.mail(uri) else desktop.browse(uri) + } + }, "encly-open-link").apply { isDaemon = true }.start() + return true + } +} + +/** + * [ExternalLinks] that hands an address to the OS. Only `https`, `http` and `mailto` addresses leave the app: + * a `file:` or custom-scheme address is refused, so a link in a note can never start a local program. There is + * no chooser on the desktop: the default application opens it. + */ +class DesktopExternalLinks(private val opener: LinkOpener) : ExternalLinks { + override fun openUrl(url: String, chooser: Boolean): Boolean { + val uri = parse(url) ?: return false + return uri.scheme.lowercase() in WEB_SCHEMES && opener.open(uri) + } + + override fun writeMail(address: String): Boolean { + val uri = parse("mailto:$address") ?: return false + return opener.open(uri) + } + + private fun parse(text: String): URI? = try { + URI(text.trim()).takeIf { it.scheme != null } + } catch (_: URISyntaxException) { + null + } + + private companion object { + val WEB_SCHEMES = setOf("https", "http", "mailto") + } +} + +/** What the window offers the UI: the capture exclusion and the background behind the content. */ +class DesktopAppWindow( + private val capture: CaptureExclusion, + private val windowProvider: () -> Window?, + private val onBackground: (Color) -> Unit = {}, +) : AppWindow { + override val isChangingConfigurations: Boolean = false + + /** Excludes the window from screenshots and recordings, wherever the OS allows (see [CaptureExclusion]). */ + override fun enforceSecure() { + windowProvider()?.let(capture::exclude) + } + + override fun setLightStatusBar(light: Boolean) = Unit + + override fun setBackgroundColor(argb: Int) = onBackground(Color(argb)) + + override fun hideKeyboard() = Unit + + override fun showKeyboard() = Unit +} + +/** + * Messages as snackbars inside the window ([ToastHost]); there is no system toast on the desktop. The + * string resource is read in a coroutine on [dispatcher] (the UI thread), in the in-app language. + */ +class DesktopToaster(dispatcher: CoroutineDispatcher = Dispatchers.Main) : Toaster { + val hostState = SnackbarHostState() + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + + override fun show(text: StringResource, duration: ToastDuration, vararg args: Any) { + scope.launch { + val message = getString(text, *args) + hostState.currentSnackbarData?.dismiss() + hostState.showSnackbar( + message = message, + duration = if (duration == ToastDuration.LONG) SnackbarDuration.Long else SnackbarDuration.Short, + ) + } + } +} + +/** Where [DesktopToaster]'s messages appear: put it at the bottom of the window, above the content. */ +@Composable +fun ToastHost(toaster: DesktopToaster, modifier: Modifier = Modifier) { + SnackbarHost(hostState = toaster.hostState, modifier = modifier) +} + +/** + * The in-app language. It is kept in [flags] and applied as the process's default locale, which the string + * resources read. There is no Activity to recreate: [revision] changes on every [apply], and the window host + * recomposes the app under a new key, so every screen reads its strings again. + */ +class DesktopAppLocaleController( + private val flags: KeyValueStore, + private val systemLocale: Locale = Locale.getDefault(), +) : AppLocaleController { + private val _revision = MutableStateFlow(0) + + /** Increases whenever the language was applied. */ + val revision: StateFlow = _revision.asStateFlow() + + override fun current(): AppLanguage = AppLanguage.fromTag(flags.getString(LANGUAGE_KEY, null)) + + override fun apply(language: AppLanguage) { + flags.edit { + if (language.tag == null) remove(LANGUAGE_KEY) else putString(LANGUAGE_KEY, language.tag!!) + } + useAsDefaultLocale(language) + _revision.value += 1 + } + + /** Puts the stored language into the process before anything is composed. */ + fun syncDefaultLocale() = useAsDefaultLocale(current()) + + private fun useAsDefaultLocale(language: AppLanguage) { + Locale.setDefault(language.tag?.let(Locale::forLanguageTag) ?: systemLocale) + } + + private companion object { + const val LANGUAGE_KEY = "app_language" + } +} + +/** This build's facts. The version is written from `version.properties` at build time. */ +class DesktopAppInfo(override val versionName: String) : AppInfo { + override val appName: String = "Encly" + + // Not a store build: the donation links are allowed. + override val donationsEnabled: Boolean = true + override val storeRatingEnabled: Boolean = false + override val storePageLinks: List = emptyList() + + companion object { + private const val RESOURCE = "/encly-build.properties" + + /** The info of the running build; the version is "0.0.0" if the build resource is missing. */ + fun fromBuild(): DesktopAppInfo { + val properties = Properties() + DesktopAppInfo::class.java.getResourceAsStream(RESOURCE)?.use(properties::load) + return DesktopAppInfo(properties.getProperty("version")?.takeIf { it.isNotBlank() } ?: "0.0.0") + } + } +} + +/** The app's cache directory: a folder of the data directory, so it is private to the user as well. */ +class DesktopCacheDirectory(override val path: Path) : CacheDirectory + +/** A desktop has no wallpaper-derived colours Encly can read. */ +object DesktopDeviceCapabilities : DeviceCapabilities { + override fun supportsDynamicColors(): Boolean = false +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt new file mode 100644 index 00000000..1a030989 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt @@ -0,0 +1,34 @@ +package com.pasich.encly.desktop.platform + +import com.pasich.encly.platform.OpenSourceNotice + +/** + * The libraries only the desktop app ships, for About -> Licenses (the ones every build has are listed there already). + * Their licence texts and notices are in the installers, under `licenses/` in the app resources (see + * `packaging/resources/common/licenses`); keep the two in step when a dependency changes. + */ +object DesktopLicenses { + private const val APACHE_2 = "Apache License 2.0" + private const val MIT = "MIT License" + + val notices: List = listOf( + OpenSourceNotice("Apache PDFBox, FontBox", APACHE_2, "https://pdfbox.apache.org/"), + OpenSourceNotice("ICU for Java (ICU4J)", "Unicode License v3", "https://icu.unicode.org/"), + OpenSourceNotice("Noto Sans Arabic", "SIL Open Font License 1.1", "https://github.com/notofonts/arabic"), + OpenSourceNotice( + "Apache Commons Logging", + APACHE_2, + "https://commons.apache.org/proper/commons-logging/", + ), + OpenSourceNotice( + "JNA (Java Native Access)", + "Apache License 2.0 or LGPL 2.1 or later", + "https://github.com/java-native-access/jna", + ), + OpenSourceNotice("dbus-java", MIT, "https://github.com/hypfvieh/dbus-java"), + OpenSourceNotice("SLF4J", MIT, "https://www.slf4j.org/"), + OpenSourceNotice("Skiko", APACHE_2, "https://github.com/JetBrains/skiko"), + OpenSourceNotice("JetBrains Runtime API, JSpecify", APACHE_2, "https://jspecify.dev/"), + OpenSourceNotice("OpenJDK", "GPL 2 with the Classpath Exception", "https://openjdk.org/legal/gplv2+ce.html"), + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSecurityServices.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSecurityServices.kt new file mode 100644 index 00000000..da01ab84 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSecurityServices.kt @@ -0,0 +1,99 @@ +package com.pasich.encly.desktop.security + +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.core.security.BiometricHost +import com.pasich.encly.core.security.BiometricManager +import com.pasich.encly.core.security.BiometricStatus +import com.pasich.encly.core.security.LockoutClock +import javax.swing.SwingUtilities +import javax.swing.Timer +import kotlin.time.TimeSource + +/** + * [MainThreadTimer] on a Swing [Timer]: the action runs on the AWT event dispatch thread, which is the UI + * thread of the Compose window, like the main Looper on Android. + */ +class SwingMainThreadTimer : MainThreadTimer { + override fun schedule(delayMs: Long, action: () -> Unit): () -> Unit { + val timer = Timer(delayMs.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()) { action() } + timer.isRepeats = false + timer.start() + val cancel: () -> Unit = { + if (SwingUtilities.isEventDispatchThread()) timer.stop() else SwingUtilities.invokeLater { timer.stop() } + } + return cancel + } +} + +/** + * [BiometricManager] of a desktop, which has no biometric unlock yet (Windows Hello, Touch ID and fprintd + * are not wired in): the slot never exists, so the vault opens with the PIN or the recovery phrase only, and + * Settings reports biometrics as unavailable. + */ +class NoBiometricManager : BiometricManager { + override fun isStrongBiometricAvailable(): Boolean = false + + override fun strongBiometricStatus(): BiometricStatus = BiometricStatus.UNAVAILABLE + + override fun hasSlot(): Boolean = false + + override fun enroll(host: BiometricHost, dek: ByteArray, onResult: (Boolean) -> Unit) = onResult(false) + + override fun unlock(host: BiometricHost, onResult: (ByteArray?) -> Unit) = onResult(null) + + override fun authenticate( + host: BiometricHost, + type: BiometricManager.BiometricType, + callback: BiometricManager.BiometricCallback, + customConfig: BiometricManager.PromptConfig?, + ) = callback.onError(ERROR_UNAVAILABLE, "Biometrics are not available on the desktop") + + override fun disable() = Unit + + private companion object { + /** BiometricPrompt's ERROR_HW_NOT_PRESENT, the code the callers already treat as "no biometrics". */ + const val ERROR_UNAVAILABLE = 12 + } +} + +/** What the operating system says about the time since it booted. Null when it cannot be read. */ +interface BootClockSource { + /** Milliseconds since boot, sleep included where the OS counts it. */ + fun uptimeMillis(): Long? + + /** An id that changes with every boot, or null when the OS gives none. */ + fun bootId(): String? +} + +/** + * [LockoutClock] on the operating system's uptime, so the PIN lockout survives a restart of the app: a clock + * that starts at zero with the process would let a restart cut a running lockout short. + * + * The lockout logic keeps its penalty at an uptime anchor and the boot it was set in. A reboot (another boot + * id, or an uptime smaller than the anchor) restarts the whole penalty, never shortens it. Where the OS gives + * no boot id (Windows) the uptime comparison alone does that: after a reboot `uptime - anchor` is never larger + * than the real time since the anchor. + * + * If the uptime cannot be read, the clock falls back to a monotonic one that starts with the process; a + * lockout is then only counted while the process lives (the weakest case, reported by [exact]). + */ +class OsUptimeClock(private val source: BootClockSource) : LockoutClock { + private val processStart = TimeSource.Monotonic.markNow() + private val uptimeWorks: Boolean = source.uptimeMillis() != null + + /** True when the clock follows the OS uptime, false for the process-local fallback. */ + val exact: Boolean get() = uptimeWorks + + override fun elapsedRealtime(): Long = + (if (uptimeWorks) source.uptimeMillis() else null) ?: processStart.elapsedNow().inWholeMilliseconds + + override fun bootCount(): Int = source.bootId()?.hashCode() ?: -1 +} + +/** Parses the first field of Linux's /proc/uptime ("12345.67 8910.11") into milliseconds. */ +internal fun parseProcUptime(text: String): Long? { + val seconds = text.trim().substringBefore(' ').toDoubleOrNull() ?: return null + return (seconds * MILLIS_PER_SECOND).toLong().takeIf { it >= 0 } +} + +private const val MILLIS_PER_SECOND = 1000 diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt new file mode 100644 index 00000000..2ca1e3e7 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt @@ -0,0 +1,342 @@ +package com.pasich.encly.desktop.security + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.core.security.AutoLockPolicy +import com.pasich.encly.core.security.DeviceLockWatcher +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities + +/** + * What the OS reports about the session being locked (a lock screen, a fast user switch, a suspend). + * [start] reports every change of the lock state through [onChange] (any thread) until the result is closed. + */ +fun interface OsLockSignals { + fun start(onChange: (locked: Boolean) -> Unit): AutoCloseable + + /** + * Whether the session is locked right now, read from the OS, or null when this OS only reports changes through + * [start] (the last reported change is then the state). A source that reports changes some lockers never send + * (a lock that starts and is never told it ended) must answer here, or its last change stays the state for good. + */ + fun liveLocked(): Boolean? = null + + /** An OS where nothing can be detected: only the idle and focus based locks apply. */ + object None : OsLockSignals { + override fun start(onChange: (locked: Boolean) -> Unit): AutoCloseable = AutoCloseable { } + } +} + +/** + * Whether the machine slept since the last time it was asked. Two OS clocks tell: one that keeps counting while + * the machine sleeps and one that stops, so the gap between them only grows by the time spent asleep (a stalled + * process or a changed wall clock moves neither). [clocks] gives both in milliseconds, or null when they cannot be + * read (then nothing is detected). The first question only takes the starting gap. + */ +class SleepDetector(private val clocks: () -> Clocks?, private val minSleepMillis: Long = MIN_SLEEP_MILLIS) { + /** [counting]: milliseconds since boot, sleep included; [awake]: the same without the time asleep. */ + class Clocks(val counting: Long, val awake: Long) + + private var gap: Long? = null + + /** True when the machine slept for at least [minSleepMillis] since the previous call. */ + @Synchronized + fun sleptSinceLastCheck(): Boolean { + val now = clocks()?.let { it.counting - it.awake } ?: return false + val before = gap + gap = now + return before != null && now - before >= minSleepMillis + } + + companion object { + /** Shorter naps are not told apart from clock rounding. */ + const val MIN_SLEEP_MILLIS: Long = 2_000L + } +} + +/** + * What Linux can be asked about the session being locked, each source `null` when it cannot be read: logind's + * `LockedHint` of the session (kept right by most lockers: swaylock, hyprlock, i3lock through xss-lock and the + * desktops' own), and whether a desktop screen saver is active. Locked when any source says so. A source that throws + * answers "not locked": an unreadable state must not keep the vault from opening. + */ +class LinuxLiveLock(private val lockedHint: () -> Boolean?, private val screenSaversActive: List<() -> Boolean?>) { + @Suppress("TooGenericExceptionCaught") + private fun says(source: () -> Boolean?): Boolean = try { + source() == true + } catch (_: Exception) { + false + } + + /** Whether the session is locked now. */ + fun isLocked(): Boolean = says(lockedHint) || screenSaversActive.any(::says) +} + +/** + * [OsLockSignals] for an OS that offers only a question ("is the session locked?"): [probe] is asked every + * [intervalMillis] on a daemon thread, and a change of its answer is reported. [probe] must never throw; a + * failure answers `false` (the state is unknown, not locked). + * + * Sleep is reported as a lock that ends at once: a machine that slept without locking the session (a Mac that + * does not ask for the password after sleep, a PC without the wake-up sign-in) leaves the vault open for whoever + * opens the lid, so [slept] says when it did and the vault locks. It is seen when the machine wakes, at the + * next poll. + */ +class PollingLockSignals( + private val intervalMillis: Long, + private val probe: () -> Boolean, + private val slept: () -> Boolean = { false }, + private val executor: () -> ScheduledExecutorService = { + Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "encly-screen-lock-poll").apply { isDaemon = true } + } + }, +) : OsLockSignals { + override fun start(onChange: (locked: Boolean) -> Unit): AutoCloseable { + val scheduler = executor() + var last = false + val poll = Runnable { + val sleptSincePoll = slept() + val now = probe() + if (now != last) { + last = now + onChange(now) + } else if (sleptSincePoll && !now) { + // Slept with the session never locked: a lock that is over as soon as it is reported. + onChange(true) + onChange(false) + } + } + scheduler.scheduleWithFixedDelay(poll, 0L, intervalMillis, TimeUnit.MILLISECONDS) + return AutoCloseable { scheduler.shutdownNow() } + } +} + +/** + * [DeviceLockWatcher] on the desktop: the OS says when the session is locked or the machine goes to sleep. + * + * It does two jobs. [DeviceLockWatcher.isDeviceLocked] and [DeviceLockWatcher.watchScreenOff] serve the + * session lock while the app is in the background, as on Android. [onLocked] serves the app being in front: + * the desktop's lock screen can come up while Encly has the focus, which Android's screen-off broadcast also + * covers there, so the host subscribes once for the whole run and locks the vault. + * + * Callbacks are delivered through [dispatch] (the AWT event thread), whatever thread the OS reports on. + */ +class DesktopDeviceLockWatcher( + private val signals: OsLockSignals, + private val dispatch: (() -> Unit) -> Unit = { SwingUtilities.invokeLater(it) }, +) : DeviceLockWatcher, + AutoCloseable { + @Volatile + private var locked = false + + private val screenOffListeners = CopyOnWriteArrayList<() -> Unit>() + private val lockListeners = CopyOnWriteArrayList<() -> Unit>() + private var subscription: AutoCloseable? = null + + /** Starts listening to the OS. Safe to call once; later calls do nothing. */ + @Synchronized + fun start() { + if (subscription == null) subscription = signals.start(::onSignal) + } + + private fun onSignal(nowLocked: Boolean) { + val wasLocked = locked + locked = nowLocked + if (nowLocked && !wasLocked) { + dispatch { + lockListeners.forEach { it() } + screenOffListeners.forEach { it() } + } + } + } + + // Where the OS can be asked, it is: the last signal can be stale for good (a locker that never says it ended). + override fun isDeviceLocked(): Boolean = signals.liveLocked() ?: locked + + override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit { + screenOffListeners += onScreenOff + val stop: () -> Unit = { screenOffListeners -= onScreenOff } + return stop + } + + /** Calls [action] every time the session becomes locked, until the returned function is called. */ + fun onLocked(action: () -> Unit): () -> Unit { + lockListeners += action + val stop: () -> Unit = { lockListeners -= action } + return stop + } + + @Synchronized + override fun close() { + subscription?.close() + subscription = null + } +} + +/** + * The foreground state of the app for [com.pasich.encly.core.security.SessionLockManager], which is a + * lifecycle observer: Android feeds it from the process lifecycle, the desktop from the window. + * + * The app is in the foreground while its window is active and not minimised. Leaving (another application + * in front, minimised) is ON_STOP, which the lock manager answers by the user's auto-lock delay; coming back + * is ON_START. The same owner is given to the UI as its lifecycle, so the navigation guards see the same + * stops and starts as the lock manager. + * + * The app's own modal dialogs (a file chooser) take the focus from the window without the user leaving the + * app: while one is open ([beginOwnDialog], [endOwnDialog]) the window counts as active. + */ +class DesktopAppLifecycle : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this) + private var active = true + private var minimised = false + private var dialogs = 0 + + override val lifecycle: Lifecycle get() = registry + + init { + // The app starts in front: the first unlock happens after the window is up. + registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + /** True while the app counts as in front. */ + val inForeground: Boolean get() = registry.currentState.isAtLeast(Lifecycle.State.STARTED) + + fun windowActivated() = update { active = true } + + fun windowDeactivated() = update { active = false } + + fun windowMinimised() = update { minimised = true } + + fun windowRestored() = update { minimised = false } + + /** An own modal dialog is about to take the focus. */ + fun beginOwnDialog() = update { dialogs++ } + + /** The dialog closed and the focus goes back to the window (the activation event may come after). */ + fun endOwnDialog() = update { + if (dialogs > 0) dialogs-- + if (dialogs == 0) active = true + } + + /** The app is closing: no more events (the lock manager stops waiting for them). */ + fun destroy() { + if (inForeground) { + registry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + + private fun update(change: () -> Unit) { + if (registry.currentState == Lifecycle.State.DESTROYED) return + change() + val wantForeground = !minimised && (active || dialogs > 0) + when { + wantForeground && !inForeground -> { + registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + !wantForeground && inForeground -> { + registry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + } + } +} + +/** + * The OS uptime in nanoseconds for [IdleLock.touch], which runs on every input event and must not ask the OS each time + * (a file read on Linux, a native call elsewhere): [uptimeMillis] is read at most once per [refreshNanos] of the + * process's own monotonic clock, and in between the uptime moves on with that clock. A sleep makes this read low until + * the next refresh, which can only make the idle time look longer, never shorter: the vault locks sooner, not later. + */ +class CoarseUptime( + private val uptimeMillis: () -> Long, + private val monotonicNanos: () -> Long = System::nanoTime, + private val refreshNanos: Long = TimeUnit.SECONDS.toNanos(1), +) { + private class Reading(val uptimeNanos: Long, val atMonotonicNanos: Long) + + @Volatile + private var reading = take() + + /** The uptime now, nanoseconds. Any thread. */ + fun nanos(): Long { + val now = monotonicNanos() + val current = reading + if (now - current.atMonotonicNanos < refreshNanos) return current.uptimeNanos + (now - current.atMonotonicNanos) + return take(now).also { reading = it }.uptimeNanos + } + + private fun take(now: Long = monotonicNanos()) = Reading(TimeUnit.MILLISECONDS.toNanos(uptimeMillis()), now) +} + +/** + * Locks the vault after the user has not touched the keyboard or the mouse for a while although the window is + * in front (the auto-lock delay covers the app being left, not an unattended open window). The limit is the + * user's auto-lock delay, but never shorter than [MIN_IDLE_MILLIS]: the default delay of 15 seconds is meant + * for leaving the app, not for pausing to read. + * + * The host calls [touch] on every input event; the check itself runs on [timer], rescheduled for exactly the + * time that is left, so a busy mouse costs a field write and no timers. + */ +class IdleLock( + private val timer: MainThreadTimer, + private val policy: AutoLockPolicy, + private val lockNow: () -> Unit, + private val clock: () -> Long = System::nanoTime, + /** The clock the check reads; the exact one, where [clock] (read on every input event) may be a cheap one. */ + private val checkClock: () -> Long = clock, +) { + @Volatile + private var lastInputNanos = clock() + private var cancel: (() -> Unit)? = null + + /** The user did something. Cheap; any thread. */ + fun touch() { + lastInputNanos = clock() + } + + /** Starts watching. */ + fun start() { + touch() + schedule(limitMillis()) + } + + /** Stops watching. */ + fun stop() { + cancel?.invoke() + cancel = null + } + + private fun limitMillis(): Long = maxOf(policy.delayMillis, MIN_IDLE_MILLIS) + + private fun schedule(delayMillis: Long) { + cancel = timer.schedule(delayMillis, ::check) + } + + private fun check() { + val limit = limitMillis() + val idle = TimeUnit.NANOSECONDS.toMillis(checkClock() - lastInputNanos) + if (idle >= limit) { + lockNow() + schedule(limit) + } else { + schedule(limit - idle) + } + } + + companion object { + /** The shortest idle time before the vault locks while the window is in front. */ + const val MIN_IDLE_MILLIS: Long = 5 * 60_000L + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/AppDirectories.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/AppDirectories.kt new file mode 100644 index 00000000..a6036187 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/AppDirectories.kt @@ -0,0 +1,98 @@ +package com.pasich.encly.desktop.storage + +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.attribute.PosixFilePermissions + +/** The desktop operating systems Encly runs on. */ +enum class DesktopOs { + WINDOWS, + MACOS, + LINUX, + ; + + companion object { + /** The OS named by the `os.name` property; Linux for any other unix, since the layout is the XDG one. */ + fun fromName(osName: String): DesktopOs { + val name = osName.lowercase() + return when { + name.startsWith("windows") -> WINDOWS + name.startsWith("mac") || name.startsWith("darwin") -> MACOS + else -> LINUX + } + } + + fun current(): DesktopOs = fromName(System.getProperty("os.name").orEmpty()) + } +} + +/** + * Where the app keeps everything that is its own: the encrypted databases, the key slots file, the settings, + * the window state and the cache. One directory per OS user, in the place the OS reserves for it. + */ +object AppDirectories { + private const val WINDOWS_NAME = "Encly" + private const val MACOS_NAME = "Encly" + private const val LINUX_NAME = "encly" + + /** + * The data directory for [os]. Pure: [env] answers environment variables and [home] is the user's home. + * - Windows: `%LOCALAPPDATA%\Encly` (the same folder the DPAPI blob of the PIN factor lives in). Local, not + * the roaming `%APPDATA%`: a roaming profile is copied to the profile server and to every machine the user + * signs in to, and the vault and the PIN's device secret must stay on this one; + * - macOS: `~/Library/Application Support/Encly`; + * - Linux: `$XDG_DATA_HOME/encly`, or `~/.local/share/encly` when it is unset, empty or not absolute (the + * XDG specification says a relative value is invalid and must be ignored). + */ + fun dataDirectory(os: DesktopOs, env: (String) -> String?, home: String): Path = when (os) { + DesktopOs.WINDOWS -> { + val appData = env("LOCALAPPDATA")?.takeIf { it.isNotBlank() } + (appData?.let(Paths::get) ?: Paths.get(home, "AppData", "Local")).resolve(WINDOWS_NAME) + } + + DesktopOs.MACOS -> Paths.get(home, "Library", "Application Support", MACOS_NAME) + + DesktopOs.LINUX -> { + val xdg = env("XDG_DATA_HOME")?.takeIf { it.isNotBlank() }?.let(Paths::get)?.takeIf { it.isAbsolute } + (xdg ?: Paths.get(home, ".local", "share")).resolve(LINUX_NAME) + } + } + + /** [dataDirectory] for the running JVM. */ + fun systemDataDirectory(): Path = + dataDirectory(DesktopOs.current(), System::getenv, System.getProperty("user.home")) + + /** + * Creates [dir] (and its parents) for the owner only and returns it. + * + * On POSIX systems (macOS, Linux) the directory gets `rwx------`, also when it already existed with looser + * permissions: the key slots file and the databases inside carry no permission bits of their own that + * matter, the directory is the boundary. On Windows POSIX permissions do not exist and the ACL is left to + * inheritance: a folder under `%LOCALAPPDATA%` is readable by the user, SYSTEM and Administrators only, which + * is the same trust boundary the DPAPI-protected PIN secret has. Encly does not rewrite ACLs (a wrong + * ACL can lock the user out of their own vault). + * + * @throws IOException when the directory cannot be created or restricted. + */ + @Throws(IOException::class) + fun createPrivate(dir: Path): Path { + val posix = Files.getFileStore(existingParent(dir)).supportsFileAttributeView("posix") + if (posix) { + val ownerOnly = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")) + Files.createDirectories(dir, ownerOnly) + Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------")) + } else { + Files.createDirectories(dir) + } + return dir + } + + /** The nearest ancestor of [dir] that exists, to ask its file store what it supports. */ + private fun existingParent(dir: Path): Path { + var candidate: Path? = dir.toAbsolutePath() + while (candidate != null && !Files.exists(candidate)) candidate = candidate.parent + return candidate ?: dir.toAbsolutePath().root + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/BackupExclusion.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/BackupExclusion.kt new file mode 100644 index 00000000..cd53adfc --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/BackupExclusion.kt @@ -0,0 +1,49 @@ +package com.pasich.encly.desktop.storage + +import java.nio.file.Path + +/** + * Keeps the data directory out of the operating system's backups, as Android (`allowBackup=false`) and iOS + * (`isExcludedFromBackup`) do: the vault, its key slots and the PIN's device secret belong on this device only. + * Where the OS has no backup of the user's home to keep it out of (Windows: the data is in the local profile, + * which File History and OneDrive folder backup do not include; Linux: the XDG data directory is the user's own + * choice to back up) nothing is done. + */ +fun interface BackupExclusion { + /** Excludes [directory] (and everything in it); false if it could not be done. Safe to repeat. */ + fun exclude(directory: Path): Boolean +} + +/** + * The extended attribute Time Machine reads to leave a path out of backups for good (the same one `tmutil + * addexclusion -p` writes): `com.apple.metadata:com_apple_backup_excludeItem`, holding the binary property list of + * the string `com.apple.backupd`. + */ +object TimeMachineExclusion { + const val ATTRIBUTE = "com.apple.metadata:com_apple_backup_excludeItem" + const val OWNER = "com.apple.backupd" + + // Markers of the binary property list format: an ASCII string whose length follows as an int object, and + // a 1-byte int. + private const val ASCII_STRING_MARKER: Byte = 0x5F + private const val ONE_BYTE_INT_MARKER: Byte = 0x10 + private const val TRAILER_UNUSED = 6 + + /** The value of [ATTRIBUTE]: a `bplist00` holding the single ASCII string [OWNER]. */ + fun attributeValue(): ByteArray { + val text = OWNER.toByteArray(Charsets.US_ASCII) + // Object 0: marker 0x5F (ASCII string, length in the next int object), 0x10 (1-byte int), the length, the text. + val objectBytes = byteArrayOf(ASCII_STRING_MARKER, ONE_BYTE_INT_MARKER, text.size.toByte()) + text + val header = "bplist00".toByteArray(Charsets.US_ASCII) + val offsetTableOffset = header.size + objectBytes.size + val offsetTable = byteArrayOf(header.size.toByte()) + // Trailer: 6 unused bytes, offset size 1, object reference size 1, 1 object, top object 0, table offset. + val trailer = + ByteArray(TRAILER_UNUSED) + byteArrayOf(1, 1) + long(1) + long(0) + long(offsetTableOffset.toLong()) + return header + objectBytes + offsetTable + trailer + } + + private fun long(value: Long): ByteArray = ByteArray(Long.SIZE_BYTES) { index -> + (value shr (Long.SIZE_BITS - Byte.SIZE_BITS * (index + 1))).toByte() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt new file mode 100644 index 00000000..94641057 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt @@ -0,0 +1,165 @@ +package com.pasich.encly.desktop.storage + +import com.pasich.encly.core.platform.KeyValueStore +import java.io.IOException +import java.io.StringWriter +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.charset.CharacterCodingException +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.Properties +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +/** + * [KeyValueStore] in one small properties file inside the app's data directory. It replaces + * `java.util.prefs`, which writes to the registry on Windows and a plist in `~/Library/Preferences` on macOS, + * outside the private directory and out of Encly's control. + * + * Every change rewrites the whole file to a temp file in the same directory, fsyncs it and renames it over the + * old one (an atomic replace), so a crash leaves the old content or the new, never a mix. [edit] returns at + * once and writes on [writer]; [commit] writes before it returns. A file that is absent starts as empty. A file + * that exists but cannot be read (an I/O error: a scanner holds it, the disk hiccups) fails closed: nothing is + * changed or written, so the file is never replaced by an empty state, and every read tries the file again. + * Meanwhile reads give the defaults. A file that was read but is not valid (damaged) is moved aside as + * `.damaged` and the store starts empty and writable: the flags are not secret, and the vault does not + * depend on them to be recognised. + */ +class FileKeyValueStore( + private val file: Path, + private val writer: Executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "encly-flags-writer").apply { isDaemon = true } + }, +) : KeyValueStore { + private val stateLock = Any() + private val writeLock = Any() + + // Guarded by [stateLock]. Null while the file exists but could not be read. + private var values: MutableMap? = load()?.toMutableMap() + + override fun getString(key: String, default: String?): String? = read { it[key] } ?: default + + override fun getBoolean(key: String, default: Boolean): Boolean = + read { it[key] }?.toBooleanStrictOrNull() ?: default + + override fun getLong(key: String, default: Long): Long = read { it[key] }?.toLongOrNull() ?: default + + override fun contains(key: String): Boolean = read { it.containsKey(key) } ?: false + + override fun edit(block: KeyValueStore.Editor.() -> Unit) { + if (read { MapEditor(it).block() } != null) writer.execute { persist() } + } + + override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean = + read { MapEditor(it).block() } != null && persist() + + /** + * [action] on the values, or null while the file is unreadable (it is read again first, and taken in once it + * can be read). + */ + private fun read(action: (MutableMap) -> T): T? = synchronized(stateLock) { + val current = values ?: load()?.toMutableMap()?.also { values = it } ?: return null + action(current) + } + + /** Writes the current values; false if the file could not be replaced (the values stay in memory). */ + private fun persist(): Boolean = synchronized(writeLock) { + val snapshot = synchronized(stateLock) { values?.toMap() } ?: return false + try { + write(snapshot) + true + } catch (_: IOException) { + false + } + } + + private fun write(snapshot: Map) { + val dir = file.toAbsolutePath().parent + Files.createDirectories(dir) + // createTempFile makes the file owner-only (0600) where POSIX permissions exist. + val temp = Files.createTempFile(dir, file.fileName.toString(), ".tmp") + try { + val properties = Properties().apply { snapshot.forEach { (key, value) -> setProperty(key, value) } } + val text = StringWriter().also { properties.store(it, null) }.toString() + FileChannel.open(temp, StandardOpenOption.WRITE).use { channel -> + val buffer = ByteBuffer.wrap(text.toByteArray(StandardCharsets.UTF_8)) + while (buffer.hasRemaining()) channel.write(buffer) + channel.force(true) + } + moveOver(temp) + } catch (e: IOException) { + Files.deleteIfExists(temp) + throw e + } + } + + private fun moveOver(temp: Path) { + try { + Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING) + } + } + + /** + * The file's content; empty when there is no file or a damaged one was moved aside, null when there is one that + * cannot be read right now. + */ + private fun load(): Map? { + // Only a file known to be absent starts empty; "cannot tell" goes on to the read and fails closed. + if (Files.notExists(file)) return emptyMap() + return try { + val properties = Properties() + Files.newBufferedReader(file, StandardCharsets.UTF_8).use(properties::load) + properties.stringPropertyNames().associateWith { properties.getProperty(it) } + } catch (_: CharacterCodingException) { + // Not UTF-8: the file is damaged, not busy. + moveAside() + } catch (_: IOException) { + null + } catch (_: IllegalArgumentException) { + // A malformed \u escape: the file is damaged. + moveAside() + } + } + + /** Keeps a damaged file as `.damaged` and starts empty; null if it cannot be moved (try again later). */ + private fun moveAside(): Map? = try { + val aside = file.resolveSibling(file.fileName.toString() + DAMAGED_SUFFIX) + Files.move(file, aside, StandardCopyOption.REPLACE_EXISTING) + emptyMap() + } catch (_: IOException) { + null + } + + private companion object { + const val DAMAGED_SUFFIX = ".damaged" + } + + private class MapEditor(private val map: MutableMap) : KeyValueStore.Editor { + override fun putString(key: String, value: String) { + map[key] = value + } + + override fun putBoolean(key: String, value: Boolean) { + map[key] = value.toString() + } + + override fun putLong(key: String, value: Long) { + map[key] = value.toString() + } + + override fun remove(key: String) { + map.remove(key) + } + + override fun clear() { + map.clear() + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/DesktopCommands.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/DesktopCommands.kt new file mode 100644 index 00000000..4136f3bc --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/DesktopCommands.kt @@ -0,0 +1,182 @@ +package com.pasich.encly.desktop.window + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.domain.model.ThemeType +import com.pasich.encly.platform.AppLocaleController +import com.pasich.encly.platform.NavigationRequests +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.designsystem.EnclyShortcuts +import com.pasich.encly.presentation.navigation.NavRoutes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * What the menu bar (and later the keyboard shortcuts and the tray) can ask the app to do. The window host + * wires menu items to this and nothing else, so the screens are not edited for them: a command either calls + * the session ([lockNow]), a setting, or asks the UI to open a screen through [NavigationRequests]. + * + * Commands that open a screen are ignored while the vault is locked (see `HostNavigationRequests`): no menu + * item skips the lock screen. A new note is asked for as the [AppCommand] it is, so it opens the way the + * keyboard shortcut and the button open it (as a tab, in a wide window). Every window has its own commands over + * its own [NavigationRequests], so a menu item acts on the window it belongs to (the focused one). + */ +@Suppress("TooManyFunctions") // One function per item of the menu bar. +interface DesktopCommands { + fun newNote() + + /** Another window on the same vault. */ + fun newWindow() + + /** Print the note open in this window (nothing if none is open). */ + fun printNote() + + /** Save the note open in this window as a PDF (nothing if none is open). */ + fun exportPdf() + + fun importNotes() + + /** Backup, restore and the Markdown export all live on the backup screen. */ + fun backup() + + fun settings() + + fun about() + + fun licences() + + fun faq() + + fun lockNow() + + fun setLanguage(language: AppLanguage) + + fun setTheme(type: ThemeType) + + fun minimise() + + fun quit() +} + +/** [DesktopCommands] over the graph's services. */ +@Suppress("TooManyFunctions") // One function per item of the menu bar. +class DefaultDesktopCommands( + private val navigation: NavigationRequests, + private val lockSession: () -> Unit, + private val locales: AppLocaleController, + private val saveThemeType: suspend (ThemeType) -> Unit, + private val scope: CoroutineScope, + private val onMinimise: () -> Unit, + private val onQuit: () -> Unit, + private val openWindow: () -> Unit = {}, +) : DesktopCommands { + override fun newNote() { + navigation.request(AppCommand.NewNote) + } + + override fun newWindow() = openWindow() + + override fun printNote() { + navigation.request(AppCommand.PrintNote) + } + + override fun exportPdf() { + navigation.request(AppCommand.ExportPdf) + } + + override fun importNotes() = open(NavRoutes.ImportNotesRoute.name) + + override fun backup() = open(NavRoutes.BackupRoute.name) + + override fun settings() = open(NavRoutes.SettingsRoute.name) + + override fun about() = open(NavRoutes.AboutRoute.name) + + override fun licences() = open(NavRoutes.LicensesRoute.name) + + override fun faq() = open(NavRoutes.FaqRoute.name) + + override fun lockNow() = lockSession() + + override fun setLanguage(language: AppLanguage) = locales.apply(language) + + override fun setTheme(type: ThemeType) { + scope.launch { saveThemeType(type) } + } + + override fun minimise() = onMinimise() + + override fun quit() = onQuit() + + private fun open(route: String) { + navigation.request(route) + } +} + +/** A key chord of the menu bar. Compose's own `KeyShortcut` hides its fields, so the map is kept in this. */ +data class Accelerator( + val key: Key, + val ctrl: Boolean = false, + val meta: Boolean = false, + val shift: Boolean = false, + val alt: Boolean = false, +) { + /** The shortcut a menu item takes. */ + fun toKeyShortcut(): KeyShortcut = KeyShortcut(key = key, ctrl = ctrl, meta = meta, shift = shift, alt = alt) +} + +/** + * The accelerators the menu bar registers, and who owns which chord. The key map of the window is + * [EnclyShortcuts] (one instance, [map], shared by the menu and the keyboard host). A command that map knows is + * either the menu's ([menuOwned]: the menu registers its chord, built from the map's own key, and the host + * declines it) or the host's (the menu registers nothing for it), never both: on a Mac the menu bar is native and + * takes its chords before any key reaches the window, so a chord both sides took would run twice or not at all. + * The chords below that are not in the map (new window, print, PDF, import, backup, settings, minimise, quit) are the + * menu's alone: none of them is a chord the map answers, so the host never runs one. + */ +object DesktopShortcuts { + /** The commands whose accelerators the menu registers; the keyboard host declines them. */ + val menuOwned: Set = setOf(AppCommand.NewNote, AppCommand.LockApp) + + /** The key map of a window on [os]: Cmd is the command key on macOS, Ctrl everywhere else. */ + fun map(os: DesktopOs): EnclyShortcuts = EnclyShortcuts(commandKeyIsMeta = os == DesktopOs.MACOS) + + fun primary(os: DesktopOs, key: Key, shift: Boolean = false): Accelerator = + Accelerator(key = key, ctrl = os != DesktopOs.MACOS, meta = os == DesktopOs.MACOS, shift = shift) + + /** The accelerator of [command] from the window's key map; the command must be one of its letter chords. */ + fun of(shortcuts: EnclyShortcuts, command: AppCommand): Accelerator { + val key = requireNotNull(shortcuts.keyOf(command)) { "$command has no letter chord" } + return Accelerator(key = key, ctrl = !shortcuts.usesMetaKey, meta = shortcuts.usesMetaKey) + } + + fun newWindow(os: DesktopOs) = primary(os, Key.N, shift = true) + + fun printNote(os: DesktopOs) = primary(os, Key.P) + + fun exportPdf(os: DesktopOs) = primary(os, Key.P, shift = true) + + fun importNotes(os: DesktopOs) = primary(os, Key.I) + + fun backup(os: DesktopOs) = primary(os, Key.B) + + fun settings(os: DesktopOs) = primary(os, Key.Comma) + + fun minimise(os: DesktopOs) = primary(os, Key.M) + + fun quit(os: DesktopOs) = primary(os, Key.Q) + + /** The chords only the menu has, which the key map does not know. */ + fun menuOnly(os: DesktopOs): List = listOf( + newWindow(os), + printNote(os), + exportPdf(os), + importNotes(os), + backup(os), + settings(os), + minimise(os), + quit(os), + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/DesktopWindows.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/DesktopWindows.kt new file mode 100644 index 00000000..7aeaf5ef --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/DesktopWindows.kt @@ -0,0 +1,243 @@ +package com.pasich.encly.desktop.window + +import androidx.compose.runtime.mutableStateListOf +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.desktop.platform.DesktopDocumentPickers +import com.pasich.encly.desktop.platform.DesktopEnvironment +import com.pasich.encly.desktop.platform.DesktopToaster +import com.pasich.encly.desktop.platform.DropRouter +import com.pasich.encly.desktop.platform.GatedFileDialogs +import com.pasich.encly.desktop.security.DesktopAppLifecycle +import com.pasich.encly.platform.NavigationRequests +import com.pasich.encly.platform.NoteEditClaims +import com.pasich.encly.platform.NoteEditRegistry +import com.pasich.encly.platform.NoteWindows +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Path + +/** + * What belongs to one window and must not be shared between windows: the requests the menu sends it, the files + * dropped on it (and the screens waiting for them), the file dialogs those screens open, and its messages. The vault, + * the lock, the settings and the other services of the graph are the process's, one for all windows. + */ +class WindowServices( + val navigation: NavigationRequests, + val dropRouter: DropRouter, + val documentPickers: DesktopDocumentPickers, + /** "Open in new window" for the screens of this window; null in a host with one window. */ + val noteWindows: NoteWindows? = null, + /** This window's claims on the notes it edits; null where nothing shares a vault between windows. */ + val claims: NoteEditClaims? = null, + /** The messages of this window: a copy or a failure is told in the window that caused it, not in every one. */ + val toaster: DesktopToaster = DesktopToaster(), +) { + /** Completed with the vault's start status once the window's app has resolved it (see `DesktopApp`). */ + val startStatus = CompletableDeferred() + + /** + * Waits until files given to the app can be taken: the start status is known and, for a vault that starts + * locked, until it is unlocked ([locked] turns false) and the lock screen has left; [settle] pauses for that. + * Files handed over before this would meet a window that cannot take them yet and be dropped without a word. + */ + suspend fun awaitReadyForFiles(locked: StateFlow, settle: suspend () -> Unit) { + if (startStatus.await() != InitialStatus.AUTH) return + if (locked.value) { + locked.first { !it } + settle() + } + } + + companion object { + /** The services of a window of [environment]. */ + fun create( + environment: DesktopEnvironment, + lifecycle: DesktopAppLifecycle, + noteWindows: NoteWindows? = null, + claims: NoteEditClaims? = null, + ): WindowServices { + val navigation = NavigationRequests() + val drops = DropRouter(navigation) + val pickers = DesktopDocumentPickers(GatedFileDialogs(environment.fileDialogs, lifecycle), drops) + return WindowServices(navigation, drops, pickers, noteWindows, claims) + } + } +} + +/** + * Hands [files] to this window once it can take them (see [WindowServices.awaitReadyForFiles]). A fixed pause was not + * enough: a slow start (a big vault, a busy disk) took longer, and the files met a window that was not ready and were + * dropped without a word. + */ +suspend fun WindowServices.handOverFiles(locked: StateFlow, files: List, settle: suspend () -> Unit) { + awaitReadyForFiles(locked, settle) + dropRouter.drop(files) +} + +/** One open window: its [id], its [services] and, for a window opened on a note, the note's id. */ +class WindowSession( + val id: Int, + val services: WindowServices, + val openNoteId: Long? = null, + /** The place this window keeps its size and position under: the lowest one no other open window uses. */ + val slot: Int = 0, +) { + /** What the menu of this window does; set by the window host once its window exists. */ + @Volatile + var commands: DesktopCommands? = null +} + +/** + * The open windows of the app and which one has the focus. Every window shows the same vault behind the same lock + * (one `SessionLockManager`): this class only decides which windows exist. + * + * Closing the last window ends the app ([onLastClosed]); a window cannot be opened before the vault exists + * ([canOpen]: not during onboarding or when the vault is damaged). + */ +class DesktopWindows( + private val canOpen: () -> Boolean, + private val onLastClosed: () -> Unit, + private val create: (id: Int, windows: DesktopWindows) -> WindowServices, +) { + private var nextId = 1 + private val open = mutableStateListOf() + + /** The vault's note claims, one registry for all windows. */ + val registry = NoteEditRegistry() + + /** The windows, in the order they were opened. Observable. */ + val sessions: List get() = open + + /** The window that was active last: where the menu bar's commands and the OS's requests go. */ + var focusedId: Int? = null + private set + + val focused: WindowSession? get() = open.firstOrNull { it.id == focusedId } ?: open.lastOrNull() + + /** Opens a window, on note [noteId] if given. Null when the vault does not allow a window yet. */ + fun open(noteId: Long? = null): WindowSession? { + // The first window is the app itself; a further one needs a vault that exists. + if (open.isNotEmpty() && !canOpen()) return null + val id = nextId++ + val used = open.map { it.slot }.toSet() + val slot = generateSequence(0) { it + 1 }.first { it !in used } + val session = WindowSession(id, create(id, this), noteId, slot) + open.add(session) + focusedId = id + return session + } + + /** The window [id] was closed. Closing the last window ends the app. */ + fun close(id: Int) { + val index = open.indexOfFirst { it.id == id } + if (index < 0) return + open.removeAt(index) + if (focusedId == id) focusedId = open.lastOrNull()?.id + if (open.isEmpty()) onLastClosed() + } + + /** Closes every window at once (Quit): the app ends, once. */ + fun closeAll() { + if (open.isEmpty()) return + open.clear() + focusedId = null + onLastClosed() + } + + /** The window [id] became the active one. */ + fun focus(id: Int) { + if (open.any { it.id == id }) focusedId = id + } + + /** Whether [id] is one of the open windows. */ + fun isOpen(id: Int): Boolean = open.any { it.id == id } +} + +/** The AWT frames of the app's windows, to tell the focus moving between them from the app being left. */ +class WindowFrames { + private val frames = mutableSetOf() + + fun add(frame: java.awt.Window) { + frames += frame + } + + fun remove(frame: java.awt.Window) { + frames -= frame + } + + /** Whether [frame] is one of the app's windows; a null (the focus left the app) is not. */ + operator fun contains(frame: java.awt.Window?): Boolean = frame != null && frame in frames +} + +/** + * Tells the app's one lifecycle what the windows together do. The app is in front while any window is active and + * not every window is minimised. Focus moving from one window of the app to another is not leaving the app: the + * first window's deactivation names the second as its opposite, and nothing is reported then, so `SessionLockManager` + * never sees a stop (which, with auto-lock set to "immediately", would lock the vault on every click between windows). + */ +class WindowForeground(private val lifecycle: DesktopAppLifecycle) { + private val windows = mutableSetOf() + private val active = mutableSetOf() + private val minimised = mutableSetOf() + + fun opened(id: Int) { + windows += id + } + + fun activated(id: Int) { + active += id + report() + } + + /** Window [id] lost the focus; [toOwnWindow] says the focus went to another window of the app. */ + fun deactivated(id: Int, toOwnWindow: Boolean) { + active -= id + if (!toOwnWindow) report() + } + + fun iconified(id: Int) { + minimised += id + report() + } + + fun deiconified(id: Int) { + minimised -= id + report() + } + + /** + * Window [id] is gone. It is never reported as the app being left: while other windows remain the focus goes to + * one of them, and with none left the app is ending, where the vault is locked only after the editors saved + * (see `awaitPendingWork`), not by the last window disappearing under them. + */ + fun closed(id: Int) { + windows -= id + active -= id + minimised -= id + reportMinimised() + } + + private fun report() { + if (active.isNotEmpty()) lifecycle.windowActivated() else lifecycle.windowDeactivated() + reportMinimised() + } + + private fun reportMinimised() { + val allMinimised = windows.isNotEmpty() && minimised.containsAll(windows) + if (allMinimised) lifecycle.windowMinimised() else lifecycle.windowRestored() + } +} + +/** + * Waits (at most [timeoutMillis]) for the work this scope launched to finish: the last-chance saves of the editors + * that were just closed run here, and the vault must not be closed under them. + */ +suspend fun CoroutineScope.awaitPendingWork(timeoutMillis: Long) { + val jobs = coroutineContext[Job]?.children?.toList().orEmpty() + withTimeoutOrNull(timeoutMillis) { jobs.joinAll() } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/IdleLockWhileOpen.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/IdleLockWhileOpen.kt new file mode 100644 index 00000000..ebed57bb --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/IdleLockWhileOpen.kt @@ -0,0 +1,46 @@ +package com.pasich.encly.desktop.window + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.pasich.encly.desktop.di.DesktopAppGraph +import com.pasich.encly.desktop.security.CoarseUptime +import com.pasich.encly.desktop.security.IdleLock +import com.pasich.encly.desktop.security.OsUptimeClock +import java.awt.AWTEvent +import java.awt.Toolkit +import java.awt.event.AWTEventListener + +private const val NANOS_PER_MILLI = 1_000_000L + +private const val INPUT_EVENTS: Long = AWTEvent.KEY_EVENT_MASK or + AWTEvent.MOUSE_EVENT_MASK or + AWTEvent.MOUSE_MOTION_EVENT_MASK or + AWTEvent.MOUSE_WHEEL_EVENT_MASK + +/** + * One idle timer for the process: the AWT listener sees the input of every window. The time is measured on the OS + * uptime, which counts the time asleep, so a machine that slept past the limit locks when it wakes. Like the window + * host, it needs real AWT and is outside the coverage gate; [IdleLock] and [CoarseUptime] are tested. + */ +@Composable +internal fun IdleLockWhileOpen(graph: DesktopAppGraph) { + DisposableEffect(graph) { + val uptime = OsUptimeClock(graph.environment.bootClock) + val coarse = CoarseUptime(uptime::elapsedRealtime) + val idle = IdleLock( + graph.environment.timer, + graph.autoLockPolicy, + lockNow = graph.sessionLockManager::lockNow, + // Every input event only reads the cheap clock; the check itself asks the OS. + clock = coarse::nanos, + checkClock = { uptime.elapsedRealtime() * NANOS_PER_MILLI }, + ) + val listener = AWTEventListener { idle.touch() } + Toolkit.getDefaultToolkit().addAWTEventListener(listener, INPUT_EVENTS) + idle.start() + onDispose { + idle.stop() + Toolkit.getDefaultToolkit().removeAWTEventListener(listener) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowHost.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowHost.kt new file mode 100644 index 00000000..f974153c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowHost.kt @@ -0,0 +1,462 @@ +package com.pasich.encly.desktop.window + +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.awtTransferable +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.loadImageBitmap +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.MenuBar +import androidx.compose.ui.window.MenuBarScope +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.about +import com.pasich.encly.core.resources.backup_title +import com.pasich.encly.core.resources.import_title +import com.pasich.encly.core.resources.licenses_title +import com.pasich.encly.core.resources.lock_now +import com.pasich.encly.core.resources.main_drawer_faq +import com.pasich.encly.core.resources.main_drawer_settings +import com.pasich.encly.core.resources.menu_exit +import com.pasich.encly.core.resources.menu_file +import com.pasich.encly.core.resources.menu_help +import com.pasich.encly.core.resources.menu_minimize +import com.pasich.encly.core.resources.menu_new_window +import com.pasich.encly.core.resources.menu_view +import com.pasich.encly.core.resources.menu_window +import com.pasich.encly.core.resources.note_add +import com.pasich.encly.core.resources.note_export_pdf +import com.pasich.encly.core.resources.note_print +import com.pasich.encly.core.resources.settings_language +import com.pasich.encly.core.resources.theme +import com.pasich.encly.core.resources.theme_type_dark +import com.pasich.encly.core.resources.theme_type_light +import com.pasich.encly.core.resources.theme_type_system +import com.pasich.encly.desktop.DesktopApp +import com.pasich.encly.desktop.di.DesktopAppGraph +import com.pasich.encly.desktop.platform.DesktopAppWindow +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.domain.model.ThemeType +import com.pasich.encly.platform.NoteWindows +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.designsystem.EnclyShortcuts +import com.pasich.encly.presentation.navigation.NavRoutes +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import java.awt.Desktop +import java.awt.Dimension +import java.awt.GraphicsEnvironment +import java.awt.datatransfer.DataFlavor +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import java.io.File +import java.nio.file.Path + +// The windows, their menu bars, drag and drop and the OS hooks. They need real AWT windows, so they cannot run on a +// headless machine and are excluded from the coverage gate (see build.gradle.kts); the logic they use +// (DesktopWindows, WindowForeground, DesktopCommands, DesktopShortcuts, WindowStateStore, DropRouter, IdleLock, +// DesktopAppLifecycle) is tested. + +private const val FIRST_FRAME_MILLIS = 1_000L +private const val SAVE_DEBOUNCE_MILLIS = 500L +private const val SAVE_WAIT_MILLIS = 3_000L + +/** + * Encly's windows. The first one opens at once; "New window" and "Open in new window" add more, all showing the + * same vault behind the same lock (see [DesktopWindows]). [launchFiles] are the backup or notes files the OS started + * the app with; the first window takes them. [onExit] ends the app: the last window closing, or Quit. + */ +@Composable +fun ApplicationScope.EnclyWindows(graph: DesktopAppGraph, launchFiles: List, onExit: () -> Unit) { + val environment = graph.environment + val foreground = remember { WindowForeground(graph.lifecycle) } + // The frames of this app, to tell the focus moving between them from the app being left. + val frames = remember { WindowFrames() } + var ending by remember { mutableStateOf(false) } + val currentOnExit by rememberUpdatedState(onExit) + val windows = remember { + DesktopWindows( + canOpen = graph.securityManager::isLockable, + onLastClosed = { ending = true }, + create = { id, owner -> + WindowServices.create( + environment = environment, + lifecycle = graph.lifecycle, + noteWindows = NoteWindows { noteId -> owner.open(noteId) }, + claims = owner.registry.forWindow(id), + ) + }, + ).also { it.open() } + } + + // The last window is gone: its editors have been cleared and save now; the vault is closed after they finish. + if (ending) { + LaunchedEffect(Unit) { + graph.applicationScope.awaitPendingWork(SAVE_WAIT_MILLIS) + currentOnExit() + } + } + + IdleLockWhileOpen(graph) + // The OS's own hooks are the process's too: they go to the window that was active last. + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + installOsHandlers(windows) { files -> + // A file opened with the app waits for the window like one it was started with. + windows.focused?.let { window -> + scope.launch { + window.services.handOverFiles(graph.sessionLockManager.locked, files) { delay(FIRST_FRAME_MILLIS) } + } + } + } + } + + windows.sessions.forEach { session -> + key(session.id) { + EnclyWindow( + graph = graph, + windows = windows, + foreground = foreground, + frames = frames, + session = session, + launchFiles = if (session.id == FIRST_WINDOW) launchFiles else emptyList(), + onQuit = windows::closeAll, + ) + } + } +} + +/** One window of the app. Every window remembers its own place and size (see [WindowStateStore]). */ +@OptIn(FlowPreview::class) +@Suppress("LongMethod", "LongParameterList", "ModifierMissing") // A window has no parent to size it. +@Composable +private fun ApplicationScope.EnclyWindow( + graph: DesktopAppGraph, + windows: DesktopWindows, + foreground: WindowForeground, + frames: WindowFrames, + session: WindowSession, + launchFiles: List, + onQuit: () -> Unit, +) { + val environment = graph.environment + val os = environment.os + val store = remember(session) { WindowStateStore(graph.flags, session.slot) } + // A window that has no place of its own yet opens at the size of the first one, where the OS puts it. + val saved = remember(session) { + store.load(screenBounds()) + ?: WindowStateStore(graph.flags).load(screenBounds())?.copy(x = null, y = null, maximised = false) + } + val state = rememberWindowState( + size = DpSize( + (saved?.width ?: WindowStateStore.DEFAULT_WIDTH).dp, + (saved?.height ?: WindowStateStore.DEFAULT_HEIGHT).dp, + ), + position = if (saved?.x != null && saved.y != null) { + WindowPosition(saved.x.dp, saved.y.dp) + } else { + WindowPosition.PlatformDefault + }, + placement = if (saved?.maximised == true) WindowPlacement.Maximized else WindowPlacement.Floating, + ) + val commands = remember { + DefaultDesktopCommands( + navigation = session.services.navigation, + lockSession = graph.sessionLockManager::lockNow, + locales = graph.locales, + saveThemeType = graph.settingsRepository::setThemeType, + scope = graph.applicationScope, + onMinimise = { state.isMinimized = true }, + onQuit = onQuit, + openWindow = { windows.open() }, + ) + } + session.commands = commands + // Known to the foreground before the window exists, so the focus arriving from the window it was opened from + // is seen as moving between our own windows. + remember(session) { foreground.opened(session.id) } + DisposableEffect(session) { onDispose { foreground.closed(session.id) } } + val icon = remember { loadWindowIcon() } + // The one key map of the window: the menu's accelerators and the keyboard host read the same. + val shortcuts = remember(os) { DesktopShortcuts.map(os) } + + Window( + onCloseRequest = { windows.close(session.id) }, + state = state, + title = "Encly", + icon = icon, + ) { + val composeWindow = window + SideEffect { composeWindow.minimumSize = Dimension(WindowStateStore.MIN_WIDTH, WindowStateStore.MIN_HEIGHT) } + + DisposableEffect(composeWindow) { + frames.add(composeWindow) + environment.dialogParent.frame = composeWindow + val listener = object : WindowAdapter() { + override fun windowOpened(e: WindowEvent) { + environment.captureExclusion.exclude(composeWindow) + } + + override fun windowActivated(e: WindowEvent) { + windows.focus(session.id) + // File dialogs belong to the window the user is working in. + environment.dialogParent.frame = composeWindow + foreground.activated(session.id) + } + + override fun windowDeactivated(e: WindowEvent) = + foreground.deactivated(session.id, toOwnWindow = e.oppositeWindow in frames) + + override fun windowIconified(e: WindowEvent) = foreground.iconified(session.id) + + override fun windowDeiconified(e: WindowEvent) = foreground.deiconified(session.id) + } + composeWindow.addWindowListener(listener) + // The window is already open when the effect runs the first time. + environment.captureExclusion.exclude(composeWindow) + onDispose { + composeWindow.removeWindowListener(listener) + frames.remove(composeWindow) + if (environment.dialogParent.frame === composeWindow) environment.dialogParent.frame = null + } + } + + LaunchedEffect(state) { + snapshotFlow { Triple(state.size, state.position, state.placement) } + .debounce(SAVE_DEBOUNCE_MILLIS) + .collect { + val maximised = state.placement == WindowPlacement.Maximized + val bounds = composeWindow.bounds.let { WindowBounds(it.x, it.y, it.width, it.height) } + store.save(bounds, maximised) + } + } + + LaunchedEffect(Unit) { + if (launchFiles.isNotEmpty()) { + session.services.handOverFiles(graph.sessionLockManager.locked, launchFiles) { + delay(FIRST_FRAME_MILLIS) + } + } + } + + LaunchedEffect(session) { openStartNote(graph, session) } + + EnclyMenuBar(os, shortcuts, commands, session) + + val appWindow = remember(composeWindow) { + DesktopAppWindow(environment.captureExclusion, windowProvider = { composeWindow }) + } + Box(Modifier.fillMaxSize().dropFiles(session.services.dropRouter::drop)) { + DesktopApp(graph, appWindow, shortcuts = shortcuts, services = session.services) + } + } +} + +/** + * A window opened on a note shows it as soon as the vault is open: at once when it already is, else after the + * unlock (and the lock screen's own way to Home) of whichever window the user unlocks. + */ +private suspend fun openStartNote(graph: DesktopAppGraph, session: WindowSession) { + val noteId = session.openNoteId ?: return + val route = "${NavRoutes.EditNoteRoute.name}/$noteId" + if (graph.sessionLockManager.locked.value) { + graph.sessionLockManager.locked.first { !it } + delay(FIRST_FRAME_MILLIS) + } + session.services.navigation.request(route) +} + +private const val FIRST_WINDOW = 1 + +/** Files dropped on the window go to [onDrop], which answers whether it took any. */ +private fun Modifier.dropFiles(onDrop: (List) -> Boolean): Modifier = dragAndDropTarget( + shouldStartDragAndDrop = { event -> event.awtTransferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor) }, + target = object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + val transferable = event.awtTransferable + if (!transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return false + val files = (transferable.getTransferData(DataFlavor.javaFileListFlavor) as? List<*>) + .orEmpty() + .filterIsInstance() + .map(File::toPath) + return onDrop(files) + } + }, +) + +/** + * The OS's own hooks: the About and Preferences items of the macOS app menu, and files opened with the app. There is + * one of each for the process, so they act on the window that was active last ([DesktopWindows.focused]). + */ +private fun installOsHandlers(windows: DesktopWindows, onFiles: (List) -> Unit) { + if (!Desktop.isDesktopSupported()) return + val desktop = Desktop.getDesktop() + runCatching { + if (desktop.isSupported(Desktop.Action.APP_ABOUT)) { + desktop.setAboutHandler { windows.focused?.commands?.about() } + } + } + runCatching { + if (desktop.isSupported(Desktop.Action.APP_PREFERENCES)) { + desktop.setPreferencesHandler { windows.focused?.commands?.settings() } + } + } + runCatching { + if (desktop.isSupported(Desktop.Action.APP_OPEN_FILE)) { + desktop.setOpenFileHandler { event -> onFiles(event.files.map(File::toPath)) } + } + } +} + +private fun loadWindowIcon(): Painter? { + val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("encly-icon.png") ?: return null + return stream.use { BitmapPainter(loadImageBitmap(it)) } +} + +private fun screenBounds(): List = runCatching { + GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { + it.defaultConfiguration.bounds.let { b -> WindowBounds(b.x, b.y, b.width, b.height) } + } +}.getOrDefault(emptyList()) + +/** + * The menu bar of one window: the native one on macOS, the window's on Windows and Linux. On a Mac the one menu bar + * shows the active window's menu, so its items reach that window's [commands] without any routing of ours. No Edit + * menu: the text fields handle cut, copy, paste and undo from the keyboard themselves, and a menu item with the same + * shortcut would take the key from them on macOS. + */ +@Composable +private fun FrameWindowScope.EnclyMenuBar( + os: DesktopOs, + shortcuts: EnclyShortcuts, + commands: DesktopCommands, + session: WindowSession, +) { + val noteOpen by session.services.navigation.editorOpen.collectAsState() + MenuBar { + FileMenu(os, shortcuts, commands, noteOpen) + ViewMenu(commands) + Menu(stringResource(Res.string.menu_window)) { + Item( + stringResource(Res.string.menu_minimize), + shortcut = DesktopShortcuts.minimise(os).toKeyShortcut(), + onClick = commands::minimise, + ) + } + Menu(stringResource(Res.string.menu_help)) { + Item(stringResource(Res.string.about), onClick = commands::about) + Item(stringResource(Res.string.licenses_title), onClick = commands::licences) + Item(stringResource(Res.string.main_drawer_faq), onClick = commands::faq) + } + } +} + +@Composable +private fun MenuBarScope.FileMenu( + os: DesktopOs, + shortcuts: EnclyShortcuts, + commands: DesktopCommands, + noteOpen: Boolean, +) { + Menu(stringResource(Res.string.menu_file)) { + Item( + stringResource(Res.string.note_add), + shortcut = DesktopShortcuts.of(shortcuts, AppCommand.NewNote).toKeyShortcut(), + onClick = commands::newNote, + ) + Item( + stringResource(Res.string.menu_new_window), + shortcut = DesktopShortcuts.newWindow(os).toKeyShortcut(), + onClick = commands::newWindow, + ) + Separator() + Item( + stringResource(Res.string.note_export_pdf), + enabled = noteOpen, + shortcut = DesktopShortcuts.exportPdf(os).toKeyShortcut(), + onClick = commands::exportPdf, + ) + Item( + stringResource(Res.string.note_print), + enabled = noteOpen, + shortcut = DesktopShortcuts.printNote(os).toKeyShortcut(), + onClick = commands::printNote, + ) + Separator() + Item( + stringResource(Res.string.import_title), + shortcut = DesktopShortcuts.importNotes(os).toKeyShortcut(), + onClick = commands::importNotes, + ) + Item( + stringResource(Res.string.backup_title), + shortcut = DesktopShortcuts.backup(os).toKeyShortcut(), + onClick = commands::backup, + ) + Separator() + Item( + stringResource(Res.string.lock_now), + shortcut = DesktopShortcuts.of(shortcuts, AppCommand.LockApp).toKeyShortcut(), + onClick = commands::lockNow, + ) + Item( + stringResource(Res.string.main_drawer_settings), + shortcut = DesktopShortcuts.settings(os).toKeyShortcut(), + onClick = commands::settings, + ) + // macOS has Quit in the application menu (Cmd+Q); the other systems have it here. + if (os != DesktopOs.MACOS) { + Separator() + Item( + stringResource(Res.string.menu_exit), + shortcut = DesktopShortcuts.quit(os).toKeyShortcut(), + onClick = commands::quit, + ) + } + } +} + +@Composable +private fun MenuBarScope.ViewMenu(commands: DesktopCommands) { + Menu(stringResource(Res.string.menu_view)) { + Menu(stringResource(Res.string.theme)) { + Item(stringResource(Res.string.theme_type_system)) { commands.setTheme(ThemeType.SYSTEM) } + Item(stringResource(Res.string.theme_type_light)) { commands.setTheme(ThemeType.LIGHT) } + Item(stringResource(Res.string.theme_type_dark)) { commands.setTheme(ThemeType.DARK) } + } + Menu(stringResource(Res.string.settings_language)) { + AppLanguage.entries.forEach { language -> + Item(stringResource(language.nativeName)) { commands.setLanguage(language) } + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowStateStore.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowStateStore.kt new file mode 100644 index 00000000..9a960fa8 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowStateStore.kt @@ -0,0 +1,96 @@ +package com.pasich.encly.desktop.window + +import com.pasich.encly.core.platform.KeyValueStore + +/** A window rectangle in AWT pixels (logical, as AWT reports them). */ +data class WindowBounds(val x: Int, val y: Int, val width: Int, val height: Int) { + val right: Int get() = x + width + val bottom: Int get() = y + height + + /** The area of the overlap with [other], 0 when they do not touch. */ + fun overlapArea(other: WindowBounds): Long { + val w = minOf(right, other.right) - maxOf(x, other.x) + val h = minOf(bottom, other.bottom) - maxOf(y, other.y) + return if (w > 0 && h > 0) w.toLong() * h else 0L + } +} + +/** What is remembered of the window between runs. [bounds] is null for a first run (the OS places it). */ +data class SavedWindow(val width: Int, val height: Int, val x: Int?, val y: Int?, val maximised: Boolean) + +/** + * The window's size, position and maximised state in the app's flags file. What comes back is always usable: + * a size below the minimum or above the biggest screen is clamped, and a position that would put the window + * (almost) off every connected screen (a monitor that is gone) is dropped, so the OS centres it. + * + * Every window has its own entry: [slot] 0 is the first window, a further window takes the lowest slot no open + * window uses, so two windows keep two places and sizes between runs. + */ +class WindowStateStore( + private val flags: KeyValueStore, + private val slot: Int = 0, + private val minWidth: Int = MIN_WIDTH, + private val minHeight: Int = MIN_HEIGHT, +) { + // The first window keeps the plain keys; a further one adds its slot ("window_width_1"). The keys are written + // as constant + suffix at every call, which is also how the check that every stored key is classified (for the + // backup) finds them. + private val suffix = if (slot == 0) "" else "_$slot" + + fun load(screens: List): SavedWindow? { + if (!flags.contains(KEY_WIDTH + suffix) || !flags.contains(KEY_HEIGHT + suffix)) return null + val biggest = screens.maxByOrNull { it.width.toLong() * it.height } + val width = flags.getLong(KEY_WIDTH + suffix, 0L).toInt().coerceAtLeast(minWidth).let { w -> + if (biggest == null) w else w.coerceAtMost(biggest.width) + } + val height = flags.getLong(KEY_HEIGHT + suffix, 0L).toInt().coerceAtLeast(minHeight).let { h -> + if (biggest == null) h else h.coerceAtMost(biggest.height) + } + val x = flags.getLong(KEY_X + suffix, Long.MIN_VALUE).takeIf { flags.contains(KEY_X + suffix) }?.toInt() + val y = flags.getLong(KEY_Y + suffix, Long.MIN_VALUE).takeIf { flags.contains(KEY_Y + suffix) }?.toInt() + val onScreen = x != null && y != null && isVisible(WindowBounds(x, y, width, height), screens) + return SavedWindow( + width = width, + height = height, + x = if (onScreen) x else null, + y = if (onScreen) y else null, + maximised = flags.getBoolean(KEY_MAXIMISED + suffix, false), + ) + } + + /** Remembers the window. The size and position of a maximised window are kept from before it was maximised. */ + fun save(bounds: WindowBounds?, maximised: Boolean) { + flags.edit { + putBoolean(KEY_MAXIMISED + suffix, maximised) + if (bounds != null && !maximised) { + putLong(KEY_WIDTH + suffix, bounds.width.toLong()) + putLong(KEY_HEIGHT + suffix, bounds.height.toLong()) + putLong(KEY_X + suffix, bounds.x.toLong()) + putLong(KEY_Y + suffix, bounds.y.toLong()) + } + } + } + + private fun isVisible(bounds: WindowBounds, screens: List): Boolean { + if (screens.isEmpty()) return true + val needed = minOf(bounds.width.toLong() * bounds.height / VISIBLE_DIVISOR, MIN_VISIBLE_AREA) + return screens.any { bounds.overlapArea(it) >= needed } + } + + companion object { + const val MIN_WIDTH = 720 + const val MIN_HEIGHT = 520 + const val DEFAULT_WIDTH = 1080 + const val DEFAULT_HEIGHT = 720 + + // A window counts as on a screen when at least a quarter of it (or 200 x 100 px) is. + private const val VISIBLE_DIVISOR = 4 + private const val MIN_VISIBLE_AREA = 200L * 100 + + private const val KEY_WIDTH = "window_width" + private const val KEY_HEIGHT = "window_height" + private const val KEY_X = "window_x" + private const val KEY_Y = "window_y" + private const val KEY_MAXIMISED = "window_maximised" + } +} diff --git a/desktopApp/src/desktopMain/resources/encly-icon.png b/desktopApp/src/desktopMain/resources/encly-icon.png new file mode 100644 index 00000000..4a8051b1 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/encly-icon.png differ diff --git a/desktopApp/src/desktopMain/resources/fonts/NotoSansArabic-Regular.ttf b/desktopApp/src/desktopMain/resources/fonts/NotoSansArabic-Regular.ttf new file mode 100644 index 00000000..8bcaec87 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/fonts/NotoSansArabic-Regular.ttf differ diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/BackupExclusionTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/BackupExclusionTest.kt new file mode 100644 index 00000000..5269bda4 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/BackupExclusionTest.kt @@ -0,0 +1,60 @@ +package com.pasich.encly.desktop + +import com.pasich.encly.desktop.nativeos.NativeBackupExclusion +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.desktop.storage.TimeMachineExclusion +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BackupExclusionTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `the attribute value is the binary plist of the string com apple backupd`() { + val value = TimeMachineExclusion.attributeValue() + + // "bplist00", the string object (0x5F, 1-byte int 17, 17 characters), one offset, the 32-byte trailer. + assertEquals(8 + 3 + 17 + 1 + 32, value.size) + assertContentEquals("bplist00".toByteArray(), value.copyOfRange(0, 8)) + assertEquals(0x5F.toByte(), value[8]) + assertEquals(0x10.toByte(), value[9]) + assertEquals(17.toByte(), value[10]) + assertEquals("com.apple.backupd", String(value, 11, 17, Charsets.US_ASCII)) + // The offset table has the one object at offset 8 and sits right after the object. + assertEquals(8.toByte(), value[28]) + val trailer = value.copyOfRange(29, value.size) + assertContentEquals(ByteArray(6), trailer.copyOfRange(0, 6)) + assertEquals(1.toByte(), trailer[6]) + assertEquals(1.toByte(), trailer[7]) + assertEquals(1L, trailer.copyOfRange(8, 16).fold(0L) { acc, b -> (acc shl 8) or (b.toLong() and 0xff) }) + assertEquals(0L, trailer.copyOfRange(16, 24).fold(0L) { acc, b -> (acc shl 8) or (b.toLong() and 0xff) }) + assertEquals(28L, trailer.copyOfRange(24, 32).fold(0L) { acc, b -> (acc shl 8) or (b.toLong() and 0xff) }) + } + + @Test + fun `other systems have nothing to exclude and say so`() { + assertTrue(NativeBackupExclusion.forOs(DesktopOs.WINDOWS).exclude(temp.root.toPath())) + assertTrue(NativeBackupExclusion.forOs(DesktopOs.LINUX).exclude(temp.root.toPath())) + } + + @Test + fun `time machine really leaves the directory out`() { + assumeTrue("ENCLY_NATIVE_IT is not set", System.getenv("ENCLY_NATIVE_IT") == "1") + assumeTrue(System.getProperty("os.name").lowercase().startsWith("mac")) + val directory = temp.newFolder("encly-data").toPath() + + assertTrue(NativeBackupExclusion.forOs(DesktopOs.MACOS).exclude(directory)) + + val answer = ProcessBuilder("tmutil", "isexcluded", directory.toString()) + .redirectErrorStream(true) + .start() + .let { process -> process.inputStream.bufferedReader().readText().also { process.waitFor() } } + assertTrue("[Excluded]" in answer, "tmutil said: $answer") + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/ClipboardTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/ClipboardTest.kt new file mode 100644 index 00000000..a7b21d17 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/ClipboardTest.kt @@ -0,0 +1,338 @@ +package com.pasich.encly.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import com.pasich.encly.desktop.platform.ClipboardWiper +import com.pasich.encly.desktop.platform.DesktopSensitiveClipboard +import com.pasich.encly.desktop.platform.SensitiveClipboardBoundary +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.awt.datatransfer.Clipboard +import java.awt.datatransfer.ClipboardOwner +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import androidx.compose.ui.platform.Clipboard as ComposeClipboard + +class ClipboardWiperTest { + private val board = Clipboard("test") + private val timer = ManualTimer() + private val wiper = ClipboardWiper({ board }, timer) + private val clipboard = DesktopSensitiveClipboard({ board }, wiper) + + private fun text(): String? = if (board.isDataFlavorAvailable( + DataFlavor.stringFlavor, + ) + ) { + board.getData(DataFlavor.stringFlavor) as String + } else { + null + } + + @Test + fun `a copy puts the text on the clipboard and arms the wipe for a minute`() { + assertTrue(clipboard.copy("label", "secret")) + assertEquals("secret", text()) + assertEquals(ClipboardWiper.CLEAR_AFTER_MS, timer.pending.single().delayMs) + assertEquals(60_000L, ClipboardWiper.CLEAR_AFTER_MS) + } + + @Test + fun `after the minute the clipboard is emptied if it still holds the copy`() { + clipboard.copy("label", "secret") + timer.fireAll() + assertEquals("", text()) + } + + @Test + fun `something copied since is left alone`() { + clipboard.copy("label", "secret") + board.setContents(StringSelection("something else"), null) + timer.fireAll() + assertEquals("something else", text()) + } + + @Test + fun `a non text clip copied since is left alone`() { + clipboard.copy("label", "secret") + board.setContents( + object : Transferable { + private val flavor = DataFlavor("application/x-test;class=java.lang.Object", "test") + + override fun getTransferDataFlavors() = arrayOf(flavor) + + override fun isDataFlavorSupported(flavor: DataFlavor) = flavor == this.flavor + + override fun getTransferData(flavor: DataFlavor): Any = Any() + }, + null, + ) + timer.fireAll() + assertFalse(board.isDataFlavorAvailable(DataFlavor.stringFlavor)) + } + + @Test + fun `a second copy replaces the first wipe`() { + clipboard.copy("label", "one") + clipboard.copy("label", "two") + assertEquals(1, timer.pending.size) + timer.fireAll() + assertEquals("", text()) + } + + @Test + fun `a clipboard that cannot be read is emptied anyway`() { + val unreadable = object : Clipboard("unreadable") { + var emptied = false + + override fun isDataFlavorAvailable(flavor: DataFlavor): Boolean = throw IllegalStateException("busy") + + override fun setContents(contents: Transferable, owner: ClipboardOwner?) { + if (contents.getTransferData(DataFlavor.stringFlavor) == "") emptied = true + super.setContents(contents, owner) + } + } + val local = ClipboardWiper({ unreadable }, timer) + local.arm("secret") + timer.fireAll() + assertTrue(unreadable.emptied) + } + + @Test + fun `clear empties it now and cancels the wipe`() { + clipboard.copy("label", "secret") + clipboard.clear() + assertEquals("", text()) + assertTrue(timer.pending.isEmpty()) + } + + @Test + fun `quitting wipes what is still there, once`() { + clipboard.copy("label", "secret") + wiper.wipeIfStillHeld() + assertEquals("", text()) + board.setContents(StringSelection("later"), null) + wiper.wipeIfStillHeld() + assertEquals("later", text()) + } + + @Test + fun `a missing clipboard copies nothing and wipes nothing`() { + val none = DesktopSensitiveClipboard({ null }, ClipboardWiper({ null }, timer)) + assertFalse(none.copy("label", "secret")) + none.clear() + ClipboardWiper({ null }, timer).apply { + arm("x") + wipeIfStillHeld() + } + } + + @Test + fun `a clipboard held by another program refuses the copy`() { + val busy = object : Clipboard("busy") { + override fun setContents(contents: Transferable?, owner: ClipboardOwner?) = + throw IllegalStateException("busy") + } + assertFalse(DesktopSensitiveClipboard({ busy }, ClipboardWiper({ busy }, timer)).copy("l", "t")) + // Wiping a busy clipboard leaves it as it is. + ClipboardWiper({ busy }, timer).apply { + arm("x") + wipeIfStillHeld() + } + assertFalse(clipboard.systemConfirmsCopy) + } +} + +class SensitiveClipWriterTest { + private val board = Clipboard("test") + private val timer = ManualTimer() + private val written = mutableListOf() + private var accepts = true + private val wiper = ClipboardWiper({ board }, timer, writer = { text -> + written += text + accepts + }) + private val clipboard = DesktopSensitiveClipboard({ board }, wiper) + + @Test + fun `a copy goes out through the marked writer and never through AWT`() { + assertTrue(clipboard.copy("label", "secret")) + + assertEquals(listOf("secret"), written) + // AWT was not touched: its clipboard has nothing (the writer would have put the text on the real one). + assertFalse(board.isDataFlavorAvailable(DataFlavor.stringFlavor)) + // The wipe is armed all the same. + assertEquals(ClipboardWiper.CLEAR_AFTER_MS, timer.pending.single().delayMs) + } + + @Test + fun `a writer that cannot write falls back to AWT`() { + accepts = false + + assertTrue(clipboard.copy("label", "secret")) + + assertEquals("secret", board.getData(DataFlavor.stringFlavor)) + assertEquals(1, timer.pending.size) + } + + @Test + fun `writing sensitive text is refused without a writer`() { + assertFalse(ClipboardWiper({ board }, timer).writeSensitive("secret")) + assertTrue(timer.pending.isEmpty()) + } +} + +/** + * The marked clipboard against the real OS clipboard of the machine. It overwrites the user's clipboard, so it runs + * only on its own OS and only when `ENCLY_NATIVE_IT=1` is set. + */ +class NativeClipboardTest { + private fun assumeNative(osPrefix: String) { + org.junit.Assume.assumeTrue("ENCLY_NATIVE_IT is not set", System.getenv("ENCLY_NATIVE_IT") == "1") + org.junit.Assume.assumeTrue(System.getProperty("os.name").lowercase().startsWith(osPrefix)) + } + + @Test + fun `macos marks the text as concealed and transient`() { + assumeNative("mac") + val pasteboard = com.pasich.encly.desktop.nativeos.NativeClipboard.forOs( + com.pasich.encly.desktop.storage.DesktopOs.MACOS, + ) as com.pasich.encly.desktop.nativeos.MacPasteboard + + assertTrue(pasteboard.write("encly-clip-test")) + + val types = pasteboard.types() + assertTrue("public.utf8-plain-text" in types, "types: $types") + assertTrue("org.nspasteboard.ConcealedType" in types, "types: $types") + assertTrue("org.nspasteboard.TransientType" in types, "types: $types") + val text = java.awt.Toolkit.getDefaultToolkit().systemClipboard.getData(DataFlavor.stringFlavor) + assertEquals("encly-clip-test", text) + java.awt.Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(""), null) + } + + @Test + fun `windows marks the text out of history and cloud sync`() { + assumeNative("windows") + val clipboard = com.pasich.encly.desktop.nativeos.NativeClipboard.forOs( + com.pasich.encly.desktop.storage.DesktopOs.WINDOWS, + ) as com.pasich.encly.desktop.nativeos.WindowsClipboard + + assertTrue(clipboard.write("encly-clip-test")) + + for (name in listOf( + com.pasich.encly.desktop.nativeos.WindowsClipboard.EXCLUDE_FROM_MONITORS, + com.pasich.encly.desktop.nativeos.WindowsClipboard.CAN_INCLUDE_IN_HISTORY, + com.pasich.encly.desktop.nativeos.WindowsClipboard.CAN_UPLOAD_TO_CLOUD, + )) { + assertTrue(clipboard.hasMarker(name), name) + } + val text = java.awt.Toolkit.getDefaultToolkit().systemClipboard.getData(DataFlavor.stringFlavor) + assertEquals("encly-clip-test", text) + java.awt.Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(""), null) + } +} + +@OptIn(ExperimentalTestApi::class) +class ComposeClipboardBoundaryTest { + private class FakeComposeClipboard : ComposeClipboard { + var entry: ClipEntry? = null + override val nativeClipboard: Any = "native" + + override suspend fun getClipEntry(): ClipEntry? = entry + + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + entry = clipEntry + } + } + + @Composable + private fun Capture(platform: ComposeClipboard, wiper: ClipboardWiper, out: (ComposeClipboard) -> Unit) { + CompositionLocalProvider(LocalClipboard provides platform) { + SensitiveClipboardBoundary(wiper) { out(LocalClipboard.current) } + } + } + + @Test + fun `text copied in the editor is wiped like an explicit copy`() = runComposeUiTest { + val board = Clipboard("compose") + val timer = ManualTimer() + val platform = FakeComposeClipboard() + var wrapped: ComposeClipboard? = null + setContent { Capture(platform, ClipboardWiper({ board }, timer)) { wrapped = it } } + val clipboard = wrapped!! + assertEquals("native", clipboard.nativeClipboard) + + board.setContents(StringSelection("from the editor"), null) + runBlocking { clipboard.setClipEntry(ClipEntry(StringSelection("from the editor"))) } + assertEquals(1, timer.pending.size) + assertEquals( + "from the editor", + runBlocking { + (clipboard.getClipEntry()!!.nativeClipEntry as Transferable).getTransferData(DataFlavor.stringFlavor) + }, + ) + timer.fireAll() + assertEquals("", board.getData(DataFlavor.stringFlavor)) + } + + @Test + fun `text copied in the editor goes out marked and is not put there plain first`() = runComposeUiTest { + val board = Clipboard("compose") + val timer = ManualTimer() + val platform = FakeComposeClipboard() + val written = mutableListOf() + var wrapped: ComposeClipboard? = null + setContent { + Capture( + platform, + ClipboardWiper({ board }, timer, writer = { + written += it + true + }), + ) { wrapped = it } + } + + runBlocking { wrapped!!.setClipEntry(ClipEntry(StringSelection("from the editor"))) } + + assertEquals(listOf("from the editor"), written) + assertNull(platform.entry, "the delegate would have written the text unmarked") + assertEquals(1, timer.pending.size) + } + + @Test + fun `non text entries are passed on and arm nothing, clearing clears`() = runComposeUiTest { + val board = Clipboard("compose") + val timer = ManualTimer() + var wrapped: ComposeClipboard? = null + setContent { Capture(FakeComposeClipboard(), ClipboardWiper({ board }, timer)) { wrapped = it } } + val clipboard = wrapped!! + runBlocking { clipboard.setClipEntry(ClipEntry("not a transferable")) } + assertTrue(timer.pending.isEmpty()) + runBlocking { + clipboard.setClipEntry( + ClipEntry( + object : Transferable { + override fun getTransferDataFlavors() = emptyArray() + + override fun isDataFlavorSupported(flavor: DataFlavor) = false + + override fun getTransferData(flavor: DataFlavor): Any = throw UnsupportedOperationException() + }, + ), + ) + } + assertTrue(timer.pending.isEmpty()) + board.setContents(StringSelection("x"), null) + runBlocking { clipboard.setClipEntry(null) } + assertEquals("", board.getData(DataFlavor.stringFlavor)) + assertNull(runBlocking { FakeComposeClipboard().getClipEntry() }) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/GraphAndAppTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/GraphAndAppTest.kt new file mode 100644 index 00000000..2501f286 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/GraphAndAppTest.kt @@ -0,0 +1,131 @@ +package com.pasich.encly.desktop + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.onboarding_welcome_headline +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.desktop.di.DesktopAppGraph +import com.pasich.encly.desktop.window.WindowServices +import com.pasich.encly.platform.AppWindow +import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +private fun graphIn( + directory: Path, + timer: ManualTimer = ManualTimer(), + capture: RecordingCapture = RecordingCapture(), +): DesktopAppGraph = + createGraphFactory().create(testEnvironment(directory, timer = timer, capture = capture)) + +class DesktopAppGraphTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `the graph builds and gives out every service`() { + val graph = graphIn(temp.root.toPath()) + assertNotNull(graph.securityManager) + assertNotNull(graph.sessionLockManager) + assertNotNull(graph.settingsRepository) + assertNotNull(graph.autoLockPolicy) + assertNotNull(graph.handoffStagingSweeper) + assertNotNull(graph.flags) + assertNotNull(graph.applicationScope) + assertNotNull(graph.lifecycle) + assertNotNull(graph.deviceLockWatcher) + assertNotNull(graph.toaster) + assertNotNull(graph.clipboardWiper) + assertNotNull(graph.locales) + assertNotNull(graph.noteDocuments) + assertNotNull(graph.externalLinks) + assertNotNull(graph.clipboard) + assertNotNull(graph.appInfo) + assertNotNull(graph.metroViewModelFactory) + assertSame(graph.environment.dataDirectory, graph.environment.dataDirectory) + } + + @Test + fun `singletons are one instance`() { + val graph = graphIn(temp.root.toPath()) + assertSame(graph.securityManager, graph.securityManager) + assertSame(graph.sessionLockManager, graph.sessionLockManager) + assertSame(graph.noteDocuments, graph.noteDocuments) + assertSame(graph.flags, graph.flags) + } + + @Test + fun `the flags and settings live in the data directory`() { + val graph = graphIn(temp.root.toPath()) + graph.flags.commit { putBoolean("probe", true) } + assertTrue(Files.isRegularFile(temp.root.toPath().resolve("encly_flags_v3.properties"))) + } + + @Test + fun `a new install starts at onboarding and the first start writes only into the data directory`() { + val graph = graphIn(temp.root.toPath()) + assertEquals(InitialStatus.ONBOARDING, runBlocking { graph.securityManager.resolveInitialStatus() }) + assertFalse(graph.securityManager.isDatabaseUnlocked()) + } + + @Test + fun `the handoff sweep runs on an empty cache`() { + val graph = graphIn(temp.root.toPath()) + graph.handoffStagingSweeper.sweep() + } + + @Test + fun `the lock manager follows the window lifecycle`() { + val timer = ManualTimer() + val graph = graphIn(temp.root.toPath(), timer) + graph.lifecycle.lifecycle.addObserver(graph.sessionLockManager) + // Leaving with no vault open locks nothing and leaves nothing scheduled. + graph.lifecycle.windowDeactivated() + graph.lifecycle.windowActivated() + assertFalse(graph.sessionLockManager.locked.value) + assertTrue(timer.pending.isEmpty()) + } +} + +@OptIn(ExperimentalTestApi::class) +class DesktopAppUiTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `a new install shows the onboarding screen in the window`() = runComposeUiTest { + val capture = RecordingCapture() + val graph = graphIn(temp.root.toPath(), capture = capture) + setContent { DesktopApp(graph, AppWindow.None) } + val headline = runBlocking { + org.jetbrains.compose.resources.getString(Res.string.onboarding_welcome_headline) + } + waitUntil(timeoutMillis = 30_000) { onAllNodesWithText(headline).fetchSemanticsNodes().isNotEmpty() } + } + + @Test + fun `a request for a screen is ignored while no vault is open`() = runComposeUiTest { + val graph = graphIn(temp.root.toPath()) + val services = WindowServices.create(graph.environment, graph.lifecycle) + setContent { DesktopApp(graph, AppWindow.None, services = services) } + services.navigation.request("SettingsRoute") + val headline = runBlocking { + org.jetbrains.compose.resources.getString(Res.string.onboarding_welcome_headline) + } + waitUntil(timeoutMillis = 30_000) { onAllNodesWithText(headline).fetchSemanticsNodes().isNotEmpty() } + waitForIdle() + onNodeWithText(headline).assertExists() + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt new file mode 100644 index 00000000..7cb1569f --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt @@ -0,0 +1,49 @@ +package com.pasich.encly.desktop + +import com.pasich.encly.desktop.platform.DesktopLicenses +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.test.Test +import kotlin.test.assertTrue + +/** The desktop-only libraries are listed in the app and their texts are in the installers. */ +class LicensesTest { + private val folder: Path = Paths.get("packaging", "resources", "common", "licenses") + + @Test + fun `every desktop only library is on the licences screen`() { + val names = DesktopLicenses.notices.map { it.name } + for (library in listOf("PDFBox", "ICU4J", "Noto Sans Arabic", "Commons Logging", "JNA", "dbus-java", "SLF4J")) { + assertTrue(names.any { library in it }, "$library is not listed: $names") + } + assertTrue(DesktopLicenses.notices.all { it.license.isNotBlank() && it.url.startsWith("https://") }) + } + + @Test + fun `the licence texts and notices of those libraries ship with the installers`() { + val expected = listOf( + "THIRD-PARTY-NOTICES.txt", + "pdfbox-LICENSE.txt", + "pdfbox-NOTICE.txt", + "icu4j-LICENSE.txt", + "NotoSansArabic-OFL.txt", + "commons-logging-LICENSE.txt", + "commons-logging-NOTICE.txt", + "jna-LICENSE.txt", + "jna-AL2.0.txt", + "jna-LGPL2.1.txt", + "dbus-java-LICENSE.txt", + "slf4j-LICENSE.txt", + ) + for (name in expected) { + val file = folder.resolve(name) + assertTrue(Files.isRegularFile(file) && Files.size(file) > 0, "$file is missing or empty") + } + // The index names every file, so a text cannot be added without being mentioned. + val index = Files.readString(folder.resolve("THIRD-PARTY-NOTICES.txt")) + for (name in expected.drop(1)) { + assertTrue(name in index, "$name is not in THIRD-PARTY-NOTICES.txt") + } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/MenuKeyboardOwnershipTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/MenuKeyboardOwnershipTest.kt new file mode 100644 index 00000000..5634cbfd --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/MenuKeyboardOwnershipTest.kt @@ -0,0 +1,182 @@ +package com.pasich.encly.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.InternalComposeUiApi +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.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.desktop.window.Accelerator +import com.pasich.encly.desktop.window.DesktopShortcuts +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.designsystem.EnclyShortcutHost +import com.pasich.encly.presentation.designsystem.EnclyShortcuts +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The menu bar and the keyboard host share one key map and never both take a chord: a chord is the menu's + * (its accelerator is registered, the host declines the command) or the host's (the menu registers nothing for it). + * Both ways round are checked for every platform, so a new menu item or a new command cannot make a chord run twice. + */ +@OptIn(ExperimentalTestApi::class) +class MenuKeyboardOwnershipTest { + // The only way to build a key event without a window; the API is for compose-ui itself, a test may use it. + @OptIn(InternalComposeUiApi::class) + private fun Accelerator.asEvent(): KeyEvent = KeyEvent( + key = key, + type = KeyEventType.KeyDown, + isCtrlPressed = ctrl, + isMetaPressed = meta, + isAltPressed = alt, + isShiftPressed = shift, + ) + + /** Every accelerator the menu bar registers on [os]. */ + private fun menuChords(os: DesktopOs): List { + val keys = DesktopShortcuts.map(os) + return DesktopShortcuts.menuOwned.map { DesktopShortcuts.of(keys, it) } + DesktopShortcuts.menuOnly(os) + } + + /** The chords the keyboard host runs: every chord of the key map that the host does not decline. */ + private fun hostChords(os: DesktopOs): List { + val meta = DesktopShortcuts.map(os).usesMetaKey + val command = { key: Key, shift: Boolean -> Accelerator(key, ctrl = !meta, meta = meta, shift = shift) } + return listOf( + command(Key.F, false), + command(Key.W, false), + command(Key.Backspace, true), + command(Key.PageDown, false), + command(Key.PageUp, false), + command(Key.RightBracket, true), + command(Key.LeftBracket, true), + Accelerator(Key.Tab, ctrl = true), + Accelerator(Key.Tab, ctrl = true, shift = true), + Accelerator(Key.Escape), + ) + } + + @Test + fun noChordTheMenuRegistersIsOneTheHostRuns() { + for (os in DesktopOs.entries) { + val keys = DesktopShortcuts.map(os) + for (chord in menuChords(os)) { + val command = keys.commandFor(chord.asEvent()) + assertTrue( + command == null || command in DesktopShortcuts.menuOwned, + "$os $chord is registered by the menu and would also run $command on the host", + ) + } + } + } + + @Test + fun noChordTheHostRunsIsOneTheMenuRegisters() { + for (os in DesktopOs.entries) { + val keys = DesktopShortcuts.map(os) + val registered = menuChords(os) + for (chord in hostChords(os)) { + val command = keys.commandFor(chord.asEvent()) + assertTrue(command != null && command !in DesktopShortcuts.menuOwned, "$os $chord is not the host's") + assertFalse(chord in registered, "$os $chord is also registered by the menu") + } + } + } + + @Test + fun everyCommandIsOwnedByExactlyOneSide() { + val keys = DesktopShortcuts.map(DesktopOs.LINUX) + for (command in AppCommand.entries) { + val menu = command in DesktopShortcuts.menuOwned + // A menu-owned command has an accelerator the menu can build from the map's own key. + if (menu) assertTrue(keys.keyOf(command) != null, "$command has no key for the menu") + } + assertEquals(setOf(AppCommand.NewNote, AppCommand.LockApp), DesktopShortcuts.menuOwned) + } + + @Test + fun theMenuAcceleratorsAreTheKeyMapsOwn() { + for (os in DesktopOs.entries) { + val keys = DesktopShortcuts.map(os) + DesktopShortcuts.menuOwned.forEach { command -> + val accelerator = DesktopShortcuts.of(keys, command) + assertEquals(keys.keyOf(command), accelerator.key) + assertEquals(keys.usesMetaKey, accelerator.meta) + assertEquals(!keys.usesMetaKey, accelerator.ctrl) + assertFalse(accelerator.shift || accelerator.alt) + // The label the user reads names the same chord. + assertTrue(keys.label(command).endsWith(accelerator.key.letter()), keys.label(command)) + } + } + } + + @Test + fun theMenuOnlyChordsAreUnknownToTheKeyMap() { + for (os in DesktopOs.entries) { + val keys = DesktopShortcuts.map(os) + DesktopShortcuts.menuOnly(os).forEach { chord -> + assertNull(keys.commandFor(chord.asEvent()), "$os $chord") + } + } + } + + private fun Key.letter() = when (this) { + Key.N -> "N" + Key.L -> "L" + else -> error("not a menu key") + } + + @Test + fun onTheDesktopWindowTheMenuChordsReachTheMenuAndTheHostChordsAreUsedUp() = runComposeUiTest { + val seenAbove = mutableListOf() + val asked = mutableListOf() + setContent { + // What the window's native menu is: something outside the content that gets the keys the host leaves. + Box( + Modifier.onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) seenAbove.add(event.key) + false + }, + ) { + EnclyShortcutHost( + onCommand = { command -> + asked += command + command !in DesktopShortcuts.menuOwned + }, + shortcuts = EnclyShortcuts(commandKeyIsMeta = false), + ) { + Box(Modifier.size(10.dp)) + } + } + } + + onRoot().performKeyInput { + keyDown(Key.CtrlLeft) + pressKey(Key.N) + pressKey(Key.L) + pressKey(Key.W) + pressKey(Key.I) + keyUp(Key.CtrlLeft) + } + waitForIdle() + + assertEquals(listOf(AppCommand.NewNote, AppCommand.LockApp, AppCommand.CloseTab), asked) + // N and L were declined and reached the menu; W was the host's; I was never a command. + assertTrue(Key.N in seenAbove && Key.L in seenAbove && Key.I in seenAbove) + assertFalse(Key.W in seenAbove) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/MultiWindowTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/MultiWindowTest.kt new file mode 100644 index 00000000..5f8856d2 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/MultiWindowTest.kt @@ -0,0 +1,167 @@ +package com.pasich.encly.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.runComposeUiTest +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_subtitle +import com.pasich.encly.core.resources.notes_title +import com.pasich.encly.core.security.AutoLock +import com.pasich.encly.core.security.AutoLockDelay +import com.pasich.encly.core.security.VaultUnlockResult +import com.pasich.encly.desktop.di.DesktopAppGraph +import com.pasich.encly.desktop.window.DesktopWindows +import com.pasich.encly.desktop.window.WindowForeground +import com.pasich.encly.desktop.window.WindowServices +import com.pasich.encly.platform.AppWindow +import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private const val PIN = "123456" + +/** A graph over a real vault that onboarding has committed and left open, as after the first PIN. */ +private fun committedGraph(directory: java.nio.file.Path): DesktopAppGraph { + val graph = createGraphFactory().create(testEnvironment(directory)) + val security = graph.securityManager + check(security.initializeNewVault(null)) + check(security.configurePin(PIN.toCharArray())) + check(security.finishInitialSetup()) + graph.lifecycle.lifecycle.addObserver(graph.sessionLockManager) + return graph +} + +/** + * Two windows over one vault and one lock, as the host runs them: each its own [WindowServices] and `DesktopApp`, + * the session and the lock the graph's. + */ +@OptIn(ExperimentalTestApi::class) +class MultiWindowLockTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun lockingInOneWindowLocksBothAndUnlockingInOneOpensBothAndNoWindowShowsContentWhileLocked() = runComposeUiTest { + val graph = committedGraph(temp.root.toPath()) + val first = WindowServices.create(graph.environment, graph.lifecycle) + val second = WindowServices.create(graph.environment, graph.lifecycle) + val lockPrompt = runBlocking { getString(Res.string.lock_subtitle) } + val notes = runBlocking { getString(Res.string.notes_title) } + fun prompts() = onAllNodesWithText(lockPrompt).fetchSemanticsNodes().size + fun lists() = onAllNodesWithText(notes).fetchSemanticsNodes().size + + setContent { + Row { + Box(Modifier.weight(1f).fillMaxHeight()) { DesktopApp(graph, AppWindow.None, services = first) } + Box(Modifier.weight(1f).fillMaxHeight()) { DesktopApp(graph, AppWindow.None, services = second) } + } + } + + // Both windows open on the notes. + waitUntil(timeoutMillis = 60_000) { lists() >= 2 } + assertEquals(0, prompts()) + + // The lock (idle, the Lock command or the OS) is the process's: both windows go to the lock screen. + graph.sessionLockManager.lockNow() + waitUntil(timeoutMillis = 60_000) { prompts() == 2 } + assertEquals(0, lists(), "no window shows notes while the vault is locked") + assertFalse(graph.securityManager.isDatabaseUnlocked()) + + // One unlock (whichever lock screen it was typed on) opens the vault for both. + assertEquals(VaultUnlockResult.SUCCESS, graph.securityManager.unlockWithPin(PIN.toCharArray())) + assertTrue(graph.sessionLockManager.onUnlocked()) + waitUntil(timeoutMillis = 60_000) { prompts() == 0 } + waitUntil(timeoutMillis = 60_000) { lists() >= 2 } + assertTrue(graph.securityManager.isDatabaseUnlocked()) + } +} + +class MultiWindowForegroundTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun movingTheFocusBetweenWindowsKeepsTheVaultOpenButLeavingTheAppLocksIt() { + val graph = committedGraph(temp.root.toPath()) + (graph.autoLockPolicy as AutoLock).setDelay(AutoLockDelay.IMMEDIATELY) + val foreground = WindowForeground(graph.lifecycle) + foreground.opened(1) + foreground.activated(1) + foreground.opened(2) + + // The OS reports window 1 losing the focus before window 2 gets it. + foreground.deactivated(1, toOwnWindow = true) + assertFalse(graph.sessionLockManager.locked.value, "the app was not left") + foreground.activated(2) + foreground.deactivated(2, toOwnWindow = true) + foreground.activated(1) + assertFalse(graph.sessionLockManager.locked.value) + assertTrue(graph.securityManager.isDatabaseUnlocked()) + + // The user goes to another application: with "immediately" the vault locks. + foreground.deactivated(1, toOwnWindow = false) + assertTrue(graph.sessionLockManager.locked.value) + assertFalse(graph.securityManager.isDatabaseUnlocked()) + } + + @Test + fun withoutTheHandoverTheSameFocusChangeWouldLockTheVault() { + // What a single lifecycle fed by every window's events would do: the reason for the aggregation. + val graph = committedGraph(temp.root.toPath()) + (graph.autoLockPolicy as AutoLock).setDelay(AutoLockDelay.IMMEDIATELY) + + graph.lifecycle.windowDeactivated() + + assertTrue(graph.sessionLockManager.locked.value) + } + + @Test + fun closingAWindowDoesNotLockTheVaultUnderTheOthers() { + val graph = committedGraph(temp.root.toPath()) + (graph.autoLockPolicy as AutoLock).setDelay(AutoLockDelay.IMMEDIATELY) + val foreground = WindowForeground(graph.lifecycle) + foreground.opened(1) + foreground.activated(1) + foreground.opened(2) + + foreground.closed(1) + + assertFalse(graph.sessionLockManager.locked.value) + assertTrue(graph.securityManager.isDatabaseUnlocked()) + } +} + +class NewWindowAvailabilityTest { + @get:Rule + val temp = TemporaryFolder() + + private fun windowsOver(graph: DesktopAppGraph) = DesktopWindows( + canOpen = graph.securityManager::isLockable, + onLastClosed = {}, + create = { _, _ -> WindowServices.create(graph.environment, graph.lifecycle) }, + ) + + @Test + fun aFurtherWindowIsRefusedDuringOnboardingAndAllowedOnceTheVaultExists() { + val fresh = createGraphFactory().create(testEnvironment(temp.newFolder().toPath())) + val onboarding = windowsOver(fresh) + assertTrue(onboarding.open() != null, "the first window is the app itself") + assertNull(onboarding.open(), "no vault yet: onboarding is one window") + + val committed = windowsOver(committedGraph(temp.newFolder().toPath())) + assertTrue(committed.open() != null) + assertTrue(committed.open() != null) + assertEquals(2, committed.sessions.size) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NativeOsTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NativeOsTest.kt new file mode 100644 index 00000000..2baef7b4 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NativeOsTest.kt @@ -0,0 +1,44 @@ +package com.pasich.encly.desktop + +import com.pasich.encly.desktop.nativeos.NativeBootClock +import com.pasich.encly.desktop.nativeos.NativeCaptureExclusion +import com.pasich.encly.desktop.nativeos.NativeScreenLock +import com.pasich.encly.desktop.storage.DesktopOs +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The native readers against the OS this test runs on (each OS runs its own in CI). They are JNA and D-Bus glue, + * excluded from the coverage gate, but they must at least load and answer sensibly. + */ +class NativeOsTest { + private val os = DesktopOs.current() + + @Test + fun `the boot clock reads the uptime of this machine`() { + val clock = NativeBootClock.forOs(os) + val first = assertNotNull(clock.uptimeMillis(), "uptime of $os") + assertTrue(first > 0) + Thread.sleep(20) + assertTrue(clock.uptimeMillis()!! >= first) + if (os != DesktopOs.WINDOWS) assertNotNull(clock.bootId(), "boot id of $os") + } + + @Test + fun `the screen lock source starts and stops without a locked session`() { + val states = mutableListOf() + val subscription = NativeScreenLock.forOs(os).start { states += it } + Thread.sleep(300) + subscription.close() + // A CI machine or a developer at the keyboard has an unlocked session. + assertFalse(states.any { it }, "reported locked: $states") + } + + @Test + fun `the capture exclusion is available where the OS has one`() { + val capture = NativeCaptureExclusion.forOs(os) + if (os == DesktopOs.LINUX) assertFalse(capture.supported) else assertTrue(capture.supported) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NavigationCarryTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NavigationCarryTest.kt new file mode 100644 index 00000000..4aa3f5a0 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NavigationCarryTest.kt @@ -0,0 +1,103 @@ +package com.pasich.encly.desktop + +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.savedstate.SavedState +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertSame + +/** + * What the desktop does on a language change (see `DesktopApp`): the app is rebuilt under a new key, and the back stack + * of the controller it replaces is handed to the new one before its graph is set. The screen stays where it was and + * the entries keep their ViewModels (their stores belong to the window and are found by the entry's id), instead of + * the new controller starting at the start destination and the old entries' ViewModels staying behind. + */ +@OptIn(ExperimentalTestApi::class) +class NavigationCarryTest { + private class ScreenViewModel : ViewModel() + + private class Rig { + val owner = object : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + var revision by mutableIntStateOf(0) + var controller: NavHostController? = null + var screenViewModel: ScreenViewModel? = null + } + + private fun androidx.compose.ui.test.ComposeUiTest.setUpRig(rig: Rig, carry: Boolean) { + setContent { + CompositionLocalProvider(LocalViewModelStoreOwner provides rig.owner) { + val revision = rig.revision + val carried: SavedState? = remember(revision) { if (carry) rig.controller?.saveState() else null } + key(revision) { + val navController = rememberNavController() + remember(navController) { + if (carried != null) navController.restoreState(carried) + Unit + } + SideEffect { rig.controller = navController } + NavHost(navController, startDestination = "start") { + composable("start") { Text("start screen") } + composable("note") { + rig.screenViewModel = + viewModel(factory = viewModelFactory { initializer { ScreenViewModel() } }) + Text("note screen") + } + } + } + } + } + } + + @Test + fun `the screen and its ViewModel survive a rebuild that carries the back stack`() = runComposeUiTest { + val rig = Rig() + setUpRig(rig, carry = true) + runOnIdle { rig.controller!!.navigate("note") } + waitForIdle() + onNodeWithText("note screen").assertExists() + val before = assertNotNull(rig.screenViewModel) + + rig.revision = 1 + waitForIdle() + + onNodeWithText("note screen").assertExists() + assertSame(before, rig.screenViewModel) + } + + @Test + fun `without carrying the back stack the rebuilt app starts over`() = runComposeUiTest { + val rig = Rig() + setUpRig(rig, carry = false) + runOnIdle { rig.controller!!.navigate("note") } + waitForIdle() + onNodeWithText("note screen").assertExists() + + rig.revision = 1 + waitForIdle() + + onNodeWithText("start screen").assertExists() + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt new file mode 100644 index 00000000..d8a72d49 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt @@ -0,0 +1,376 @@ +package com.pasich.encly.desktop + +import com.ibm.icu.text.ArabicShaping +import com.pasich.encly.data.export.DocumentBlock +import com.pasich.encly.data.export.DocumentItem +import com.pasich.encly.data.export.ListKind +import com.pasich.encly.data.export.NoteDocument +import com.pasich.encly.desktop.documents.DesktopNoteDocuments +import com.pasich.encly.desktop.documents.PrintLauncher +import com.pasich.encly.platform.PrintResult +import kotlinx.coroutines.runBlocking +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import org.apache.fontbox.ttf.TTFParser +import org.apache.pdfbox.Loader +import org.apache.pdfbox.io.RandomAccessReadBuffer +import org.apache.pdfbox.pdmodel.common.PDRectangle +import org.apache.pdfbox.text.PDFTextStripper +import org.apache.pdfbox.text.TextPosition +import org.junit.Test +import java.awt.print.Pageable +import java.io.File +import java.io.IOException +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NoteDocumentsPdfTest { + private val offline = PrintLauncher { _, _ -> error("nothing prints here") } + private val documents = DesktopNoteDocuments(offline) + + private fun pdfOf(note: NoteDocument, renderer: DesktopNoteDocuments = documents): ByteArray { + val out = Buffer() + renderer.writePdf(note, out) + return out.readByteArray() + } + + private fun textOf(pdf: ByteArray): String = Loader.loadPDF(pdf).use { PDFTextStripper().getText(it) } + + /** Every glyph of the first page as the PDF holds it: the character it maps back to and where it is. */ + private fun glyphsOf(pdf: ByteArray): List = Loader.loadPDF(pdf).use { doc -> + val found = mutableListOf() + object : PDFTextStripper() { + override fun processTextPosition(text: TextPosition) { + found += text + super.processTextPosition(text) + } + }.getText(doc) + found + } + + private fun pageWidth(pdf: ByteArray): Float = Loader.loadPDF(pdf).use { it.getPage(0).mediaBox.width } + + private fun pagesOf(pdf: ByteArray): Int = Loader.loadPDF(pdf).use { it.numberOfPages } + + private fun note(vararg blocks: DocumentBlock, title: String = "Title", tags: List = emptyList()) = + NoteDocument(title, tags, blocks.toList()) + + @Test + fun theFileIsAPdfWithTheTitleTagsAndText() { + val paragraph = DocumentBlock.Paragraph("Buy bread and milk") + val pdf = pdfOf(note(paragraph, title = "Groceries", tags = listOf("Home", "Weekly"))) + + assertTrue(String(pdf, 0, 5, Charsets.ISO_8859_1) == "%PDF-") + val text = textOf(pdf) + assertTrue("Groceries" in text, text) + assertTrue("Home, Weekly" in text, text) + assertTrue("Buy bread and milk" in text, text) + } + + @Test + fun theTextOfEveryAppLanguageSurvivesTheRoundTrip() { + val lines = listOf( + "Привіт, світе! Їжак, єнот, ґанок і Ґудзик", + "Zażółć gęślą jaźń. Łódź, Gdańsk, święto", + "Größe, Straße, Übung, Äpfel, Öl", + "Año nuevo, ¿qué tal, ¡hola!, pingüino", + "À l'école, être, garçon, cœur, naïve", + "Perché, città, più, è già così", + "Dankjewel, ijs, café, coördinatie", + "Coração, informação, não, vovô", + ) + val pdf = pdfOf(note(*lines.map(DocumentBlock::Paragraph).toTypedArray())) + + val text = textOf(pdf) + lines.forEach { line -> assertTrue(line in text, "missing: $line in\n$text") } + assertFalse('?' in text, "a letter of the app languages was replaced") + } + + @Test + fun theFontCoversEveryLetterOfEveryAppString() { + val font = TTFParser().parse(RandomAccessReadBuffer(DesktopNoteDocuments.bundledFont())) + val arabic = TTFParser().parse(RandomAccessReadBuffer(DesktopNoteDocuments.bundledArabicFont())) + val lookup = font.unicodeCmapLookup + val arabicLookup = arabic.unicodeCmapLookup + val strings = File("../core/resources/src/commonMain/strings") + val files = strings.listFiles { f -> f.isDirectory }.orEmpty().map { File(it, "strings.xml") } + assertTrue(files.size >= 10, "the ten locales: ${files.size}") + val missing = sortedSetOf() + files.forEach { file -> + // Arabic is drawn in its joined forms, which the Arabic font must have as well. + val text = ArabicShaping(ArabicShaping.LETTERS_SHAPE or ArabicShaping.LENGTH_GROW_SHRINK) + .shape(file.readText()) + text.codePoints().filter { Character.isLetter(it) }.forEach { + if (lookup.getGlyphId(it) <= 0 && arabicLookup.getGlyphId(it) <= 0) { + missing += String(Character.toChars(it)) + } + } + } + font.close() + arabic.close() + assertEquals(emptySet(), missing, "letters of the app's strings the PDF font cannot draw") + } + + @Test + fun arabicIsJoinedAndReadsBackAsTheLogicalText() { + // Not tested here: a yeh that joins on its left reads back as the Persian yeh, which has the same glyph. + val lines = + listOf("مرحبا بالعالم", "الملف report.pdf مهم", "السعر 250 دولار و 12 كتابا", "لا أحد هنا، ألا تعلم؟") + val pdf = pdfOf(note(*lines.map(DocumentBlock::Paragraph).toTypedArray())) + + val text = textOf(pdf) + lines.forEach { line -> assertTrue(line in text, "missing: $line in\n$text") } + assertFalse('?' in text, text) + // The lam-alef is drawn as the one ligature glyph, not as two letters. + // (the stripper's own text is normalised back to letters, so the font's mapping of each glyph is read) + val drawn = glyphsOf(pdf).joinToString("") { glyph -> + glyph.characterCodes.joinToString("") { glyph.font.toUnicode(it).orEmpty() } + } + assertTrue('\uFEFB' in drawn || '\uFEFC' in drawn, "no lam-alef ligature in the glyphs") + } + + @Test + fun anArabicParagraphIsRightAlignedAndALatinOneIsNot() { + val pdf = pdfOf(note(DocumentBlock.Paragraph("مرحبا"), DocumentBlock.Paragraph("Hello"), title = "T")) + val right = pageWidth(pdf) - 54f + val glyphs = glyphsOf(pdf) + val arabic = glyphs.filter { it.unicode.single() in '\uFE70'..'\uFEFF' } + val latin = glyphs.filter { it.unicode.single() in "Hello" } + + assertEquals(right, arabic.maxOf { it.xDirAdj + it.widthDirAdj }, 0.5f) + assertTrue(arabic.minOf { it.xDirAdj } > 54f + 20f, "the Arabic word starts at the left margin") + assertEquals(54f, latin.minOf { it.xDirAdj }, 0.5f) + } + + @Test + fun theDirectionOfAParagraphIsItsFirstStrongCharacter() { + val pdf = pdfOf(note(DocumentBlock.Paragraph("123 مرحبا"), DocumentBlock.Paragraph("123 abc"), title = "T")) + val glyphs = glyphsOf(pdf) + val digitsOfArabic = glyphs.filter { it.unicode == "1" } + + // The line begins on the right with its numbers, and a Latin line begins on the left. + val first = digitsOfArabic.first() + val second = digitsOfArabic.last() + assertTrue(first.xDirAdj > 54f + 20f, "an Arabic paragraph begins on the right") + assertEquals(54f, second.xDirAdj, 0.5f) + assertTrue("123" in textOf(pdf)) + } + + @Test + fun aRightToLeftListHasItsMarkersOnTheRight() { + val pdf = pdfOf( + note( + DocumentBlock.Items(ListKind.NUMBERED, listOf(DocumentItem("مرحبا"), DocumentItem("item"))), + title = "T", + ), + ) + val glyphs = glyphsOf(pdf) + val right = pageWidth(pdf) - 54f + val numbers = glyphs.filter { it.unicode == "1" || it.unicode == "2" } + val arabic = glyphs.filter { it.unicode.single() in '\uFE70'..'\uFEFF' } + + val one = numbers.first { it.unicode == "1" } + val two = numbers.first { it.unicode == "2" } + assertTrue(one.xDirAdj > arabic.maxOf { it.xDirAdj }, "the number is on the right of its Arabic text") + assertTrue(one.xDirAdj + one.widthDirAdj <= right + 0.5f) + assertTrue(two.xDirAdj < 54f + 20f, "a Latin item keeps its number on the left") + } + + @Test + fun hebrewReadsRightToLeftWithoutAnError() { + val pdf = pdfOf(note(DocumentBlock.Paragraph("שלום עולם"), DocumentBlock.Quote("שלום"), title = "T")) + + val right = pageWidth(pdf) - 54f + assertEquals(right, glyphsOf(pdf).filter { it.unicode == "?" }.maxOf { it.xDirAdj + it.widthDirAdj }, 0.5f) + } + + @Test + fun anArabicParagraphBreaksIntoLinesOnItsLogicalWords() { + val words = (1..80).joinToString(" ") { "كلمة$it" } + val pdf = pdfOf(note(DocumentBlock.Paragraph(words), title = "T")) + + val lines = textOf(pdf).lines().filter { it.isNotBlank() } + assertTrue(lines.size > 3) + assertEquals(words, lines.drop(1).joinToString(" ") { it.trim() }) + } + + @Test + fun whatTheFontCannotDrawBecomesAQuestionMarkInsteadOfAnError() { + val pdf = pdfOf(note(DocumentBlock.Paragraph("Party 🎉 time 中 tab\there"))) + + val text = textOf(pdf) + assertTrue("Party ? time ? tab" in text.replace(Regex("\\s+"), " "), text) + } + + @Test + fun everyKindOfBlockIsDrawn() { + val pdf = pdfOf( + note( + DocumentBlock.Heading(1, "Heading one"), + DocumentBlock.Heading(4, "Heading four"), + DocumentBlock.Paragraph("A paragraph\nwith two lines"), + DocumentBlock.Quote("A quotation"), + DocumentBlock.Link("Site", "https://example.org/path"), + DocumentBlock.Link("", "mailto:me@example.org"), + DocumentBlock.Separator, + DocumentBlock.Items(ListKind.BULLET, listOf(DocumentItem("bullet one"), DocumentItem("bullet two"))), + DocumentBlock.Items(ListKind.NUMBERED, listOf(DocumentItem("first"), DocumentItem("second"))), + DocumentBlock.Items( + ListKind.CHECKLIST, + listOf(DocumentItem("done", checked = true), DocumentItem("todo")), + ), + ), + ) + + val text = textOf(pdf) + listOf( + "Heading one", "Heading four", "A paragraph", "with two lines", "A quotation", "Site", + "https://example.org/path", "mailto:me@example.org", "bullet one", "bullet two", "1.", "first", + "2.", "second", "done", "todo", + ).forEach { assertTrue(it in text, "missing: $it in\n$text") } + // A link is text only: the file holds no link annotation that opens by itself. + Loader.loadPDF(pdf).use { doc -> + assertTrue(doc.pages.all { page -> page.annotations.isEmpty() }) + } + } + + @Test + fun aLongNoteFlowsOntoMorePagesAndALongWordIsBroken() { + val paragraphs = (1..120).map { DocumentBlock.Paragraph("Line number $it of a long note, with words to wrap.") } + val unbroken = DocumentBlock.Paragraph("x".repeat(400)) + val pdf = pdfOf(note(*(paragraphs + unbroken).toTypedArray())) + + assertTrue(pagesOf(pdf) >= 3) + val text = textOf(pdf) + assertTrue("Line number 120" in text) + assertEquals(400, text.count { it == 'x' }) + } + + @Test + fun anEmptyNoteIsStillAPage() { + val pdf = pdfOf(note(title = "")) + + assertEquals(1, pagesOf(pdf)) + } + + @Test + fun theHeadingIsNeverTheLastLineOfAPage() { + val filler = (1..42).map { DocumentBlock.Paragraph("filler $it") } + val blocks = filler + DocumentBlock.Heading(2, "Kept with its text") + DocumentBlock.Paragraph("body") + val pdf = pdfOf(note(*blocks.toTypedArray())) + + Loader.loadPDF(pdf).use { doc -> + val last = PDFTextStripper().apply { + startPage = doc.numberOfPages + endPage = doc.numberOfPages + } + .getText(doc) + if (doc.numberOfPages > 1 && "Kept with its text" in last) { + assertTrue("body" in last, "the heading was left alone at the end of a page") + } + } + } + + @Test + fun paperIsLetterWhereLetterIsUsedAndA4Elsewhere() { + val before = Locale.getDefault() + try { + Locale.setDefault(Locale.US) + assertEquals(PDRectangle.LETTER, DesktopNoteDocuments.paperOf()) + Locale.setDefault(Locale.GERMANY) + assertEquals(PDRectangle.A4, DesktopNoteDocuments.paperOf()) + } finally { + Locale.setDefault(before) + } + val letter = DesktopNoteDocuments(offline, paper = { PDRectangle.LETTER }) + Loader.loadPDF(pdfOf(note(DocumentBlock.Paragraph("x")), letter)).use { + assertEquals(PDRectangle.LETTER.width, it.getPage(0).mediaBox.width) + } + } + + @Test + fun aMissingFontIsAnErrorAndNeverAFallbackToSystemFonts() { + val broken = DesktopNoteDocuments(offline, fontBytes = { throw IOException("gone") }) + + assertFailsWith { pdfOf(note(DocumentBlock.Paragraph("x")), broken) } + assertFailsWith { runBlocking { broken.print(note()) } } + } + + @Test + fun theWholeFileIsBuiltBeforeAnythingIsWritten() { + val broken = DesktopNoteDocuments(offline, fontBytes = { throw IOException("gone") }) + val out = Buffer() + + assertFailsWith { broken.writePdf(note(DocumentBlock.Paragraph("x")), out) } + + assertEquals(0L, out.size) + } + + @Test + fun makingAPdfWritesNoFileAnywhere() { + val temp = File(System.getProperty("java.io.tmpdir")) + val cache = File(System.getProperty("user.home"), ".pdfbox.cache") + val tempBefore = temp.list().orEmpty().toSet() + val cacheBefore = cache.exists() to cache.lastModified() + + pdfOf(note(DocumentBlock.Paragraph("Привіт"), DocumentBlock.Heading(1, "Zażółć"))) + val printing = DesktopNoteDocuments(PrintLauncher { _, _ -> PrintResult.PRINTED }) + runBlocking { printing.print(note(DocumentBlock.Paragraph("x"))) } + + val created = temp.list().orEmpty().toSet() - tempBefore + assertTrue(created.none { it.contains("pdf", ignoreCase = true) }, "temp files: $created") + assertEquals(cacheBefore, cache.exists() to cache.lastModified(), "the font cache of PDFBox was touched") + } + + @Test + fun printingHandsTheRenderedPagesToThePrinter() = runBlocking { + var pages = 0 + val printer = PrintLauncher { pageable: Pageable, _ -> + pages = pageable.numberOfPages + PrintResult.PRINTED + } + + val result = DesktopNoteDocuments(printer).print(note(DocumentBlock.Paragraph("Hello"))) + + assertEquals(PrintResult.PRINTED, result) + assertEquals(1, pages) + } + + @Test + fun theGuardAgainstALockReachesThePrinter() = runBlocking { + var seen: Boolean? = null + val printer = PrintLauncher { _, mayProceed -> + seen = mayProceed() + PrintResult.CANCELLED + } + + DesktopNoteDocuments(printer).print(note(DocumentBlock.Paragraph("Hello"))) { false } + + assertEquals(false, seen) + } + + @Test + fun aClosedPrintDialogIsReportedAsCancelled() = runBlocking { + val closed = DesktopNoteDocuments(PrintLauncher { _, _ -> PrintResult.CANCELLED }) + val result = closed.print(note(DocumentBlock.Paragraph("Hello"))) + + assertEquals(PrintResult.CANCELLED, result) + } + + @Test + fun aPrinterThatFailsFailsThePrint() { + val printer = PrintLauncher { _, _ -> throw IOException("jam") } + + assertFailsWith { + runBlocking { DesktopNoteDocuments(printer).print(note(DocumentBlock.Paragraph("Hello"))) } + } + } + + @Test + fun theBundledFontIsPartOfTheApp() { + assertTrue(DesktopNoteDocuments.bundledFont().size > 100_000) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/PlatformServicesTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/PlatformServicesTest.kt new file mode 100644 index 00000000..bbec0f7b --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/PlatformServicesTest.kt @@ -0,0 +1,516 @@ +package com.pasich.encly.desktop + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.ui.graphics.Color +import com.pasich.encly.core.backup.BackupFormat +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_now +import com.pasich.encly.desktop.platform.AwtLinkOpener +import com.pasich.encly.desktop.platform.DesktopAppInfo +import com.pasich.encly.desktop.platform.DesktopAppLocaleController +import com.pasich.encly.desktop.platform.DesktopAppWindow +import com.pasich.encly.desktop.platform.DesktopDeviceCapabilities +import com.pasich.encly.desktop.platform.DesktopDocumentPickers +import com.pasich.encly.desktop.platform.DesktopDocuments +import com.pasich.encly.desktop.platform.DesktopExternalLinks +import com.pasich.encly.desktop.platform.DesktopToaster +import com.pasich.encly.desktop.platform.DropRouter +import com.pasich.encly.desktop.platform.DroppedKind +import com.pasich.encly.desktop.platform.FileFilters +import com.pasich.encly.desktop.platform.GatedFileDialogs +import com.pasich.encly.desktop.platform.toDocumentRef +import com.pasich.encly.desktop.security.DesktopAppLifecycle +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.NavigationRequests +import com.pasich.encly.platform.ToastDuration +import kotlinx.coroutines.Dispatchers +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.awt.Frame +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ExternalLinksTest { + private val opener = RecordingLinks() + private val links = DesktopExternalLinks(opener) + + @Test + fun `web and mail addresses are opened`() { + assertTrue(links.openUrl("https://example.org/privacy")) + assertTrue(links.openUrl("http://example.org", chooser = true)) + assertTrue(links.writeMail("dev@example.org")) + assertEquals( + listOf("https://example.org/privacy", "http://example.org", "mailto:dev@example.org"), + opener.opened.map(URI::toString), + ) + } + + @Test + fun `local files and unknown schemes never leave the app`() { + assertFalse(links.openUrl("file:///etc/passwd")) + assertFalse(links.openUrl("javascript:alert(1)")) + assertFalse(links.openUrl("ms-settings:privacy")) + assertFalse(links.openUrl("not a url at all")) + assertFalse(links.openUrl("/just/a/path")) + assertFalse(links.writeMail("bad address with spaces")) + assertTrue(opener.opened.isEmpty()) + } + + @Test + fun `a refused address is reported`() { + val refusing = DesktopExternalLinks(RecordingLinks(accepts = false)) + assertFalse(refusing.openUrl("https://example.org")) + assertFalse(refusing.writeMail("a@b.c")) + } + + @Test + fun `the AWT opener never throws on a headless machine`() { + // Headless CI has no browser: the answer is false (or true on a desktop), never an exception. + AwtLinkOpener.open(URI("https://example.org")).let { } + } +} + +class DocumentsTest { + @get:Rule + val temp = TemporaryFolder() + + private val documents = DesktopDocuments() + + @Test + fun `a document is written, read, named and deleted`() { + val ref = temp.root.toPath().resolve("note.enclybak").toDocumentRef() + documents.openWrite(ref)!!.buffered().use { it.write(byteArrayOf(1, 2, 3)) } + assertContentEquals(byteArrayOf(1, 2, 3), documents.openRead(ref)!!.buffered().use { it.readByteArray() }) + assertEquals("note.enclybak", documents.displayName(ref)) + documents.delete(ref) + assertFalse(Files.exists(Path.of(ref.value))) + documents.delete(ref) + } + + private fun assertContentEquals(expected: ByteArray, actual: ByteArray) = assertTrue(expected.contentEquals(actual)) + + @Test + fun `writing replaces the old content`() { + val ref = temp.newFile("a.md").toPath().also { Files.writeString(it, "a long old content") }.toDocumentRef() + documents.openWrite(ref)!!.buffered().use { it.write("new".encodeToByteArray()) } + assertEquals("new", Files.readString(Path.of(ref.value))) + } + + @Test + fun `a missing file is an IO error and a bad path has no stream`() { + val missing = temp.root.toPath().resolve("gone").toDocumentRef() + kotlin.test.assertFailsWith { documents.openRead(missing) } + val bad = DocumentRef("bad\u0000path") + assertNull(documents.openRead(bad)) + assertNull(documents.openWrite(bad)) + assertNull(documents.displayName(bad)) + documents.delete(bad) + kotlin.test.assertFailsWith { documents.openTree(bad, 10) } + } + + @Test + fun `a folder is walked without following symbolic links`() { + val root = temp.newFolder("notes").toPath() + Files.writeString(root.resolve("a.md"), "a") + Files.createDirectory(root.resolve("sub")) + Files.writeString(root.resolve("sub/b.md"), "b") + val outside = temp.newFile("secret.txt").toPath().also { Files.writeString(it, "secret") } + val linksSupported = runCatching { Files.createSymbolicLink(root.resolve("link.md"), outside) }.isSuccess + + val tree = documents.openTree(root.toDocumentRef(), maxEntries = 100) + val top = tree.children(tree.rootId) + assertEquals(setOf("a.md", "sub") + if (linksSupported) emptySet() else emptySet(), top.map { it.name }.toSet()) + val sub = top.single { it.name == "sub" } + assertTrue(sub.isDirectory) + assertEquals(listOf("b.md"), tree.children(sub.id).map { it.name }) + assertEquals( + "a", + tree.open( + top.single { + it.name == "a.md" + }.id, + )!!.buffered().use { it.readByteArray().decodeToString() }, + ) + } + + @Test + fun `the tree opens nothing outside its folder, no links and no directories`() { + val root = temp.newFolder("notes").toPath() + val inside = root.resolve("a.md").also { Files.writeString(it, "a") } + val outside = temp.newFile("secret.txt").toPath() + val tree = documents.openTree(root.toDocumentRef(), maxEntries = 100) + assertNull(tree.open(outside.toString())) + assertNull(tree.open(root.toString())) + assertNull(tree.open(root.resolve("missing.md").toString())) + assertNotNull(tree.open(inside.toString())) + runCatching { Files.createSymbolicLink(root.resolve("link.md"), outside) }.onSuccess { + assertNull(tree.open(root.resolve("link.md").toString())) + } + // Not a folder: no children. + assertTrue(tree.children(inside.toString()).isEmpty()) + } + + @Test + fun `listing stops at the entry limit`() { + val root = temp.newFolder("many").toPath() + repeat(10) { Files.writeString(root.resolve("$it.md"), "x") } + val tree = documents.openTree(root.toDocumentRef(), maxEntries = 3) + assertEquals(3, tree.children(tree.rootId).size) + } +} + +class FileFiltersAndKindsTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `mime types become extensions, and anything generic means no filter`() { + assertEquals(setOf("md", "markdown"), FileFilters.extensionsFor(arrayOf("text/markdown"))) + assertEquals( + setOf("zip", "md", "markdown"), + FileFilters.extensionsFor(arrayOf("application/zip", "text/markdown")), + ) + assertEquals(emptySet(), FileFilters.extensionsFor(arrayOf("*/*"))) + assertEquals(emptySet(), FileFilters.extensionsFor(arrayOf(BackupFormat.MIME_TYPE))) + assertEquals(emptySet(), FileFilters.extensionsFor(emptyArray())) + assertEquals(emptySet(), FileFilters.extensionsFor(arrayOf("text/markdown", "application/x-unknown"))) + } + + @Test + fun `dropped files are told apart by extension`() { + fun kind(name: String) = DroppedKind.of(temp.root.toPath().resolve(name)) + assertEquals(DroppedKind.BACKUP, kind("vault.enclybak")) + assertEquals(DroppedKind.BACKUP, kind("VAULT.ENCLYBAK")) + assertEquals(DroppedKind.NOTES, kind("a.md")) + assertEquals(DroppedKind.NOTES, kind("export.ZIP")) + assertEquals(DroppedKind.NOTES, kind("keep.json")) + assertNull(kind("photo.png")) + assertNull(kind("noextension")) + assertEquals(DroppedKind.FOLDER, DroppedKind.of(temp.newFolder("dir").toPath())) + } +} + +class DropRouterTest { + @get:Rule + val temp = TemporaryFolder() + + private val navigation = NavigationRequests() + private var now = 0L + private val router = DropRouter(navigation, post = { it() }, clock = { now }) + + private fun file(name: String): Path = temp.newFile(name).toPath() + + private fun requested(): List = drainRoutes(navigation) + + @Test + fun `a backup answers the screen that asks for one file`() { + var picked: DocumentRef? = null + router.registerOneFile { picked = it } + val backup = file("vault.enclybak") + assertTrue(router.drop(listOf(backup))) + assertEquals(backup.toDocumentRef(), picked) + assertTrue(requested().isEmpty()) + } + + @Test + fun `the newest screen gets the file and a closed screen none`() { + val calls = mutableListOf() + router.registerOneFile { calls += "old" } + val newest = router.registerOneFile { calls += "new" } + router.drop(listOf(file("a.enclybak"))) + assertEquals(listOf("new"), calls) + newest.close() + router.drop(listOf(file("b.enclybak"))) + assertEquals(listOf("new", "old"), calls) + } + + @Test + fun `notes answer the import screen and a folder its folder picker`() { + var many: List = emptyList() + var folder: DocumentRef? = null + router.registerManyFiles { many = it } + router.registerFolder { folder = it } + val a = file("a.md") + val b = file("b.zip") + router.drop(listOf(a, b, file("ignored.png"))) + assertEquals(listOf(a.toDocumentRef(), b.toDocumentRef()), many) + val dir = temp.newFolder("dir").toPath() + router.drop(listOf(dir)) + assertEquals(dir.toDocumentRef(), folder) + } + + @Test + fun `a backup wins over notes in one drop`() { + var one: DocumentRef? = null + var many: List = emptyList() + router.registerOneFile { one = it } + router.registerManyFiles { many = it } + val backup = file("x.enclybak") + router.drop(listOf(file("a.md"), backup)) + assertEquals(backup.toDocumentRef(), one) + assertTrue(many.isEmpty()) + } + + @Test + fun `files Encly cannot use are not routed`() { + assertFalse(router.drop(listOf(file("photo.png")))) + assertFalse(router.drop(emptyList())) + assertTrue(requested().isEmpty()) + } + + @Test + fun `with no screen waiting the matching screen is requested and gets the file when it appears`() { + val backup = file("vault.enclybak") + assertTrue(router.drop(listOf(backup))) + assertEquals(listOf("BackupRoute"), requested()) + var picked: DocumentRef? = null + router.registerOneFile { picked = it } + assertEquals(backup.toDocumentRef(), picked) + } + + @Test + fun `notes with no import screen open ask for it`() { + val note = file("a.md") + router.drop(listOf(note)) + assertEquals(listOf("ImportNotesRoute"), requested()) + var many: List = emptyList() + router.registerManyFiles { many = it } + assertEquals(listOf(note.toDocumentRef()), many) + } + + @Test + fun `a folder with no screen open asks for the import screen`() { + val dir = temp.newFolder("dir").toPath() + router.drop(listOf(dir)) + assertEquals(listOf("ImportNotesRoute"), requested()) + var folder: DocumentRef? = null + router.registerFolder { folder = it } + assertEquals(dir.toDocumentRef(), folder) + } + + @Test + fun `a waiting file goes stale, and goes to the right kind of screen only`() { + router.drop(listOf(file("a.enclybak"))) + now += DropRouter.PENDING_MILLIS + 1 + var picked: DocumentRef? = null + router.registerOneFile { picked = it } + assertNull(picked) + + router.drop(listOf(file("b.enclybak"))) + var many: List? = null + router.registerManyFiles { many = it } + assertNull(many) + router.registerOneFile { picked = it } + assertNotNull(picked) + } +} + +class PickersTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `gated dialogs mark the own dialog open while it is shown and closed after`() { + val lifecycle = DesktopAppLifecycle() + val inner = FakeFileDialogs() + val gated = GatedFileDialogs(inner, lifecycle) + val file = temp.newFile("a").toPath() + inner.saveAnswer = file + inner.openAnswer = listOf(file) + inner.folderAnswer = file + + var saved: Path? = null + var opened: List = emptyList() + var folder: Path? = null + gated.save("backup.enclybak") { saved = it } + gated.open(multiple = true, extensions = setOf("md")) { opened = it } + gated.folder { folder = it } + assertEquals(file, saved) + assertEquals(listOf(file), opened) + assertEquals(file, folder) + assertEquals(listOf("save:backup.enclybak", "open:true:md", "folder"), inner.calls) + + // While a dialog is open the window losing focus is not leaving the app. + val slow = object : com.pasich.encly.desktop.platform.FileDialogs { + override fun save(suggestedName: String, onResult: (Path?) -> Unit) { + lifecycle.windowDeactivated() + assertTrue(lifecycle.inForeground) + onResult(null) + } + + override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) = Unit + + override fun folder(onResult: (Path?) -> Unit) = Unit + } + GatedFileDialogs(slow, lifecycle).save("x") { } + assertTrue(lifecycle.inForeground) + } + + @Test + fun `a dialog that cannot be shown does not keep the app in front`() { + val lifecycle = DesktopAppLifecycle() + val broken = object : com.pasich.encly.desktop.platform.FileDialogs { + override fun save(suggestedName: String, onResult: (Path?) -> Unit) = error("no display") + + override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) = Unit + + override fun folder(onResult: (Path?) -> Unit) = Unit + } + + assertFailsWith { GatedFileDialogs(broken, lifecycle).save("x") { } } + lifecycle.windowDeactivated() + + // Without the give-back the dialog count would stay above zero and the app could never be "left". + assertFalse(lifecycle.inForeground) + } + + @Test + fun `a dialog that answers twice is ended once and cancelling reaches the delegate`() { + val lifecycle = DesktopAppLifecycle() + var cancelled = false + var stray: (Path?) -> Unit = { } + val twice = object : com.pasich.encly.desktop.platform.FileDialogs { + override fun save(suggestedName: String, onResult: (Path?) -> Unit) { + onResult(null) + stray = onResult + } + + override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) = Unit + + override fun folder(onResult: (Path?) -> Unit) = Unit + + override fun cancelAll() { + cancelled = true + } + } + val gated = GatedFileDialogs(twice, lifecycle) + + gated.save("x") { } + // A second dialog is open now; the stray second answer of the first must not have ended it too. + gated.open(multiple = false, extensions = emptySet()) { } + stray(null) + lifecycle.windowDeactivated() + assertTrue(lifecycle.inForeground) + + gated.cancelAll() + assertTrue(cancelled) + } + + @Test + fun `the AWT dialogs are built for each OS`() { + for (os in com.pasich.encly.desktop.storage.DesktopOs.entries) { + com.pasich.encly.desktop.platform.AwtFileDialogs(os) { null as Frame? } + } + } + + @Test + fun `the frame the dialogs belong to is remembered`() { + val parent = com.pasich.encly.desktop.platform.DialogParent() + assertNull(parent.frame) + } + + @Test + fun `the pickers class is created with its collaborators`() { + DesktopDocumentPickers(FakeFileDialogs(), DropRouter(NavigationRequests())) + } +} + +class HostServicesTest { + @Test + fun `the build version is read from the build resource`() { + val info = DesktopAppInfo.fromBuild() + assertEquals("Encly", info.appName) + assertTrue(Regex("""\d+\.\d+\.\d+""").matches(info.versionName), info.versionName) + assertTrue(info.donationsEnabled) + assertFalse(info.storeRatingEnabled) + assertTrue(info.storePageLinks.isEmpty()) + assertEquals("1.2.3", DesktopAppInfo("1.2.3").versionName) + } + + @Test + fun `a desktop has no dynamic colours`() { + assertFalse(DesktopDeviceCapabilities.supportsDynamicColors()) + } + + @Test + fun `the window excludes itself from capture and ignores the rest`() { + val capture = RecordingCapture() + var background: Color? = null + val frame = object : java.awt.Window(null as Frame?) {} + val window = DesktopAppWindow(capture, { frame }, { background = it }) + window.enforceSecure() + assertEquals(listOf(frame), capture.windows) + window.setBackgroundColor(0xFF112233.toInt()) + assertEquals(Color(0xFF112233.toInt()), background) + window.setLightStatusBar(true) + window.hideKeyboard() + window.showKeyboard() + assertFalse(window.isChangingConfigurations) + DesktopAppWindow(capture, { null }).enforceSecure() + assertEquals(1, capture.windows.size) + } + + @Test + fun `the toaster shows a message as a snackbar in the in-app language resources`() { + val toaster = DesktopToaster(Dispatchers.Unconfined) + toaster.show(Res.string.lock_now, ToastDuration.LONG) + val data = awaitSnackbar(toaster) + assertEquals("Lock now", data.visuals.message) + assertEquals(SnackbarDuration.Long, data.visuals.duration) + data.dismiss() + toaster.show(Res.string.lock_now, ToastDuration.SHORT) + val next = awaitSnackbar(toaster) + assertEquals(SnackbarDuration.Short, next.visuals.duration) + next.dismiss() + // The default dispatcher is the UI thread's. + DesktopToaster() + } + + private fun awaitSnackbar(toaster: DesktopToaster): androidx.compose.material3.SnackbarData { + val deadline = System.nanoTime() + 10_000_000_000L + while (System.nanoTime() < deadline) { + toaster.hostState.currentSnackbarData?.let { return it } + Thread.sleep(10) + } + error("no snackbar was shown") + } + + @Test + fun `the language is stored, applied to the process and announced`() { + val flags = MemoryKeyValueStore() + val system = Locale.getDefault() + val controller = DesktopAppLocaleController(flags, system) + try { + assertEquals(AppLanguage.SYSTEM, controller.current()) + controller.apply(AppLanguage.UKRAINIAN) + assertEquals("uk", Locale.getDefault().language) + assertEquals("uk", flags.getString("app_language", null)) + assertEquals(AppLanguage.UKRAINIAN, DesktopAppLocaleController(flags, system).current()) + DesktopAppLocaleController(flags, system).syncDefaultLocale() + controller.apply(AppLanguage.SYSTEM) + assertEquals(system, Locale.getDefault()) + assertFalse(flags.contains("app_language")) + assertEquals(2, controller.revision.value) + } finally { + Locale.setDefault(system) + } + } + + @Test + fun `a stored unknown language means the system language`() { + val flags = MemoryKeyValueStore().also { it.values["app_language"] = "xx" } + assertEquals(AppLanguage.SYSTEM, DesktopAppLocaleController(flags).current()) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt new file mode 100644 index 00000000..eedc2cdf --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt @@ -0,0 +1,640 @@ +package com.pasich.encly.desktop + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.pasich.encly.core.security.AutoLockPolicy +import com.pasich.encly.core.security.BiometricHost +import com.pasich.encly.core.security.BiometricManager +import com.pasich.encly.core.security.BiometricStatus +import com.pasich.encly.desktop.security.CoarseUptime +import com.pasich.encly.desktop.security.DesktopAppLifecycle +import com.pasich.encly.desktop.security.DesktopDeviceLockWatcher +import com.pasich.encly.desktop.security.IdleLock +import com.pasich.encly.desktop.security.LinuxLiveLock +import com.pasich.encly.desktop.security.NoBiometricManager +import com.pasich.encly.desktop.security.OsLockSignals +import com.pasich.encly.desktop.security.OsUptimeClock +import com.pasich.encly.desktop.security.PollingLockSignals +import com.pasich.encly.desktop.security.SleepDetector +import com.pasich.encly.desktop.security.SwingMainThreadTimer +import com.pasich.encly.desktop.security.parseProcUptime +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopAppLifecycleTest { + private fun events(lifecycle: DesktopAppLifecycle): MutableList { + val seen = mutableListOf() + lifecycle.lifecycle.addObserver(LifecycleEventObserver { _, event -> seen += event }) + seen.clear() + return seen + } + + @Test + fun `the app starts in front`() { + assertTrue(DesktopAppLifecycle().inForeground) + } + + @Test + fun `leaving the window stops the app and coming back starts it`() { + val lifecycle = DesktopAppLifecycle() + val seen = events(lifecycle) + lifecycle.windowDeactivated() + assertFalse(lifecycle.inForeground) + assertEquals(listOf(Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP), seen) + seen.clear() + lifecycle.windowActivated() + assertTrue(lifecycle.inForeground) + assertEquals(listOf(Lifecycle.Event.ON_START, Lifecycle.Event.ON_RESUME), seen) + } + + @Test + fun `minimising leaves even while the window is still active`() { + val lifecycle = DesktopAppLifecycle() + lifecycle.windowMinimised() + assertFalse(lifecycle.inForeground) + lifecycle.windowRestored() + assertTrue(lifecycle.inForeground) + } + + @Test + fun `an own dialog taking the focus is not leaving`() { + val lifecycle = DesktopAppLifecycle() + lifecycle.beginOwnDialog() + lifecycle.windowDeactivated() + assertTrue(lifecycle.inForeground) + // The dialog closes before the window is told it is active again: still in front. + lifecycle.endOwnDialog() + assertTrue(lifecycle.inForeground) + lifecycle.windowActivated() + assertTrue(lifecycle.inForeground) + } + + @Test + fun `a dialog end without a begin changes nothing`() { + val lifecycle = DesktopAppLifecycle() + lifecycle.endOwnDialog() + assertTrue(lifecycle.inForeground) + } + + @Test + fun `repeated events do not repeat the transition`() { + val lifecycle = DesktopAppLifecycle() + val seen = events(lifecycle) + lifecycle.windowDeactivated() + lifecycle.windowDeactivated() + lifecycle.windowMinimised() + assertEquals(2, seen.size) + } + + @Test + fun `destroying ends the lifecycle and later events are ignored`() { + val lifecycle = DesktopAppLifecycle() + val seen = events(lifecycle) + lifecycle.destroy() + assertEquals(listOf(Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY), seen) + lifecycle.windowActivated() + assertFalse(lifecycle.inForeground) + // Destroying a stopped app only ends it. + DesktopAppLifecycle().apply { + windowDeactivated() + destroy() + } + } +} + +class IdleLockTest { + private class Rig(delay: Long) { + val timer = ManualTimer() + var nowNanos = 0L + var locks = 0 + val idle = IdleLock( + timer = timer, + policy = object : AutoLockPolicy { + override val delayMillis: Long = delay + }, + lockNow = { locks++ }, + clock = { nowNanos }, + ) + + fun advance(millis: Long) { + nowNanos += millis * NANOS_PER_MILLI + } + + private companion object { + const val NANOS_PER_MILLI = 1_000_000L + } + } + + @Test + fun `the limit is never below the minimum, whatever the short auto-lock delay`() { + val rig = Rig(delay = 15_000) + rig.idle.start() + assertEquals(IdleLock.MIN_IDLE_MILLIS, rig.timer.pending.single().delayMs) + } + + @Test + fun `a long auto-lock delay is the limit`() { + val rig = Rig(delay = 30 * 60_000L) + rig.idle.start() + assertEquals(30 * 60_000L, rig.timer.pending.single().delayMs) + } + + @Test + fun `locks after the limit without input and keeps watching`() { + val rig = Rig(delay = 0) + rig.idle.start() + rig.advance(IdleLock.MIN_IDLE_MILLIS) + rig.timer.fireAll() + assertEquals(1, rig.locks) + assertEquals(IdleLock.MIN_IDLE_MILLIS, rig.timer.pending.single().delayMs) + } + + @Test + fun `input moves the deadline`() { + val rig = Rig(delay = 0) + rig.idle.start() + rig.advance(IdleLock.MIN_IDLE_MILLIS - 1_000) + rig.idle.touch() + rig.advance(2_000) + rig.timer.fireAll() + assertEquals(0, rig.locks) + // Re-armed for exactly the time that is left. + assertEquals(IdleLock.MIN_IDLE_MILLIS - 2_000, rig.timer.pending.single().delayMs) + } + + @Test + fun `stop cancels the check`() { + val rig = Rig(delay = 0) + rig.idle.start() + rig.idle.stop() + assertTrue(rig.timer.pending.isEmpty()) + rig.idle.stop() + } +} + +class DesktopDeviceLockWatcherTest { + private class Signals : OsLockSignals { + var report: ((Boolean) -> Unit)? = null + var closed = false + + override fun start(onChange: (locked: Boolean) -> Unit): AutoCloseable { + report = onChange + return AutoCloseable { closed = true } + } + } + + private fun watcher(signals: OsLockSignals) = DesktopDeviceLockWatcher(signals) { it() } + + @Test + fun `the lock state follows the OS`() { + val signals = Signals() + val watcher = watcher(signals).also { it.start() } + assertFalse(watcher.isDeviceLocked()) + signals.report!!(true) + assertTrue(watcher.isDeviceLocked()) + signals.report!!(false) + assertFalse(watcher.isDeviceLocked()) + } + + @Test + fun `becoming locked tells the listeners once`() { + val signals = Signals() + val watcher = watcher(signals).also { it.start() } + var lock = 0 + var screenOff = 0 + watcher.onLocked { lock++ } + watcher.watchScreenOff { screenOff++ } + signals.report!!(true) + signals.report!!(true) + assertEquals(1, lock) + assertEquals(1, screenOff) + signals.report!!(false) + signals.report!!(true) + assertEquals(2, lock) + } + + @Test + fun `stopped listeners hear nothing`() { + val signals = Signals() + val watcher = watcher(signals).also { it.start() } + var heard = 0 + val stopLocked = watcher.onLocked { heard++ } + val stopOff = watcher.watchScreenOff { heard++ } + stopLocked() + stopOff() + signals.report!!(true) + assertEquals(0, heard) + } + + @Test + fun `start listens once and close lets go`() { + val signals = Signals() + val watcher = watcher(signals) + watcher.start() + val first = signals.report + watcher.start() + assertEquals(first, signals.report) + watcher.close() + assertTrue(signals.closed) + watcher.close() + } + + @Test + fun `an OS that can be asked is asked, not the last signal`() { + // A locker started by logind's Lock (swaylock, hyprlock) never sends Unlock: the latched signal stays true. + var live: Boolean? = true + val signals = object : OsLockSignals { + var report: ((Boolean) -> Unit)? = null + + override fun start(onChange: (locked: Boolean) -> Unit): AutoCloseable { + report = onChange + return AutoCloseable { } + } + + override fun liveLocked(): Boolean? = live + } + val watcher = watcher(signals).also { it.start() } + signals.report!!(true) + assertTrue(watcher.isDeviceLocked()) + + live = false + + assertFalse(watcher.isDeviceLocked(), "the desktop was unlocked; no Unlock signal came") + var locks = 0 + watcher.onLocked { locks++ } + signals.report!!(false) + signals.report!!(true) + assertEquals(1, locks, "a new lock still reaches the listeners") + } + + @Test + fun `an OS that only reports changes keeps the last report as the state`() { + val signals = Signals() + val watcher = watcher(signals).also { it.start() } + assertNull(signals.liveLocked()) + + signals.report!!(true) + + assertTrue(watcher.isDeviceLocked()) + } + + @Test + fun `an OS without lock signals never reports locked`() { + val watcher = watcher(OsLockSignals.None).also { it.start() } + assertFalse(watcher.isDeviceLocked()) + watcher.close() + } + + @Test + fun `callbacks are dispatched to the UI thread`() { + val signals = Signals() + val dispatched = mutableListOf<() -> Unit>() + val watcher = DesktopDeviceLockWatcher(signals) { dispatched += it }.also { it.start() } + var heard = 0 + watcher.onLocked { heard++ } + signals.report!!(true) + assertEquals(0, heard) + dispatched.forEach { it() } + assertEquals(1, heard) + } + + @Test + fun `polling reports changes of the answer`() { + val answers = ArrayDeque(listOf(false, true, true, false)) + val reported = mutableListOf() + val done = CountDownLatch(2) + val polling = PollingLockSignals( + intervalMillis = 5, + probe = { if (answers.size > 1) answers.removeFirst() else answers.first() }, + ) + val subscription = polling.start { + reported += it + done.countDown() + } + assertTrue(done.await(5, TimeUnit.SECONDS)) + subscription.close() + assertEquals(listOf(true, false), reported.take(2)) + } + + @Test + fun `a sleep without a lock is reported as a lock that is over at once`() { + val sleeps = ArrayDeque(listOf(false, true, false, false)) + val reported = mutableListOf() + val done = CountDownLatch(2) + val polling = PollingLockSignals( + intervalMillis = 5, + probe = { false }, + slept = { if (sleeps.size > 1) sleeps.removeFirst() else false }, + ) + val subscription = polling.start { + reported += it + done.countDown() + } + assertTrue(done.await(5, TimeUnit.SECONDS)) + subscription.close() + // Locked, then unlocked: the lock listeners hear one lock, and the device is not "locked" afterwards. + assertEquals(listOf(true, false), reported.take(2)) + } + + @Test + fun `a sleep during a real lock adds nothing`() { + val answers = ArrayDeque(listOf(false, true, true, true)) + val sleeps = ArrayDeque(listOf(false, false, true, false)) + val reported = mutableListOf() + val polling = PollingLockSignals( + intervalMillis = 5, + probe = { if (answers.size > 1) answers.removeFirst() else answers.first() }, + slept = { if (sleeps.size > 1) sleeps.removeFirst() else false }, + ) + val done = CountDownLatch(1) + val subscription = polling.start { + reported += it + done.countDown() + } + assertTrue(done.await(5, TimeUnit.SECONDS)) + Thread.sleep(100) + subscription.close() + assertEquals(listOf(true), reported) + } + + @Test + fun `polling accepts its own scheduler`() { + val scheduler = Executors.newSingleThreadScheduledExecutor() + val polling = PollingLockSignals(intervalMillis = 1, probe = { false }, executor = { scheduler }) + polling.start { }.close() + assertTrue(scheduler.isShutdown) + } +} + +class CoarseUptimeTest { + private var uptimeMillis = 10_000L + private var monotonic = 0L + private var reads = 0 + private val clock = CoarseUptime( + uptimeMillis = { + reads++ + uptimeMillis + }, + monotonicNanos = { monotonic }, + refreshNanos = 1_000_000_000L, + ) + + @Test + fun `an input event within the second does not ask the OS and the uptime moves on with the process clock`() { + val first = clock.nanos() + val readsAfterFirst = reads + monotonic += 400_000_000L + + assertEquals(first + 400_000_000L, clock.nanos()) + assertEquals(readsAfterFirst, reads) + } + + @Test + fun `after a second the OS is asked again and a sleep in between shows`() { + clock.nanos() + monotonic += 1_500_000_000L + // The machine slept an hour: the uptime moved on, the process clock (macOS) did not. + uptimeMillis += 3_600_000L + + assertEquals(3_610_000L * 1_000_000L, clock.nanos()) + assertEquals(2, reads) + } + + @Test + fun `the idle check reads the exact clock and not the cheap one`() { + val timer = ManualTimer() + var exactNanos = 0L + var cheapNanos = 0L + var locks = 0 + val idle = IdleLock( + timer = timer, + policy = object : AutoLockPolicy { + override val delayMillis: Long = 15_000 + }, + lockNow = { locks++ }, + clock = { cheapNanos }, + checkClock = { exactNanos }, + ) + idle.start() + // The cheap clock stood still (a sleep), the exact one did not: past the limit, the vault locks. + exactNanos = (IdleLock.MIN_IDLE_MILLIS + 1) * 1_000_000L + timer.fireAll() + + assertEquals(1, locks) + } +} + +class SleepDetectorTest { + private var counting = 10_000L + private var awake = 10_000L + private var readable = true + private val detector = SleepDetector({ if (readable) SleepDetector.Clocks(counting, awake) else null }) + + @Test + fun `the first question only takes the starting gap`() { + assertFalse(detector.sleptSinceLastCheck()) + } + + @Test + fun `time that passes awake is no sleep`() { + detector.sleptSinceLastCheck() + counting += 1_500 + awake += 1_500 + assertFalse(detector.sleptSinceLastCheck()) + } + + @Test + fun `a gap that grew by the time asleep is a sleep, once`() { + detector.sleptSinceLastCheck() + counting += 3_600_000 + awake += 1_500 + + assertTrue(detector.sleptSinceLastCheck()) + counting += 1_500 + awake += 1_500 + assertFalse(detector.sleptSinceLastCheck()) + } + + @Test + fun `a nap shorter than the threshold is not told apart from rounding`() { + detector.sleptSinceLastCheck() + counting += 1_000 + + assertFalse(detector.sleptSinceLastCheck()) + } + + @Test + fun `clocks that cannot be read detect nothing, and a sleep in the meantime is still seen afterwards`() { + detector.sleptSinceLastCheck() + readable = false + counting += 10_000 + assertFalse(detector.sleptSinceLastCheck()) + readable = true + assertTrue(detector.sleptSinceLastCheck()) + } +} + +class OsUptimeClockTest { + @Test + fun `the clock follows the OS uptime and the boot id`() { + val source = FakeBootClock(uptime = 5_000, id = "boot-a") + val clock = OsUptimeClock(source) + assertTrue(clock.exact) + assertEquals(5_000L, clock.elapsedRealtime()) + source.uptime = 9_000 + assertEquals(9_000L, clock.elapsedRealtime()) + val a = clock.bootCount() + source.id = "boot-b" + assertTrue(a != clock.bootCount()) + source.id = "boot-a" + assertEquals(a, clock.bootCount()) + } + + @Test + fun `without a boot id the count is minus one`() { + assertEquals(-1, OsUptimeClock(FakeBootClock(id = null)).bootCount()) + } + + @Test + fun `without an uptime the clock still runs, from the start of the process`() { + val clock = OsUptimeClock(FakeBootClock(uptime = null)) + assertFalse(clock.exact) + val first = clock.elapsedRealtime() + Thread.sleep(5) + assertTrue(clock.elapsedRealtime() >= first) + } + + @Test + fun `proc uptime is parsed to milliseconds`() { + assertEquals(12_345_670L, parseProcUptime("12345.67 8910.11\n")) + assertNull(parseProcUptime("garbage")) + assertNull(parseProcUptime("-1.0 2.0")) + } +} + +class NoBiometricManagerTest { + private val host = object : BiometricHost {} + private val manager = NoBiometricManager() + + @Test + fun `biometrics are never available`() { + assertFalse(manager.isStrongBiometricAvailable()) + assertEquals(BiometricStatus.UNAVAILABLE, manager.strongBiometricStatus()) + assertFalse(manager.hasSlot()) + manager.disable() + } + + @Test + fun `enrolling and unlocking answer with nothing`() { + var enrolled: Boolean? = null + manager.enroll(host, ByteArray(32)) { enrolled = it } + assertEquals(false, enrolled) + var key: ByteArray? = byteArrayOf(1) + manager.unlock(host) { key = it } + assertNull(key) + } + + @Test + fun `an authentication request is an error`() { + var errorCode = 0 + manager.authenticate( + host, + BiometricManager.BiometricType.GENERAL, + object : BiometricManager.BiometricCallback { + override fun onSuccess() = Unit + + override fun onError(errorCode: Int, errorMessage: String) { + reported = errorCode + } + + override fun onFailed() = Unit + }, + ) + errorCode = reported + assertTrue(errorCode != 0) + } + + private var reported = 0 +} + +class SwingMainThreadTimerTest { + @Test + fun `a scheduled action runs once on the UI thread`() { + val ran = CountDownLatch(1) + var onUiThread = false + SwingMainThreadTimer().schedule(1) { + onUiThread = SwingUtilities.isEventDispatchThread() + ran.countDown() + } + assertTrue(ran.await(5, TimeUnit.SECONDS)) + assertTrue(onUiThread) + } + + @Test + fun `a cancelled action never runs`() { + val ran = CountDownLatch(1) + val cancel = SwingMainThreadTimer().schedule(50) { ran.countDown() } + cancel() + assertFalse(ran.await(300, TimeUnit.MILLISECONDS)) + } + + @Test + fun `cancelling from the UI thread works too`() { + val ran = CountDownLatch(1) + val cancel = SwingMainThreadTimer().schedule(50) { ran.countDown() } + SwingUtilities.invokeAndWait { cancel() } + assertFalse(ran.await(300, TimeUnit.MILLISECONDS)) + } +} + +class LinuxLiveLockTest { + private fun live(hint: () -> Boolean?, vararg savers: () -> Boolean?) = LinuxLiveLock(hint, savers.toList()) + + @Test + fun `a session that logind calls locked is locked`() { + assertTrue(live({ true }).isLocked()) + } + + @Test + fun `an unlocked session with nothing active is not locked`() { + assertFalse(live({ false }, { false }, { null }).isLocked()) + } + + @Test + fun `an active screen saver is a lock even when logind sets no hint`() { + assertTrue(live({ false }, { null }, { true }).isLocked()) + assertTrue(live({ null }, { true }).isLocked()) + } + + @Test + fun `nothing that can be read counts as not locked`() { + assertFalse(live({ null }, { null }).isLocked()) + assertFalse(live({ null }).isLocked()) + } + + @Test + fun `a source that fails is skipped and the others still answer`() { + assertFalse(live({ error("no bus") }, { error("no screen saver") }).isLocked()) + assertTrue(live({ error("no bus") }, { true }).isLocked()) + assertTrue(live({ true }, { error("no screen saver") }).isLocked()) + } + + @Test + fun `every answer is read when asked and none is remembered`() { + var hint: Boolean? = true + val lock = live({ hint }) + + assertTrue(lock.isLocked()) + hint = false + assertFalse(lock.isLocked()) + hint = true + assertTrue(lock.isLocked()) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StartupTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StartupTest.kt new file mode 100644 index 00000000..ed94e12b --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StartupTest.kt @@ -0,0 +1,57 @@ +package com.pasich.encly.desktop + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SingleInstanceLockTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `a second start in the same data directory is refused until the first ends`() { + val dir = temp.root.toPath() + val first = SingleInstanceLock.tryAcquire(dir) + assertNotNull(first) + assertNull(SingleInstanceLock.tryAcquire(dir)) + first.close() + val again = SingleInstanceLock.tryAcquire(dir) + assertNotNull(again) + again.close() + } + + @Test + fun `another data directory is independent`() { + val a = SingleInstanceLock.tryAcquire(temp.newFolder("a").toPath()) + val b = SingleInstanceLock.tryAcquire(temp.newFolder("b").toPath()) + assertNotNull(a) + assertNotNull(b) + a.close() + b.close() + } + + @Test + fun `a directory that does not exist cannot be locked`() { + assertNull(SingleInstanceLock.tryAcquire(temp.root.toPath().resolve("missing"))) + } +} + +class LaunchFilesTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `only existing files Encly can open are taken from the arguments`() { + val backup = temp.newFile("vault.enclybak") + val note = temp.newFile("a.md") + val photo = temp.newFile("p.png") + val args = + arrayOf(backup.path, "--flag", photo.path, temp.root.resolve("missing.md").path, "bad\u0000path", note.path) + assertEquals(listOf(backup.toPath(), note.toPath()), LaunchFiles.fromArguments(args)) + assertTrue(LaunchFiles.fromArguments(emptyArray()).isEmpty()) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt new file mode 100644 index 00000000..18ec7140 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt @@ -0,0 +1,267 @@ +package com.pasich.encly.desktop + +import com.pasich.encly.desktop.storage.AppDirectories +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.desktop.storage.FileKeyValueStore +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.Executor +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AppDirectoriesTest { + @get:Rule + val temp = TemporaryFolder() + + private fun env(vararg pairs: Pair): (String) -> String? = mapOf(*pairs)::get + + @Test + fun `windows uses LOCALAPPDATA and never the roaming profile`() { + assertEquals( + Paths.get("C:\\Users\\a\\AppData\\Local", "Encly"), + AppDirectories.dataDirectory( + DesktopOs.WINDOWS, + env("LOCALAPPDATA" to "C:\\Users\\a\\AppData\\Local", "APPDATA" to "C:\\roaming"), + "C:\\Users\\a", + ), + ) + assertEquals( + Paths.get("home", "AppData", "Local", "Encly"), + AppDirectories.dataDirectory(DesktopOs.WINDOWS, env("LOCALAPPDATA" to " ", "APPDATA" to "r"), "home"), + ) + assertEquals( + Paths.get("home", "AppData", "Local", "Encly"), + AppDirectories.dataDirectory(DesktopOs.WINDOWS, env("APPDATA" to "C:\\roaming"), "home"), + ) + } + + @Test + fun `macos uses Application Support`() { + assertEquals( + Paths.get("/Users/a", "Library", "Application Support", "Encly"), + AppDirectories.dataDirectory(DesktopOs.MACOS, env(), "/Users/a"), + ) + } + + @Test + fun `linux uses XDG_DATA_HOME only when it is absolute`() { + assertEquals( + Paths.get("/data/xdg", "encly"), + AppDirectories.dataDirectory(DesktopOs.LINUX, env("XDG_DATA_HOME" to "/data/xdg"), "/home/a"), + ) + val fallback = Paths.get("/home/a", ".local", "share", "encly") + assertEquals(fallback, AppDirectories.dataDirectory(DesktopOs.LINUX, env(), "/home/a")) + assertEquals(fallback, AppDirectories.dataDirectory(DesktopOs.LINUX, env("XDG_DATA_HOME" to ""), "/home/a")) + assertEquals(fallback, AppDirectories.dataDirectory(DesktopOs.LINUX, env("XDG_DATA_HOME" to "rel"), "/home/a")) + } + + @Test + fun `os names map to the layouts`() { + assertEquals(DesktopOs.WINDOWS, DesktopOs.fromName("Windows 11")) + assertEquals(DesktopOs.MACOS, DesktopOs.fromName("Mac OS X")) + assertEquals(DesktopOs.MACOS, DesktopOs.fromName("Darwin")) + assertEquals(DesktopOs.LINUX, DesktopOs.fromName("Linux")) + assertEquals(DesktopOs.LINUX, DesktopOs.fromName("FreeBSD")) + DesktopOs.current() + } + + @Test + fun `the system directory is an absolute path of this user`() { + assertTrue(AppDirectories.systemDataDirectory().isAbsolute) + } + + @Test + fun `a private directory is owner only where POSIX permissions exist`() { + assumeTrue(Files.getFileStore(temp.root.toPath()).supportsFileAttributeView("posix")) + val dir = temp.root.toPath().resolve("a/b/Encly") + AppDirectories.createPrivate(dir) + assertEquals("rwx------", PosixFilePermissions.toString(Files.getPosixFilePermissions(dir))) + } + + @Test + fun `an existing loose directory is tightened`() { + assumeTrue(Files.getFileStore(temp.root.toPath()).supportsFileAttributeView("posix")) + val dir = temp.newFolder("loose").toPath() + Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwxr-xr-x")) + AppDirectories.createPrivate(dir) + assertEquals("rwx------", PosixFilePermissions.toString(Files.getPosixFilePermissions(dir))) + } + + @Test + fun `a directory is created without POSIX permissions too`() { + // Whatever the file store supports, creating works and is idempotent. + val dir = temp.root.toPath().resolve("plain") + assertEquals(dir, AppDirectories.createPrivate(dir)) + assertEquals(dir, AppDirectories.createPrivate(dir)) + assertTrue(Files.isDirectory(dir)) + } +} + +class FileKeyValueStoreTest { + @get:Rule + val temp = TemporaryFolder() + + private val now = Executor { it.run() } + + private fun file(): Path = temp.root.toPath().resolve("flags.properties") + + @Test + fun `values are typed and survive a restart`() { + val store = FileKeyValueStore(file(), now) + assertTrue( + store.commit { + putString("s", "héllo = wörld\nline") + putBoolean("b", true) + putLong("l", 1L shl 40) + }, + ) + val again = FileKeyValueStore(file(), now) + assertEquals("héllo = wörld\nline", again.getString("s", null)) + assertTrue(again.getBoolean("b", false)) + assertEquals(1L shl 40, again.getLong("l", 0L)) + assertTrue(again.contains("s")) + } + + @Test + fun `defaults come back for a missing or mistyped key`() { + val store = FileKeyValueStore(file(), now) + store.edit { putString("text", "not a number") } + assertEquals("d", store.getString("none", "d")) + assertEquals(7L, store.getLong("none", 7L)) + assertEquals(9L, store.getLong("text", 9L)) + assertTrue(store.getBoolean("text", true)) + assertFalse(store.contains("none")) + } + + @Test + fun `remove and clear are persisted`() { + val store = FileKeyValueStore(file(), now) + store.commit { + putString("a", "1") + putString("b", "2") + } + store.commit { remove("a") } + assertNull(FileKeyValueStore(file(), now).getString("a", null)) + store.commit { clear() } + assertFalse(FileKeyValueStore(file(), now).contains("b")) + } + + @Test + fun `edit returns at once and writes later on the writer`() { + val queued = mutableListOf() + val store = FileKeyValueStore(file()) { queued += it } + store.edit { putString("k", "v") } + // In memory at once, on disk only after the writer ran. + assertEquals("v", store.getString("k", null)) + assertFalse(Files.exists(file())) + queued.forEach(Runnable::run) + assertEquals("v", FileKeyValueStore(file(), now).getString("k", null)) + } + + @Test + fun `a write leaves no temp file and replaces the old content whole`() { + val store = FileKeyValueStore(file(), now) + repeat(5) { i -> store.commit { putLong("n", i.toLong()) } } + val names = Files.list(temp.root.toPath()).use { it.map { p -> p.fileName.toString() }.toList() } + assertEquals(listOf("flags.properties"), names) + assertEquals(4L, FileKeyValueStore(file(), now).getLong("n", -1)) + } + + @Test + fun `a failed write keeps the old file intact and reports false`() { + val store = FileKeyValueStore(file(), now) + assertTrue(store.commit { putString("k", "old") }) + val before = Files.readAllBytes(file()) + // Make the target a directory: the rename over it fails, as a full disk or a lock would. + Files.delete(file()) + Files.createDirectory(file()) + Files.createFile(file().resolve("keep")) + assertFalse(store.commit { putString("k", "new") }) + assertTrue(Files.exists(file().resolve("keep"))) + val names = Files.list(temp.root.toPath()).use { it.map { p -> p.fileName.toString() }.toList() } + assertEquals(listOf("flags.properties"), names, "no temp file is left behind") + assertTrue(before.isNotEmpty()) + // The value is still held in memory. + assertEquals("new", store.getString("k", null)) + } + + @Test + fun `a damaged file is moved aside and the store starts empty and writable`() { + val damaged = byteArrayOf(0x5c, 0x75, 0x7a) + Files.write(file(), damaged) + val store = FileKeyValueStore(file(), now) + assertFalse(store.contains("anything")) + assertTrue(store.getBoolean("flag", true)) + assertContentEquals(damaged, Files.readAllBytes(file().resolveSibling("flags.properties.damaged"))) + assertTrue(store.commit { putString("k", "v") }) + assertEquals("v", FileKeyValueStore(file(), now).getString("k", null)) + } + + @Test + fun `a file that is not utf-8 is damaged too`() { + Files.write(file(), byteArrayOf(0x66, 0x3d, 0xc3.toByte(), 0x28)) + val store = FileKeyValueStore(file(), now) + assertTrue(store.commit { putString("k", "v") }) + assertTrue(Files.exists(file().resolveSibling("flags.properties.damaged"))) + assertEquals("v", FileKeyValueStore(file(), now).getString("k", null)) + } + + @Test + fun `a file that cannot be read now fails closed and is never overwritten`() { + // A directory where the file should be: reading it is an I/O error, not damage. + Files.createDirectory(file()) + val store = FileKeyValueStore(file(), now) + assertFalse(store.contains("anything")) + assertTrue(store.getBoolean("flag", true)) + assertFalse(store.commit { putString("k", "v") }) + store.edit { putString("k", "v") } + assertTrue(Files.isDirectory(file())) + assertNull(store.getString("k", null)) + } + + @Test + fun `a file that could not be read is read again once it can be`() { + Files.createDirectory(file()) + val store = FileKeyValueStore(file(), now) + assertFalse(store.getBoolean("flag", false)) + Files.delete(file()) + Files.writeString(file(), "flag=true\n") + assertTrue(store.getBoolean("flag", false)) + assertTrue(store.commit { putString("k", "v") }) + assertEquals("v", FileKeyValueStore(file(), now).getString("k", null)) + assertTrue(FileKeyValueStore(file(), now).getBoolean("flag", false)) + } + + @Test + fun `a missing file starts empty and is created by the first write`() { + val store = FileKeyValueStore(file(), now) + assertFalse(store.contains("anything")) + assertTrue(store.commit { putString("k", "v") }) + assertEquals("v", FileKeyValueStore(file(), now).getString("k", null)) + } + + @Test + fun `a file in a directory that does not exist yet is created`() { + val nested = temp.root.toPath().resolve("a/b/flags.properties") + assertTrue(FileKeyValueStore(nested, now).commit { putBoolean("x", true) }) + assertTrue(Files.isRegularFile(nested)) + } + + @Test + fun `the default writer persists in the background`() { + val store = FileKeyValueStore(file()) + store.edit { putString("async", "2") } + // commit writes the whole current state, the asynchronous edit before it included. + assertTrue(store.commit { putString("sync", "1") }) + assertEquals("2", FileKeyValueStore(file()).getString("async", null)) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/TestSupport.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/TestSupport.kt new file mode 100644 index 00000000..4633a0d6 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/TestSupport.kt @@ -0,0 +1,242 @@ +package com.pasich.encly.desktop + +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.desktop.platform.CaptureExclusion +import com.pasich.encly.desktop.platform.DesktopAppLocaleController +import com.pasich.encly.desktop.platform.DesktopEnvironment +import com.pasich.encly.desktop.platform.FileDialogs +import com.pasich.encly.desktop.platform.LinkOpener +import com.pasich.encly.desktop.security.BootClockSource +import com.pasich.encly.desktop.security.OsLockSignals +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.desktop.window.DefaultDesktopCommands +import com.pasich.encly.domain.model.ThemeType +import com.pasich.encly.platform.NavigationRequests +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.awt.Window +import java.awt.datatransfer.Clipboard +import java.net.URI +import java.nio.file.Path +import java.security.SecureRandom +import java.util.Locale +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** A [PinHardwareFactor] in memory: the same HMAC contract as the OS secret store, with no OS involved. */ +class FakePinFactor : PinHardwareFactor { + private val keys = mutableMapOf() + + override fun ensureKey(slot: PinKeySlot): Boolean { + if (keys.containsKey(slot)) return false + reset(slot) + return true + } + + override fun reset(slot: PinKeySlot) { + keys[slot] = ByteArray(KEY_SIZE).also(SecureRandom()::nextBytes) + } + + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { + val key = keys[slot] ?: throw com.pasich.encly.core.security.PinFactorException(lost = true) + return Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(data) + } + } + + override fun delete(slot: PinKeySlot) { + keys.remove(slot) + } + + private companion object { + const val KEY_SIZE = 32 + } +} + +/** A [KeyValueStore] in memory. */ +class MemoryKeyValueStore : KeyValueStore { + val values = mutableMapOf() + + override fun getString(key: String, default: String?): String? = values[key] ?: default + + override fun getBoolean(key: String, default: Boolean): Boolean = values[key]?.toBooleanStrictOrNull() ?: default + + override fun getLong(key: String, default: Long): Long = values[key]?.toLongOrNull() ?: default + + override fun contains(key: String): Boolean = values.containsKey(key) + + override fun edit(block: KeyValueStore.Editor.() -> Unit) { + object : KeyValueStore.Editor { + override fun putString(key: String, value: String) { + values[key] = value + } + + override fun putBoolean(key: String, value: Boolean) { + values[key] = value.toString() + } + + override fun putLong(key: String, value: Long) { + values[key] = value.toString() + } + + override fun remove(key: String) { + values.remove(key) + } + + override fun clear() { + values.clear() + } + }.block() + } + + override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean { + edit(block) + return true + } +} + +/** The pieces `DefaultDesktopCommands` needs, with what they were asked recorded. */ +class CommandsRig { + val navigation = NavigationRequests() + val originalLocale: Locale = Locale.getDefault() + val locales = DesktopAppLocaleController(MemoryKeyValueStore(), originalLocale) + val events = mutableListOf() + val themes = mutableListOf() + var lockNowCalls = 0 + val commands = DefaultDesktopCommands( + navigation = navigation, + lockSession = { lockNowCalls++ }, + locales = locales, + saveThemeType = { themes += it }, + scope = CoroutineScope(Dispatchers.Unconfined), + onMinimise = { events += "minimise" }, + onQuit = { events += "quit" }, + openWindow = { events += "window" }, + ) + + /** The screens asked for so far, in order. */ + fun requested(): List = drainRoutes(navigation) + + fun restoreLocale() = Locale.setDefault(originalLocale) +} + +/** The routes requested through [navigation] and not yet taken, in order. */ +fun drainRoutes(navigation: NavigationRequests): List { + val out = mutableListOf() + runBlocking { withTimeoutOrNull(WAIT_MS) { navigation.routes.collect { out += it } } } + return out +} + +/** The commands requested through [navigation] and not yet taken, in order. */ +fun drainCommands(navigation: NavigationRequests): List { + val out = mutableListOf() + runBlocking { withTimeoutOrNull(WAIT_MS) { navigation.commands.collect { out += it } } } + return out +} + +private const val WAIT_MS = 200L + +/** A timer that runs nothing by itself: the test fires what was scheduled. */ +class ManualTimer : MainThreadTimer { + class Task(val delayMs: Long, val action: () -> Unit) { + var cancelled = false + var fired = false + } + + val tasks = mutableListOf() + + override fun schedule(delayMs: Long, action: () -> Unit): () -> Unit { + val task = Task(delayMs, action) + tasks += task + val cancel: () -> Unit = { task.cancelled = true } + return cancel + } + + /** The tasks that are still waiting. */ + val pending: List get() = tasks.filter { !it.cancelled && !it.fired } + + /** Runs every waiting task once (the tasks they schedule wait for the next call). */ + fun fireAll() { + pending.forEach { + it.fired = true + it.action() + } + } +} + +class FakeBootClock(var uptime: Long? = 1_000L, var id: String? = "boot-1") : BootClockSource { + override fun uptimeMillis(): Long? = uptime + + override fun bootId(): String? = id +} + +class RecordingCapture : CaptureExclusion { + val windows = mutableListOf() + override val supported: Boolean = true + + override fun exclude(window: Window): Boolean { + windows += window + return true + } +} + +class RecordingLinks(var accepts: Boolean = true) : LinkOpener { + val opened = mutableListOf() + + override fun open(uri: URI): Boolean { + opened += uri + return accepts + } +} + +/** Dialogs that answer at once with what the test set up. */ +class FakeFileDialogs : FileDialogs { + var saveAnswer: Path? = null + var openAnswer: List = emptyList() + var folderAnswer: Path? = null + val calls = mutableListOf() + + override fun save(suggestedName: String, onResult: (Path?) -> Unit) { + calls += "save:$suggestedName" + onResult(saveAnswer) + } + + override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) { + calls += "open:$multiple:${extensions.sorted().joinToString(",")}" + onResult(openAnswer) + } + + override fun folder(onResult: (Path?) -> Unit) { + calls += "folder" + onResult(folderAnswer) + } +} + +/** An environment in [directory] with no OS access at all: nothing touches the real data folder or Keychain. */ +fun testEnvironment( + directory: Path, + timer: ManualTimer = ManualTimer(), + clipboard: Clipboard = Clipboard("test"), + dialogs: FileDialogs = FakeFileDialogs(), + links: LinkOpener = RecordingLinks(), + capture: CaptureExclusion = RecordingCapture(), + lockSignals: OsLockSignals = OsLockSignals.None, + bootClock: BootClockSource = FakeBootClock(), +): DesktopEnvironment = DesktopEnvironment( + os = DesktopOs.LINUX, + dataDirectory = directory, + pinFactor = FakePinFactor(), + lockSignals = lockSignals, + bootClock = bootClock, + captureExclusion = capture, + fileDialogs = dialogs, + linkOpener = links, + systemClipboard = { clipboard }, + timer = timer, +) diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowAndCommandsTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowAndCommandsTest.kt new file mode 100644 index 00000000..d66a68a5 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowAndCommandsTest.kt @@ -0,0 +1,238 @@ +package com.pasich.encly.desktop + +import androidx.compose.ui.input.key.Key +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.desktop.storage.FileKeyValueStore +import com.pasich.encly.desktop.window.Accelerator +import com.pasich.encly.desktop.window.DesktopShortcuts +import com.pasich.encly.desktop.window.SavedWindow +import com.pasich.encly.desktop.window.WindowBounds +import com.pasich.encly.desktop.window.WindowStateStore +import com.pasich.encly.presentation.designsystem.AppCommand +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.util.concurrent.Executor +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WindowStateStoreTest { + @get:Rule + val temp = TemporaryFolder() + + private val screen = WindowBounds(0, 0, 1920, 1080) + private val second = WindowBounds(1920, 0, 1280, 1024) + + private fun flags() = FileKeyValueStore(temp.root.toPath().resolve("flags.properties"), Executor { it.run() }) + + @Test + fun `a first run has nothing saved`() { + assertNull(WindowStateStore(flags()).load(listOf(screen))) + } + + @Test + fun `every window slot keeps its own place and size`() { + WindowStateStore(flags(), slot = 0).save(WindowBounds(100, 80, 1000, 700), maximised = false) + WindowStateStore(flags(), slot = 1).save(WindowBounds(300, 200, 800, 600), maximised = false) + WindowStateStore(flags(), slot = 1).save(null, maximised = true) + + assertEquals( + SavedWindow(1000, 700, 100, 80, maximised = false), + WindowStateStore(flags(), slot = 0).load(listOf(screen)), + ) + assertEquals( + SavedWindow(800, 600, 300, 200, maximised = true), + WindowStateStore(flags(), slot = 1).load(listOf(screen)), + ) + assertNull(WindowStateStore(flags(), slot = 2).load(listOf(screen))) + } + + @Test + fun `size and position survive a restart`() { + WindowStateStore(flags()).save(WindowBounds(100, 80, 1000, 700), maximised = false) + val restored = WindowStateStore(flags()).load(listOf(screen)) + assertEquals(SavedWindow(1000, 700, 100, 80, maximised = false), restored) + } + + @Test + fun `maximised keeps the size from before it`() { + val store = WindowStateStore(flags()) + store.save(WindowBounds(10, 10, 900, 600), maximised = false) + store.save(WindowBounds(0, 0, 1920, 1080), maximised = true) + val restored = WindowStateStore(flags()).load(listOf(screen)) + assertEquals(SavedWindow(900, 600, 10, 10, maximised = true), restored) + // Un-maximising after maximising only (no bounds) keeps the flag and the old size. + store.save(null, maximised = false) + assertFalse(WindowStateStore(flags()).load(listOf(screen))!!.maximised) + } + + @Test + fun `a size below the minimum or above the screen is clamped`() { + val store = WindowStateStore(flags()) + store.save(WindowBounds(0, 0, 100, 100), maximised = false) + val small = store.load(listOf(screen))!! + assertEquals(WindowStateStore.MIN_WIDTH, small.width) + assertEquals(WindowStateStore.MIN_HEIGHT, small.height) + store.save(WindowBounds(0, 0, 9000, 9000), maximised = false) + val big = store.load(listOf(screen))!! + assertEquals(1920, big.width) + assertEquals(1080, big.height) + } + + @Test + fun `a window on a monitor that is gone is moved back`() { + val store = WindowStateStore(flags()) + store.save(WindowBounds(2500, 100, 1000, 700), maximised = false) + assertEquals(2500, store.load(listOf(screen, second))!!.x) + val withoutSecond = store.load(listOf(screen))!! + assertNull(withoutSecond.x) + assertNull(withoutSecond.y) + assertEquals(1000, withoutSecond.width) + } + + @Test + fun `a barely visible window is moved back and no screens means no check`() { + val store = WindowStateStore(flags()) + store.save(WindowBounds(1900, 1060, 1000, 700), maximised = false) + assertNull(store.load(listOf(screen))!!.x) + assertEquals(1900, store.load(emptyList())!!.x) + } + + @Test + fun `bounds know how much they overlap`() { + val a = WindowBounds(0, 0, 10, 10) + assertEquals(25L, a.overlapArea(WindowBounds(5, 5, 10, 10))) + assertEquals(0L, a.overlapArea(WindowBounds(10, 0, 5, 5))) + assertEquals(10, a.right) + assertEquals(10, a.bottom) + } +} + +class DesktopCommandsAndShortcutsTest { + @Test + fun `the primary modifier is Cmd on macOS and Ctrl elsewhere`() { + assertEquals(Accelerator(Key.N, ctrl = false, meta = true), DesktopShortcuts.primary(DesktopOs.MACOS, Key.N)) + for (os in listOf(DesktopOs.WINDOWS, DesktopOs.LINUX)) { + assertEquals( + Accelerator(Key.N, ctrl = true, meta = false, shift = true), + DesktopShortcuts.primary(os, Key.N, shift = true), + ) + } + } + + @Test + fun `every menu command has its own shortcut`() { + for (os in DesktopOs.entries) { + val keys = DesktopShortcuts.map(os) + val shortcuts = listOf( + DesktopShortcuts.of(keys, AppCommand.NewNote), + DesktopShortcuts.newWindow(os), + DesktopShortcuts.printNote(os), + DesktopShortcuts.exportPdf(os), + DesktopShortcuts.importNotes(os), + DesktopShortcuts.backup(os), + DesktopShortcuts.of(keys, AppCommand.LockApp), + DesktopShortcuts.settings(os), + DesktopShortcuts.minimise(os), + DesktopShortcuts.quit(os), + ) + assertEquals(shortcuts.size, shortcuts.toSet().size) + } + val linux = DesktopShortcuts.map(DesktopOs.LINUX) + assertEquals(DesktopShortcuts.primary(DesktopOs.LINUX, Key.L), DesktopShortcuts.of(linux, AppCommand.LockApp)) + val mac = DesktopShortcuts.map(DesktopOs.MACOS) + assertEquals(DesktopShortcuts.primary(DesktopOs.MACOS, Key.N), DesktopShortcuts.of(mac, AppCommand.NewNote)) + } + + @Test + fun `a command that is not a letter chord has no menu accelerator`() { + assertFailsWith { + DesktopShortcuts.of(DesktopShortcuts.map(DesktopOs.LINUX), AppCommand.NextTab) + } + } + + @Test + fun `the window key map follows the os`() { + assertTrue(DesktopShortcuts.map(DesktopOs.MACOS).usesMetaKey) + assertFalse(DesktopShortcuts.map(DesktopOs.LINUX).usesMetaKey) + assertFalse(DesktopShortcuts.map(DesktopOs.WINDOWS).usesMetaKey) + } + + @Test + fun `commands ask for the screens and the session`() { + val rig = CommandsRig() + with(rig.commands) { + importNotes() + backup() + settings() + about() + licences() + faq() + } + assertEquals( + listOf( + "ImportNotesRoute", + "BackupRoute", + "SettingsRoute", + "AboutRoute", + "LicensesRoute", + "FaqRoute", + ), + rig.requested(), + ) + } + + @Test + fun `a new note is asked for as a command so it opens like the button does`() { + val rig = CommandsRig() + + rig.commands.newNote() + + assertEquals(emptyList(), rig.requested()) + assertEquals(listOf(AppCommand.NewNote), drainCommands(rig.navigation)) + } + + @Test + fun `new window print and pdf are asked of the window they belong to`() { + val rig = CommandsRig() + + rig.commands.printNote() + rig.commands.exportPdf() + rig.commands.newWindow() + + assertEquals(listOf(AppCommand.PrintNote, AppCommand.ExportPdf), drainCommands(rig.navigation)) + assertEquals(listOf("window"), rig.events) + } + + @Test + fun `language theme minimise and quit reach their owners`() { + val rig = CommandsRig() + try { + rig.commands.setLanguage(AppLanguage.GERMAN) + assertEquals(AppLanguage.GERMAN, rig.locales.current()) + assertEquals("de", java.util.Locale.getDefault().language) + assertEquals(1, rig.locales.revision.value) + rig.commands.setLanguage(AppLanguage.SYSTEM) + assertEquals(AppLanguage.SYSTEM, rig.locales.current()) + assertEquals(rig.originalLocale, java.util.Locale.getDefault()) + } finally { + rig.restoreLocale() + } + rig.commands.setTheme(com.pasich.encly.domain.model.ThemeType.DARK) + rig.commands.minimise() + rig.commands.quit() + assertEquals(listOf("minimise", "quit"), rig.events) + assertEquals(listOf(com.pasich.encly.domain.model.ThemeType.DARK), rig.themes) + } + + @Test + fun `lock now closes only an open vault`() { + val rig = CommandsRig() + rig.commands.lockNow() + assertEquals(1, rig.lockNowCalls) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowsTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowsTest.kt new file mode 100644 index 00000000..2985ca53 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowsTest.kt @@ -0,0 +1,425 @@ +package com.pasich.encly.desktop + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.pasich.encly.desktop.di.DesktopAppGraph +import com.pasich.encly.desktop.security.DesktopAppLifecycle +import com.pasich.encly.desktop.storage.DesktopOs +import com.pasich.encly.desktop.window.DesktopShortcuts +import com.pasich.encly.desktop.window.DesktopWindows +import com.pasich.encly.desktop.window.WindowForeground +import com.pasich.encly.desktop.window.WindowFrames +import com.pasich.encly.desktop.window.WindowServices +import com.pasich.encly.desktop.window.awaitPendingWork +import com.pasich.encly.presentation.designsystem.AppCommand +import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeFalse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.awt.Frame +import java.awt.GraphicsEnvironment +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DesktopWindowsTest { + @get:Rule + val temp = TemporaryFolder() + + private var canOpen = true + private var ended = 0 + + private fun windows(): DesktopWindows { + val graph = createGraph() + return DesktopWindows( + canOpen = { canOpen }, + onLastClosed = { ended++ }, + create = { id, owner -> + WindowServices.create( + graph.environment, + graph.lifecycle, + claims = owner.registry.forWindow(id), + ) + }, + ) + } + + private fun createGraph() = + createGraphFactory().create(testEnvironment(temp.root.toPath())) + + @Test + fun everyWindowTakesTheLowestPlaceNoOpenWindowUses() { + val windows = windows() + + val first = assertNotNull(windows.open()) + val second = assertNotNull(windows.open()) + val third = assertNotNull(windows.open()) + assertEquals(listOf(0, 1, 2), listOf(first, second, third).map { it.slot }) + + // A closed window gives its place back; the next one takes it, the others keep theirs. + windows.close(second.id) + assertEquals(1, assertNotNull(windows.open()).slot) + assertEquals(2, third.slot) + } + + @Test + fun filesWaitForTheStartStatusAndForTheUnlockOfALockedVault() = runBlocking { + val windows = windows() + val services = assertNotNull(windows.open()).services + val locked = kotlinx.coroutines.flow.MutableStateFlow(true) + var ready = false + val waiting = launch { + services.awaitReadyForFiles(locked) { } + ready = true + } + + // Nothing is known about the vault yet: a slow start must not let the files through. + kotlinx.coroutines.delay(50) + assertFalse(ready) + + services.startStatus.complete(com.pasich.encly.core.security.InitialStatus.AUTH) + kotlinx.coroutines.delay(50) + assertFalse(ready, "the vault is still locked") + + locked.value = false + waiting.join() + assertTrue(ready) + } + + @Test + fun filesAreTakenAtOnceWhenTheVaultIsOpenOrThereIsNoneYet() = runBlocking { + val windows = windows() + for (status in listOf( + com.pasich.encly.core.security.InitialStatus.MAIN, + com.pasich.encly.core.security.InitialStatus.ONBOARDING, + )) { + val services = assertNotNull(windows.open()).services + services.startStatus.complete(status) + // Still "locked" as a flag: only a vault that starts locked waits for the unlock. + services.awaitReadyForFiles(kotlinx.coroutines.flow.MutableStateFlow(true)) { } + } + } + + @Test + fun everyWindowHasItsOwnMessages() { + val windows = windows() + + val first = assertNotNull(windows.open()) + val second = assertNotNull(windows.open()) + + assertNotSame(first.services.toaster, second.services.toaster) + } + + @Test + fun theFirstWindowOpensAlwaysAndTheFocusIsOnIt() { + canOpen = false + val windows = windows() + + val first = assertNotNull(windows.open()) + + assertEquals(listOf(first.id), windows.sessions.map { it.id }) + assertSame(first, windows.focused) + } + + @Test + fun furtherWindowsNeedAVaultThatAllowsThem() { + val windows = windows() + windows.open() + + canOpen = false + assertNull(windows.open()) + assertEquals(1, windows.sessions.size) + + canOpen = true + assertNotNull(windows.open()) + assertEquals(2, windows.sessions.size) + } + + @Test + fun aWindowOpenedOnANoteKnowsIt() { + val windows = windows() + windows.open() + + val second = assertNotNull(windows.open(noteId = 42L)) + + assertEquals(42L, second.openNoteId) + } + + @Test + fun everyWindowHasItsOwnRequestsDropsAndPickers() { + val windows = windows() + val first = assertNotNull(windows.open()) + val second = assertNotNull(windows.open()) + + assertNotSame(first.services.navigation, second.services.navigation) + assertNotSame(first.services.dropRouter, second.services.dropRouter) + assertNotSame(first.services.documentPickers, second.services.documentPickers) + + // A request for one window is never taken by the other. + first.services.navigation.request(AppCommand.NewNote) + assertEquals(listOf(AppCommand.NewNote), drainCommands(first.services.navigation)) + assertEquals(emptyList(), drainCommands(second.services.navigation)) + } + + @Test + fun theMenuAndTheOsReachTheWindowThatWasActiveLast() { + val windows = windows() + val first = assertNotNull(windows.open()) + val second = assertNotNull(windows.open()) + val firstRig = CommandsRig() + val secondRig = CommandsRig() + first.commands = firstRig.commands + second.commands = secondRig.commands + + // The newest window has the focus. + assertSame(second, windows.focused) + windows.focused?.commands?.settings() + assertEquals(listOf("SettingsRoute"), secondRig.requested()) + assertEquals(emptyList(), firstRig.requested()) + + windows.focus(first.id) + windows.focused?.commands?.about() + assertEquals(listOf("AboutRoute"), firstRig.requested()) + assertEquals(emptyList(), secondRig.requested()) + } + + @Test + fun focusingAWindowThatIsGoneChangesNothing() { + val windows = windows() + val first = assertNotNull(windows.open()) + windows.focus(99) + assertSame(first, windows.focused) + assertFalse(windows.isOpen(99)) + assertTrue(windows.isOpen(first.id)) + } + + @Test + fun closingAWindowMovesTheFocusAndOnlyTheLastOneEndsTheApp() { + val windows = windows() + val first = assertNotNull(windows.open()) + val second = assertNotNull(windows.open()) + + windows.close(second.id) + assertEquals(0, ended) + assertSame(first, windows.focused) + + // Closing what is already closed does nothing. + windows.close(second.id) + assertEquals(0, ended) + + windows.close(first.id) + assertEquals(1, ended) + assertNull(windows.focused) + } + + @Test + fun quitClosesEveryWindowAndEndsTheAppOnce() { + val windows = windows() + windows.open() + windows.open() + + windows.closeAll() + windows.closeAll() + + assertEquals(0, windows.sessions.size) + assertEquals(1, ended) + } + + @Test + fun theWindowsShareOneNoteRegistryButClaimPerWindow() { + val windows = windows() + val first = assertNotNull(windows.open()) + val second = assertNotNull(windows.open()) + + assertTrue(assertNotNull(first.services.claims).claim(5)) + assertFalse(assertNotNull(second.services.claims).claim(5)) + assertTrue(windows.registry.isClaimed(5)) + } +} + +class WindowForegroundTest { + private class Stops : DefaultLifecycleObserver { + var stopCount = 0 + + override fun onStop(owner: LifecycleOwner) { + stopCount++ + } + } + + private val lifecycle = DesktopAppLifecycle() + private val observer = Stops().also { lifecycle.lifecycle.addObserver(it) } + private val foreground = WindowForeground(lifecycle) + + private fun twoWindowsOpen() { + foreground.opened(1) + foreground.activated(1) + foreground.opened(2) + } + + @Test + fun theFocusMovingBetweenOurOwnWindowsIsNotLeavingTheApp() { + twoWindowsOpen() + val stopsBefore = observer.stopCount + + // The OS reports the loss of one window first and names the other as its opposite. + foreground.deactivated(1, toOwnWindow = true) + foreground.activated(2) + foreground.deactivated(2, toOwnWindow = true) + foreground.activated(1) + + assertEquals(stopsBefore, observer.stopCount) + assertTrue(lifecycle.inForeground) + } + + @Test + fun leavingForAnotherApplicationStopsAndComingBackStarts() { + twoWindowsOpen() + + foreground.deactivated(1, toOwnWindow = false) + assertFalse(lifecycle.inForeground) + assertEquals(1, observer.stopCount) + + foreground.activated(2) + assertTrue(lifecycle.inForeground) + } + + @Test + fun theAppStaysInFrontWhileAnyWindowIsActive() { + twoWindowsOpen() + foreground.activated(2) + + foreground.deactivated(1, toOwnWindow = false) + + assertTrue(lifecycle.inForeground, "window 2 is still active") + foreground.deactivated(2, toOwnWindow = false) + assertFalse(lifecycle.inForeground) + } + + @Test + fun theAppIsLeftOnlyWhenEveryWindowIsMinimised() { + twoWindowsOpen() + foreground.activated(2) + + foreground.iconified(1) + assertTrue(lifecycle.inForeground, "window 2 is not minimised") + + foreground.iconified(2) + assertFalse(lifecycle.inForeground) + + foreground.deiconified(2) + assertTrue(lifecycle.inForeground) + } + + @Test + fun closingAWindowIsNeverReportedAsLeavingTheApp() { + twoWindowsOpen() + foreground.activated(2) + val stopsBefore = observer.stopCount + + foreground.closed(2) + assertEquals(stopsBefore, observer.stopCount) + + // The last one too: the vault is closed after the editors saved, not under them. + foreground.closed(1) + assertEquals(stopsBefore, observer.stopCount) + assertTrue(lifecycle.inForeground) + } + + @Test + fun aMinimisedWindowThatIsClosedNoLongerHoldsTheOthersBack() { + twoWindowsOpen() + foreground.iconified(1) + foreground.activated(2) + + foreground.closed(1) + + assertTrue(lifecycle.inForeground) + } +} + +class PendingWorkTest { + @Test + fun waitsForWhatTheScopeLaunched() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val gate = CompletableDeferred() + var saved = false + scope.launch { + gate.await() + saved = true + } + scope.launch { gate.complete(Unit) } + + scope.awaitPendingWork(timeoutMillis = 5_000) + + assertTrue(saved) + } + + @Test + fun givesUpAfterTheTimeout() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + scope.launch { CompletableDeferred().await() } + + val started = System.nanoTime() + scope.awaitPendingWork(timeoutMillis = 100) + + assertTrue((System.nanoTime() - started) / 1_000_000 < 5_000) + } + + @Test + fun aScopeWithoutAJobHasNothingToWaitFor() = runBlocking { + CoroutineScope(Dispatchers.Default).awaitPendingWork(timeoutMillis = 100) + } +} + +class WindowShortcutsTest { + @Test + fun newWindowPrintAndPdfAreMenuChordsWithTheirOwnKeys() { + for (os in DesktopOs.entries) { + val chords = DesktopShortcuts.menuOnly(os) + assertTrue(DesktopShortcuts.newWindow(os) in chords) + assertTrue(DesktopShortcuts.printNote(os) in chords) + assertTrue(DesktopShortcuts.exportPdf(os) in chords) + assertEquals(chords.size, chords.toSet().size, "$os: a chord is used twice") + assertTrue(DesktopShortcuts.newWindow(os).shift) + assertFalse(DesktopShortcuts.printNote(os).shift) + assertTrue(DesktopShortcuts.exportPdf(os).shift) + } + } +} + +class WindowFramesTest { + @Test + fun theFocusLeavingTheAppIsNoFrameOfTheApp() { + assertFalse(null in WindowFrames()) + } + + @Test + fun framesAreRememberedUntilTheyAreRemoved() { + assumeFalse(GraphicsEnvironment.isHeadless()) + val frames = WindowFrames() + val frame = Frame() + val other = Frame() + try { + frames.add(frame) + + assertTrue(frame in frames) + assertFalse(other in frames) + frames.remove(frame) + assertFalse(frame in frames) + } finally { + frame.dispose() + other.dispose() + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 822f4063..1f3e4255 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,15 +6,19 @@ Encly is a local-first Android application. Its runtime direction is: presentation → domain/use cases → repositories → local data source → Room/SQLCipher ``` -The presentation layer contains Compose screens and Hilt ViewModels. It renders state and sends -user intent; persistence and security decisions remain outside Composables. +The presentation layer contains Compose screens and Metro-injected ViewModels. It renders state and +sends user intent; persistence and security decisions remain outside Composables. ## Layers - **Presentation** — navigation, screens, reusable UI and ViewModels. - **Domain** — use cases plus repository contracts. ViewModels use these contracts for feature operations such as saving a note, moving it to trash or changing a task. -- **Data** — Room entities/DAOs, repository implementations and the local data-source facade. +- **Data** — repository implementations and the local data-source facade. The Room entities, DAOs, + migrations and `SecureDatabaseManager` are the Kotlin Multiplatform module `:core:database` + (Android, desktop, iOS): the database is opened through Sealite's SQLCipher driver on every + target, and the app tells it where the files are through `DatabasePlatform` (on Android, the + app's own `databases` directory, the same files as ever). - **Core security** — vault setup/unlock, session lock and auto-lock delay (`SessionLockManager`, `AutoLock`), the Keystore-bound PIN factor, biometric slot, recovery slot and the SQLCipher database lifecycle. @@ -52,6 +56,12 @@ 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 is the Kotlin Multiplatform module `:editor` (package `com.pasich.encly.editor`: +`model`, `codec`, `state`, `focus`, `theme`, `ui`; see `editor/README.md`), which depends on nothing of +the app (`EditorBoundaryTest`): the app gives it its texts +(`EditorStrings`), look (`EditorTheme`), clipboard, link opener and logger, and a controller over +the note's view model (`EditorController`), all bound in `presentation/screen/editnote/EditorBindings.kt`. + 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 @@ -59,9 +69,50 @@ on the JVM. Find, the slash picker and the template offer are transient UI state 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 +editor's `MarkdownBlockMapper` (shared with the importers) in its paste mode (nothing rewritten); the reading density (`NoteLineSpacing`) is presentation only. +## Platform boundary + +The shared core is three Kotlin Multiplatform modules, checked by a source-scan test each +(`CommonBoundaryTest`, `CryptoBoundaryTest`, `SecurityBoundaryTest` in the module's `desktopTest`): + +- `:core:common` (Android, desktop, iOS): `UiText`, `AppStrings`, `LoadState`, `KeyValueStore`, + `MainThreadTimer`, `SensitiveDataCleaner`, `AppLogger`. No dependency on another module. +- `:core:crypto` (Android, desktop): `Crypto` (AES-256-GCM, HKDF, PBKDF2, SHA-256, the CSPRNG) and BIP-39. The API + is common code; the implementation is JCA in `jvmAndAndroidMain`, shared by both targets. There is no iOS + target until AES-GCM exists there (Apple's public CommonCrypto has none). +- `:core:security` (Android, desktop): the vault store, the PIN, wipe-PIN, duress and recovery slots, the + lockout, `SecurityManager`, `SessionLockManager`, `AutoLock`, and the backup file format and cipher. Locks are + atomicfu `SynchronizedObject`s. Only the vault file I/O and the backup JSON bytes are JVM code + (`jvmAndAndroidMain`). + +Their sources import nothing of Android and nothing of the JVM. What they need from the platform is an interface +they declare, implemented in `:app` and bound in `core/di`: `KeyValueStore` (flags), `VaultFileIo` (the vault +file's fsync and atomic replace, `JvmVaultFileIo` in the module), `VaultDatabase` (the encrypted database, +`SecureDatabaseVault`, in `:shared` for both apps), `BiometricManager` and `BiometricHost`, `PinHardwareFactor`, `LockoutClock`, +`DeviceLockWatcher`, `MainThreadTimer`, `HandoffStagingSweeper`, `AppStrings`. + +The rest of the domain and data logic (`domain`, `data/repository|backup|importer|export|handoff|zip`) and the whole +UI (design system, screens, dialogs, navigation, ViewModels) are the Kotlin Multiplatform module `:shared` (Android, +desktop; no iOS target while `:core:crypto` has none). Its common sources are held to the same rule by +`SharedBoundaryTest` (`desktopTest`): no `android.`, `java.` or `androidx.activity|core|appcompat|fragment|biometric` +import, none of `LocalContext`, `LocalActivity`, `LocalView` or `LocalConfiguration`, and no dependency on `:app`; the few +platform source sets (`jvmAndAndroidMain`, `androidMain`, `desktopMain`) are listed there file by file, each with its +reason. What the UI needs from the platform is an interface in `com.pasich.encly.platform`, implemented by the host: + +- Composition locals, provided once around the content by `AndroidHostServices` (`:app`): `PlatformDocuments` and + `DocumentPickers` (the Storage Access Framework: `AndroidDocuments`, `AndroidDocumentPickers`), `ExternalLinks`, + `Toaster`, `SensitiveClipboard` (the clipboard policy of `SensitiveClip`), `AppWindow` (FLAG_SECURE, status bar, + window background, keyboard, rotation), `ScreenBiometricHost` (the Activity a biometric prompt is shown on), + `AppLocaleController` and `AppInfo` (the flavor's flags). +- ViewModel dependencies, bound in the graph: `PlatformDocuments`, `CacheDirectory`, `AppLocaleController`, + `DeviceCapabilities`. + +`:app` keeps `MainActivity`, `ImportFromMyNotesActivity`, `MyApplication`, the graph and its Android binding containers, +and every Android adapter (the platform interfaces above, Keystore and biometrics, the device lock, SharedPreferences, +the DataStore factory, the keyboard and clipboard boundaries, the hand-off caller check, the in-app language). + ## Navigation Startup resolves vault state before rendering the main graph. Locked sessions route to the lock @@ -83,8 +134,8 @@ the full unlock reveal plays only on the first unlock after launch, later unlock ## Encrypted backups Export/import is fully offline: the UI hands whole files to and from the Storage Access -Framework (`CreateDocument` / `OpenDocument`), so there is no storage permission, no temp file -and no network code. The key comes from the BIP39 recovery seed (see +Framework (`CreateDocument` / `OpenDocument`, behind `DocumentPickers` and `PlatformDocuments`), so there is no +storage permission, no temp file and no network code. The key comes from the BIP39 recovery seed (see [SECURITY.md](../SECURITY.md#encrypted-backups)), so the same 12 words open a backup on a new phone. @@ -99,7 +150,7 @@ phone. | `data/backup/VaultDataStore` | Narrow storage contract; `RoomVaultDataStore` over `BackupDao` | | `data/backup/BackupPhraseSetup` | Recovery seed + backup-key slot needed before an export | | `data/backup/PendingRestore` | Onboarding restore held in memory until PIN setup opens the vault | -| `data/backup/BackupDocuments` | Reads (≤ 64 MiB, buffer sized from the header) and writes the picked document; never overwrites an existing one | +| `data/backup/BackupDocuments` | Reads (≤ 64 MiB, buffer sized from the header) and writes the picked document (through `PlatformDocuments`); never overwrites an existing one | | `presentation/.../BackupViewModel` + `BackupFlows` | Re-auth, export, import and seed-setup steps | ```mermaid @@ -198,5 +249,5 @@ use) are written, in one transaction on the vault session bound when the read st files, parsed notes and selection live in the ViewModel only, are never put in saved state, and are dropped on cancel, on finish and when the vault locks. Import is available in a decoy session too and imports into whichever vault is open. The real exporters' files are the test fixtures -(`app/src/test/resources/import-fixtures`). +(`shared/src/jvmAndAndroidTest/resources/import-fixtures`). diff --git a/editor/.gitignore b/editor/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/editor/.gitignore @@ -0,0 +1 @@ +/build diff --git a/editor/Module.md b/editor/Module.md new file mode 100644 index 00000000..73e8e038 --- /dev/null +++ b/editor/Module.md @@ -0,0 +1,67 @@ +# Module Encly editor + +The block editor of Encly notes, as a Kotlin Multiplatform library for Android, the desktop JVM and iOS. +A note is a list of [Block][com.pasich.encly.editor.model.Block]s (paragraphs, headings, quotes, lists, link +cards, separators). The library keeps the blocks in a +[BlockEditorState][com.pasich.encly.editor.state.BlockEditorState] with undo, find and every edit +operation, shows them with Compose Multiplatform, and stores them as a stable JSON format +([BlockConverter][com.pasich.encly.editor.codec.BlockConverter]). + +It depends on nothing of Encly. The host supplies texts, theme, clipboard, link opening and logging through +[EditorProvider][com.pasich.encly.editor.EditorProvider]. + +## Quick start + +```kotlin +val state = remember { BlockEditorState() } + +EditorProvider(strings = myStrings, theme = EditorTheme(), clipboard = myClipboard) { + val host = EditorBlocksHost( + controller = myController, // an EditorController over `state` + focusRegistry = rememberBlockFocusRegistry(state.blocks), + highlights = FindHighlights(state.find.state.collectAsState()), + onOpenSheet = { kind, block -> /* show the sheet of this block */ }, + ) + LazyColumn { + editorBlocks(host, blocks = state.blocks, isLocked = false, editingLinkIds = emptySet()) + } +} +``` + +# Package com.pasich.encly.editor + +The extension points a host implements: strings, clipboard, link opener, logger, Markdown reader, and +[EditorProvider][com.pasich.encly.editor.EditorProvider] that provides them. + +# Package com.pasich.encly.editor.model + +The block model: [Block][com.pasich.encly.editor.model.Block] and its payloads. + +# Package com.pasich.encly.editor.codec + +Reading and writing blocks: the stored JSON, Markdown, and the inline HTML of imported notes. + +# Package com.pasich.encly.editor.state + +[BlockEditorState][com.pasich.encly.editor.state.BlockEditorState] and the operations on it: structural edits, +list and text editing, selection, templates, slash commands, Smart Paste and find. + +# Package com.pasich.encly.editor.focus + +Focus and caret handling across the blocks of a note. + +# Package com.pasich.encly.editor.theme + +[EditorTheme][com.pasich.encly.editor.theme.EditorTheme]: the spacing, type and icon tokens the editor draws with. + +# Package com.pasich.encly.editor.ui + +The editor's Compose UI: the blocks host and its controller. + +# Package com.pasich.encly.editor.ui.blocks + +Composables and helpers of the individual blocks, and the note link rules. + +# Package com.pasich.encly.editor.ui.sheets + +The bottom sheets of a block (link actions, other actions). diff --git a/editor/README.md b/editor/README.md new file mode 100644 index 00000000..147d4cb9 --- /dev/null +++ b/editor/README.md @@ -0,0 +1,96 @@ +# Encly editor + +The block editor of Encly notes as a Kotlin Multiplatform library: a note is a list of blocks (paragraph, +headings, quote, bulleted / numbered / check lists, link card, separator), edited with Compose Multiplatform. + +It knows nothing about Encly's vault, database, dependency injection or security policies. Everything it +needs from its host (texts, theme, clipboard, link opening, logging, Markdown reading) comes in through a +few small interfaces, so the same editor runs on Android, the desktop JVM and iOS. + +| Target | Source sets | Checked in | +|---|---|---| +| Android (min SDK 26) | `commonMain`, `androidMain` | unit tests on the JVM under Robolectric (`androidHostTest`) | +| Desktop JVM | `commonMain`, `desktopMain` | `desktopTest` | +| iOS (`iosArm64`, `iosSimulatorArm64`) | `commonMain`, `iosMain` | compiled in CI on macOS; logic is shared, so it is tested on the JVM | + +The module depends on Compose Multiplatform (runtime, foundation, ui, material3), `kotlinx-coroutines` and +`kotlinx-serialization-json`. It has no dependency on `:app` (a test fails if one is added) and no `java.`, +`android.` or `R` in `commonMain` (`EditorBoundaryTest`). + +## Layout + +`src/commonMain/kotlin/com/pasich/encly/editor/` + +| Package | What is in it | +|---|---| +| `editor` | The extension points: `EditorProvider`, `EditorStrings` / `EditorString`, `EditorClipboard`, `LinkOpener`, `EditorLogger`, `MarkdownBlockParser` | +| `model` | `Block` (sealed: text, heading, quote, list, link, separator), `BlockType`, `BlockFactory`, the list and link payloads | +| `codec` | `BlockConverter` (blocks to / from the stored JSON; the format is frozen by golden tests), `MarkdownBlockMapper`, `InlineHtml`, `ImportBlocks` | +| `state` | `BlockEditorState`: the blocks, undo / redo, selection, find, slash picker and every edit operation, with no UI in it | +| `focus` | Focus and caret handling across blocks (`BlockFocusRegistry`) | +| `theme` | `EditorTheme` (spacing, font sizes and families, line spacing, icons) and `LocalEditorTheme` | +| `ui` | `EditorBlocksHost`, `editorBlocks` (the `LazyColumn` content), `EditorController`, the block composables and sheets | + +## Public API + +The public surface is small on purpose and is tracked in `api/` (Kotlin's built-in ABI validation): a change +that touches it fails `:editor:checkKotlinAbi` until `:editor:updateKotlinAbi` rewrites the dump and the +diff is reviewed. Explicit API mode is on, and detekt requires KDoc on every public declaration. + +- **Content**: `Block` and its subclasses, `BlockConverter.blocksToJson` / `jsonToBlocksOrNull` for storage, + `MarkdownBlockMapper`, `InlineHtml` and `ImportBlocks` for importers. +- **Editing**: `BlockEditorState` (`load`, `blocks`, `undo`, `redo`, `contentChanges`, `selection`, `find`, + `slash`, ...) and the operations that take it (`applyTool`, `applyTemplate`, `applyShortcut`, `breakLine`, ...). +- **Showing it**: `EditorProvider` above, then `editorBlocks(host, blocks, ...)` inside the host's own + `LazyColumn`. The `EditorBlocksHost` it takes bundles an `EditorController` (the host implements it on top of + `BlockEditorState`), the focus registry, the find highlights and the callback that opens a block's sheet. + +## Extension points + +| Interface | What the host decides | +|---|---| +| `EditorStrings` | Every text the editor shows, by `EditorString` key. English is the default. | +| `EditorTheme` | Spacing, font sizes and families, line spacing, icons. The editor draws with Material 3 but reads its own tokens. | +| `EditorClipboard` | How text is copied. `sensitive = true` marks text the user wrote: keep it out of previews and clear it later. | +| `LinkOpener` | Opening a web or mail link. The editor only offers links that `isOpenableLink` accepts. | +| `EditorLogger` | Where recoverable failures go. Events are fixed descriptions, never note content. | +| `MarkdownBlockParser` | How pasted Markdown becomes blocks (Smart Paste). `MarkdownBlockMapper` is the default. | + +Each has an inert default, so the editor can be shown with only what the host cares about. + +## How Encly binds it + +The app module (`app/.../presentation/screen/editnote/`) owns everything security- and platform-specific: + +- `EditorBindings.kt`: `AppEditorProvider` maps the editor's string keys onto Android resources, builds the + `EditorTheme` from the user's font and spacing settings, and passes the app's clipboard (sensitive text is + flagged and wiped), link opener and logger. +- `EditNoteViewModelController`: implements `EditorController` on the note's `BlockEditorState`, and + saves through `NotePersistence`. +- The keyboard privacy policy (no learning, secure text input), `FLAG_SECURE` and the clipboard wipe stay in + the app; the editor never touches them. + +## Platform code + +| Declaration | android / desktop | iOS | +|---|---|---| +| `toAsciiHost` (`ui/blocks/AsciiHost.kt`): a link card shows an internationalized host in its ASCII (punycode) form, so it cannot imitate another site | `java.net.IDN` | the pure-Kotlin `Punycode` encoder (RFC 3492) | + +Everything else is in `commonMain`. The two implementations are checked against the same reference names +(`PunycodeTest`). + +## Build and test + +``` +./gradlew :editor:desktopTest :editor:testAndroidHostTest # unit and Compose UI tests +./gradlew :editor:koverVerify :editor:koverVerifyEditorLogic # coverage gates +./gradlew :editor:detekt :editor:checkKotlinAbi # static analysis, public API dump +./gradlew :editor:dokkaGeneratePublicationHtml # API docs (build/dokka) +./gradlew :editor:compileKotlinIosSimulatorArm64 # macOS only +``` + +Coverage is gated at 100 % of lines for the whole module and 100 % of branches for the logic packages +(`model`, `codec`, `state`, `focus`, `theme`). The branches of `@Composable` functions are not gated: the Compose +compiler adds `$changed` / `$default` checks to every one of them that no test can take both ways. +Tests of pure logic are in `commonTest` (`kotlin.test`); Compose UI tests run under Robolectric in +`androidHostTest`. diff --git a/editor/api/android/editor.api b/editor/api/android/editor.api new file mode 100644 index 00000000..2e24bf83 --- /dev/null +++ b/editor/api/android/editor.api @@ -0,0 +1,1184 @@ +public abstract interface class com/pasich/encly/editor/EditorClipboard { + public static final field None Lcom/pasich/encly/editor/EditorClipboard$None; + public abstract fun copy (Ljava/lang/String;Ljava/lang/String;Z)V +} + +public final class com/pasich/encly/editor/EditorClipboard$None : com/pasich/encly/editor/EditorClipboard { + public fun copy (Ljava/lang/String;Ljava/lang/String;Z)V +} + +public abstract interface class com/pasich/encly/editor/EditorLogger { + public static final field None Lcom/pasich/encly/editor/EditorLogger$None; + public abstract fun warn (Ljava/lang/String;Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/EditorLogger$None : com/pasich/encly/editor/EditorLogger { + public fun warn (Ljava/lang/String;Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/EditorServicesKt { + public static final fun EditorProvider (Lcom/pasich/encly/editor/EditorStrings;Lcom/pasich/encly/editor/theme/EditorTheme;Lcom/pasich/encly/editor/EditorClipboard;Lcom/pasich/encly/editor/LinkOpener;Lcom/pasich/encly/editor/EditorLogger;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun getLocalEditorStrings ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + +public final class com/pasich/encly/editor/EditorString : java/lang/Enum { + public static final field BLOCK_BULLETED_LIST Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_CHECKLIST Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_CODE Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_DELETE Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_DUPLICATE Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_HEADING Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_HEADING_LEVEL Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_LINK Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_LIST Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_MOVE_DOWN Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_MOVE_UP Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_NUMBERED_LIST Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_SELECT Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_SEPARATOR Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_TABLE Lcom/pasich/encly/editor/EditorString; + public static final field CHECKLIST_LOCK Lcom/pasich/encly/editor/EditorString; + public static final field CHECKLIST_UNLOCK Lcom/pasich/encly/editor/EditorString; + public static final field ENTER_URL Lcom/pasich/encly/editor/EditorString; + public static final field LINK_ADD Lcom/pasich/encly/editor/EditorString; + public static final field LINK_BLOCKED Lcom/pasich/encly/editor/EditorString; + public static final field LINK_CONTENT_FAILED Lcom/pasich/encly/editor/EditorString; + public static final field LINK_COPY Lcom/pasich/encly/editor/EditorString; + public static final field LINK_EDIT Lcom/pasich/encly/editor/EditorString; + public static final field LINK_OPEN Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_INDENT Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_MOVE_DOWN Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_MOVE_UP Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_OUTDENT Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_PLACEHOLDER Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_REORDER Lcom/pasich/encly/editor/EditorString; + public static final field MORE_OPTIONS Lcom/pasich/encly/editor/EditorString; + public static final field PRESS_TO_EDIT Lcom/pasich/encly/editor/EditorString; + public static final field QUOTE Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_CLOSE Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_COPY Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_COPY_MARKDOWN Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_COUNT Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_CUT Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_DELETE Lcom/pasich/encly/editor/EditorString; + public static final field SLASH_MENU_TITLE Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_ADD_COLUMN Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_ADD_ROW Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_CELL_OPTIONS Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_HEADER_ROW Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_REMOVE_COLUMN Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_REMOVE_ROW Lcom/pasich/encly/editor/EditorString; + public static final field TAP_AGAIN_TO_DELETE Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_ACTION_ITEMS Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_AGENDA Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_ATTENDEES Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_BLANK Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_CHECKLIST_TODO Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_JOURNAL Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_JOURNAL_GRATITUDE Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_JOURNAL_TODAY Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_JOURNAL_TOMORROW Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_MEETING Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_NOTES Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT_DECISIONS Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT_GOAL Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT_QUESTIONS Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT_TASKS Lcom/pasich/encly/editor/EditorString; + public static final field TEXT Lcom/pasich/encly/editor/EditorString; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/EditorString; + public static fun values ()[Lcom/pasich/encly/editor/EditorString; +} + +public abstract interface class com/pasich/encly/editor/EditorStrings { + public abstract fun heading (I)Ljava/lang/String; + public abstract fun selectionCount (I)Ljava/lang/String; + public abstract fun text (Lcom/pasich/encly/editor/EditorString;)Ljava/lang/String; +} + +public abstract interface class com/pasich/encly/editor/LinkOpener { + public static final field None Lcom/pasich/encly/editor/LinkOpener$None; + public abstract fun open (Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/LinkOpener$None : com/pasich/encly/editor/LinkOpener { + public fun open (Ljava/lang/String;)V +} + +public abstract interface class com/pasich/encly/editor/MarkdownBlockParser { + public abstract fun parsePaste (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/util/List; +} + +public final class com/pasich/encly/editor/codec/BlockConverter { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/codec/BlockConverter; + public final fun blocksToJson (Ljava/util/List;)Ljava/lang/String; + public final fun jsonToBlocks (Ljava/lang/String;)Ljava/util/List; + public final fun jsonToBlocksOrNull (Ljava/lang/String;Lcom/pasich/encly/editor/EditorLogger;)Ljava/util/List; + public static synthetic fun jsonToBlocksOrNull$default (Lcom/pasich/encly/editor/codec/BlockConverter;Ljava/lang/String;Lcom/pasich/encly/editor/EditorLogger;ILjava/lang/Object;)Ljava/util/List; +} + +public final class com/pasich/encly/editor/codec/ImportBlocks { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/codec/ImportBlocks; + public final fun code (Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block; + public final fun heading (Ljava/lang/String;I)Lcom/pasich/encly/editor/model/Block; + public final fun list (Lcom/pasich/encly/editor/model/BlockType;Ljava/util/List;)Lcom/pasich/encly/editor/model/Block; + public final fun paragraphs (Ljava/lang/String;)Ljava/util/List; + public final fun quote (Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block; + public final fun table (Ljava/util/List;Z)Lcom/pasich/encly/editor/model/Block; + public final fun text (Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block; +} + +public final class com/pasich/encly/editor/codec/InlineHtml { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/codec/InlineHtml; + public final fun toPlainText (Ljava/lang/String;Z)Ljava/lang/String; + public static synthetic fun toPlainText$default (Lcom/pasich/encly/editor/codec/InlineHtml;Ljava/lang/String;ZILjava/lang/Object;)Ljava/lang/String; +} + +public final class com/pasich/encly/editor/codec/MarkdownBlockMapper : com/pasich/encly/editor/MarkdownBlockParser { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/codec/MarkdownBlockMapper; + public fun parsePaste (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/util/List; + public final fun toBlocks (Ljava/lang/String;)Ljava/util/List; +} + +public final class com/pasich/encly/editor/focus/BlockFocusRegistry { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/EditorLogger;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lcom/pasich/encly/editor/EditorLogger;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun focus (Lcom/pasich/encly/editor/state/FocusRequest;)Z + public final fun focus (Ljava/lang/String;Z)Z + public static synthetic fun focus$default (Lcom/pasich/encly/editor/focus/BlockFocusRegistry;Ljava/lang/String;ZILjava/lang/Object;)Z + public final fun focusWhenComposed (Lcom/pasich/encly/editor/state/FocusRequest;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun focusWhenComposed$default (Lcom/pasich/encly/editor/focus/BlockFocusRegistry;Lcom/pasich/encly/editor/state/FocusRequest;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public abstract class com/pasich/encly/editor/model/Block { + public static final field $stable I + public abstract fun getId ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$CodeBlock : com/pasich/encly/editor/model/Block, com/pasich/encly/editor/model/TextualBlock { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$CodeBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$CodeBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$CodeBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$HBlock : com/pasich/encly/editor/model/Block, com/pasich/encly/editor/model/TextualBlock { + public static final field $stable I + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$HBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$HBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$HBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockType ()Lcom/pasich/encly/editor/model/BlockType; + public fun getId ()Ljava/lang/String; + public fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$LinkBlock : com/pasich/encly/editor/model/Block { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$LinkBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$LinkBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$LinkBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlock ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun getId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$ListBlock : com/pasich/encly/editor/model/Block { + public static final field $stable I + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;)Lcom/pasich/encly/editor/model/Block$ListBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$ListBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$ListBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockType ()Lcom/pasich/encly/editor/model/BlockType; + public fun getId ()Ljava/lang/String; + public final fun getItems ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun getLocked ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$QuoteBlock : com/pasich/encly/editor/model/Block, com/pasich/encly/editor/model/TextualBlock { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$QuoteBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$QuoteBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$QuoteBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$SeparatorBlock : com/pasich/encly/editor/model/Block { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$SeparatorBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$SeparatorBlock;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$SeparatorBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$TableBlock : com/pasich/encly/editor/model/Block { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$TableBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$TableBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$TableBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public final fun getTable ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$TextBlock : com/pasich/encly/editor/model/Block, com/pasich/encly/editor/model/TextualBlock { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/EditorString;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/EditorString;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Lcom/pasich/encly/editor/EditorString; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/EditorString;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$TextBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$TextBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/EditorString;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$TextBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public final fun getPlaceholder ()Lcom/pasich/encly/editor/EditorString; + public fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/BlockFactory { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/model/BlockFactory; + public final fun createBlock (Lcom/pasich/encly/editor/model/BlockType;)Lcom/pasich/encly/editor/model/Block; +} + +public final class com/pasich/encly/editor/model/BlockKt { + public static final fun hasContent (Lcom/pasich/encly/editor/model/Block;)Z +} + +public final class com/pasich/encly/editor/model/BlockType : java/lang/Enum { + public static final field CODE Lcom/pasich/encly/editor/model/BlockType; + public static final field H1 Lcom/pasich/encly/editor/model/BlockType; + public static final field H2 Lcom/pasich/encly/editor/model/BlockType; + public static final field H3 Lcom/pasich/encly/editor/model/BlockType; + public static final field H4 Lcom/pasich/encly/editor/model/BlockType; + public static final field LINK Lcom/pasich/encly/editor/model/BlockType; + public static final field LIST_BULLET Lcom/pasich/encly/editor/model/BlockType; + public static final field LIST_CHECK Lcom/pasich/encly/editor/model/BlockType; + public static final field LIST_NUMBER Lcom/pasich/encly/editor/model/BlockType; + public static final field QUOTE Lcom/pasich/encly/editor/model/BlockType; + public static final field SEPARATOR Lcom/pasich/encly/editor/model/BlockType; + public static final field TABLE Lcom/pasich/encly/editor/model/BlockType; + public static final field TEXT Lcom/pasich/encly/editor/model/BlockType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/model/BlockType; + public static fun values ()[Lcom/pasich/encly/editor/model/BlockType; +} + +public final class com/pasich/encly/editor/model/ItemListBlock { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;ZLjava/lang/String;ILcom/pasich/encly/editor/model/BlockType;)V + public synthetic fun (Ljava/lang/String;ZLjava/lang/String;ILcom/pasich/encly/editor/model/BlockType;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Z + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()I + public final fun component5 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun copy (Ljava/lang/String;ZLjava/lang/String;ILcom/pasich/encly/editor/model/BlockType;)Lcom/pasich/encly/editor/model/ItemListBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/ItemListBlock;Ljava/lang/String;ZLjava/lang/String;ILcom/pasich/encly/editor/model/BlockType;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/ItemListBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getKind ()Lcom/pasich/encly/editor/model/BlockType; + public final fun getLevel ()I + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCheck ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/LinkDataBlock { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)Lcom/pasich/encly/editor/model/LinkDataBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/LinkDataBlock;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lcom/pasich/encly/editor/model/LinkDataBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getImageUrl ()Ljava/lang/String; + public final fun getTitle ()Ljava/lang/String; + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public final fun isError ()Z + public final fun setError (Z)V + public final fun setImageUrl (Ljava/lang/String;)V + public final fun setTitle (Ljava/lang/String;)V + public final fun setUrl (Ljava/lang/String;)V + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/ListLevelsKt { + public static final field MAX_LIST_LEVEL I + public static final fun isListKind (Lcom/pasich/encly/editor/model/BlockType;)Z + public static final fun kindIn (Lcom/pasich/encly/editor/model/ItemListBlock;Lcom/pasich/encly/editor/model/BlockType;)Lcom/pasich/encly/editor/model/BlockType; + public static final fun normalizedLevels (Ljava/util/List;Lcom/pasich/encly/editor/model/BlockType;)Ljava/util/List; + public static final fun numbers (Ljava/util/List;Lcom/pasich/encly/editor/model/BlockType;)Ljava/util/List; + public static final fun siblingStarts (Ljava/util/List;I)Ljava/util/List; + public static final fun subtreeEnd (Ljava/util/List;I)I +} + +public final class com/pasich/encly/editor/model/ListMovesKt { + public static final fun movedBy (Ljava/util/List;II)Lkotlin/Pair; + public static final fun shifted (Ljava/util/List;IZLcom/pasich/encly/editor/model/BlockType;)Ljava/util/List; + public static final fun siblingOffsetAt (Ljava/util/List;ILjava/util/List;Ljava/util/List;F)I + public static final fun withSiblingsOfKind (Ljava/util/List;ILcom/pasich/encly/editor/model/BlockType;Lcom/pasich/encly/editor/model/BlockType;)Ljava/util/List; +} + +public final class com/pasich/encly/editor/model/TableCell { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/pasich/encly/editor/model/TableCell; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/TableCell;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableCell; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/TableData { + public static final field $stable I + public static final field Companion Lcom/pasich/encly/editor/model/TableData$Companion; + public fun (Ljava/util/List;Z)V + public synthetic fun (Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addColumn (I)Lcom/pasich/encly/editor/model/TableData; + public final fun addRow (I)Lcom/pasich/encly/editor/model/TableData; + public final fun cell (Ljava/lang/String;)Lcom/pasich/encly/editor/model/TableCell; + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Z + public final fun copy (Ljava/util/List;Z)Lcom/pasich/encly/editor/model/TableData; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/TableData;Ljava/util/List;ZILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableData; + public fun equals (Ljava/lang/Object;)Z + public final fun getColumnCount ()I + public final fun getHeader ()Z + public final fun getRowCount ()I + public final fun getRows ()Ljava/util/List; + public fun hashCode ()I + public final fun locate (Ljava/lang/String;)Lkotlin/Pair; + public final fun removeColumn (I)Lcom/pasich/encly/editor/model/TableData; + public final fun removeRow (I)Lcom/pasich/encly/editor/model/TableData; + public fun toString ()Ljava/lang/String; + public final fun valueAt (II)Ljava/lang/String; + public final fun withCell (Ljava/lang/String;Ljava/lang/String;)Lcom/pasich/encly/editor/model/TableData; + public final fun withHeader (Z)Lcom/pasich/encly/editor/model/TableData; +} + +public final class com/pasich/encly/editor/model/TableData$Companion { + public final fun empty (II)Lcom/pasich/encly/editor/model/TableData; + public static synthetic fun empty$default (Lcom/pasich/encly/editor/model/TableData$Companion;IIILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableData; + public final fun of (Ljava/util/List;Z)Lcom/pasich/encly/editor/model/TableData; + public static synthetic fun of$default (Lcom/pasich/encly/editor/model/TableData$Companion;Ljava/util/List;ZILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableData; +} + +public final class com/pasich/encly/editor/model/TableRow { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Lcom/pasich/encly/editor/model/TableRow; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/TableRow;Ljava/util/List;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableRow; + public fun equals (Ljava/lang/Object;)Z + public final fun getCells ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/pasich/encly/editor/model/TextualBlock { + public abstract fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; +} + +public final class com/pasich/encly/editor/state/BlockDuplicateKt { + public static final fun duplicateBlockOf (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)Z + public static final fun duplicateInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;)V +} + +public final class com/pasich/encly/editor/state/BlockEditingKt { + public static final fun applyShortcut (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Lcom/pasich/encly/editor/state/Shortcut;)Z + public static final fun backspaceAtStart (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)Z + public static final fun breakLine (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Lcom/pasich/encly/editor/state/LineBreak;)Ljava/lang/String; + public static final fun pasteLink (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Lcom/pasich/encly/editor/model/LinkDataBlock;)Z +} + +public final class com/pasich/encly/editor/state/BlockEditorState { + public static final field $stable I + public fun ()V + public fun (Lcom/pasich/encly/editor/MarkdownBlockParser;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lcom/pasich/encly/editor/MarkdownBlockParser;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addBlock (Lcom/pasich/encly/editor/model/Block;)V + public final fun changeValue (Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/Object;ZLcom/pasich/encly/editor/state/TypingStep;)V + public static synthetic fun changeValue$default (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/Object;ZLcom/pasich/encly/editor/state/TypingStep;ILjava/lang/Object;)V + public final fun getBlocks ()Ljava/util/List; + public final fun getCanRedo ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getCanUndo ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getContentChanges ()Lkotlinx/coroutines/flow/Flow; + public final fun getFind ()Lcom/pasich/encly/editor/state/NoteFind; + public final fun getMarkdown ()Lcom/pasich/encly/editor/MarkdownBlockParser; + public final fun getPickedBlocks ()Lcom/pasich/encly/editor/state/SelectedBlocks; + public final fun getSelection ()Lcom/pasich/encly/editor/state/BlockSelection; + public final fun getSlash ()Lcom/pasich/encly/editor/state/SlashPicker; + public final fun getTextRange ()Lcom/pasich/encly/editor/state/TextRangeSelection; + public final fun load (Ljava/util/List;)V + public final fun redo ()V + public final fun rememberCopied (Ljava/lang/String;Ljava/util/List;)V + public final fun removeBlock (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/BlockRemoveAction;Z)V + public static synthetic fun removeBlock$default (Lcom/pasich/encly/editor/state/BlockEditorState;Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/BlockRemoveAction;ZILjava/lang/Object;)V + public final fun replaceBlock (ILcom/pasich/encly/editor/model/Block;)Z + public final fun undo ()V +} + +public final class com/pasich/encly/editor/state/BlockEditorToolbarKt { + public static final fun addBlockAfter (Lcom/pasich/encly/editor/state/BlockEditorState;ILcom/pasich/encly/editor/model/Block;)V + public static final fun addBlockToEnd (Lcom/pasich/encly/editor/state/BlockEditorState;)V + public static final fun canMoveInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;Z)Z + public static final fun canRemoveInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;)Z + public static final fun indexOf (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)I + public static final fun interactedBlockPosition (Lcom/pasich/encly/editor/state/BlockEditorState;)I + public static final fun moveBlockOf (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Z)V + public static final fun moveInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;Z)V + public static final fun removeInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;)V +} + +public final class com/pasich/encly/editor/state/BlockRemoveAction : java/lang/Enum { + public static final field REMOVE Lcom/pasich/encly/editor/state/BlockRemoveAction; + public static final field REMOVE_BACKSPACE Lcom/pasich/encly/editor/state/BlockRemoveAction; + public static final field REMOVE_BACKSPACE_LIST Lcom/pasich/encly/editor/state/BlockRemoveAction; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/BlockRemoveAction; + public static fun values ()[Lcom/pasich/encly/editor/state/BlockRemoveAction; +} + +public final class com/pasich/encly/editor/state/BlockSelection { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun getFocusedBlockId ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getImmediateFocus ()Lkotlin/jvm/functions/Function1; + public final fun getInteractedBlockId ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getInteractedIndex ()I + public final fun getRequests ()Lkotlinx/coroutines/flow/Flow; + public final fun onCaret (Ljava/lang/String;I)V + public final fun onFocusLost (Ljava/lang/String;)V + public final fun onFocused (Ljava/lang/String;)V + public final fun onInteraction (Ljava/lang/String;)V + public final fun onItemCaret (Ljava/lang/String;Ljava/lang/String;I)V + public final fun setImmediateFocus (Lkotlin/jvm/functions/Function1;)V +} + +public final class com/pasich/encly/editor/state/BlockToolsKt { + public static final fun applyTool (Lcom/pasich/encly/editor/state/BlockEditorState;Lcom/pasich/encly/editor/model/BlockType;Z)V + public static synthetic fun applyTool$default (Lcom/pasich/encly/editor/state/BlockEditorState;Lcom/pasich/encly/editor/model/BlockType;ZILjava/lang/Object;)V + public static final fun focusFirstBlock (Lcom/pasich/encly/editor/state/BlockEditorState;)V + public static final fun focusWorkingBlock (Lcom/pasich/encly/editor/state/BlockEditorState;)V + public static final fun getHEADING_TOOL ()Lcom/pasich/encly/editor/model/BlockType; + public static final fun toggleChecklistLock (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)Z + public static final fun toolType (Lcom/pasich/encly/editor/model/Block;)Lcom/pasich/encly/editor/model/BlockType; +} + +public final class com/pasich/encly/editor/state/FindMatch { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;II)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()I + public final fun component4 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;II)Lcom/pasich/encly/editor/state/FindMatch; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/FindMatch;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/Object;)Lcom/pasich/encly/editor/state/FindMatch; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockId ()Ljava/lang/String; + public final fun getEnd ()I + public final fun getField ()Ljava/lang/String; + public final fun getItemId ()Ljava/lang/String; + public final fun getStart ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/FindState { + public static final field $stable I + public fun ()V + public fun (ZLjava/lang/String;Ljava/util/List;I)V + public synthetic fun (ZLjava/lang/String;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()I + public final fun copy (ZLjava/lang/String;Ljava/util/List;I)Lcom/pasich/encly/editor/state/FindState; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/FindState;ZLjava/lang/String;Ljava/util/List;IILjava/lang/Object;)Lcom/pasich/encly/editor/state/FindState; + public fun equals (Ljava/lang/Object;)Z + public final fun getCurrent ()I + public final fun getCurrentMatch ()Lcom/pasich/encly/editor/state/FindMatch; + public final fun getMatches ()Ljava/util/List; + public final fun getQuery ()Ljava/lang/String; + public fun hashCode ()I + public final fun isOpen ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/FocusRequest { + public static final field $stable I + public fun (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;Ljava/lang/Float;Z)V + public synthetic fun (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;Ljava/lang/Float;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Z + public final fun component3 ()Ljava/lang/Integer; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/Float; + public final fun component6 ()Z + public final fun copy (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;Ljava/lang/Float;Z)Lcom/pasich/encly/editor/state/FocusRequest; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/FocusRequest;Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;Ljava/lang/Float;ZILjava/lang/Object;)Lcom/pasich/encly/editor/state/FocusRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockId ()Ljava/lang/String; + public final fun getCaret ()Ljava/lang/Integer; + public final fun getCaretOffset ()Ljava/lang/Integer; + public final fun getCaretX ()Ljava/lang/Float; + public final fun getCursorToEnd ()Z + public final fun getFromBelow ()Z + public final fun getItemId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/HeadMove : java/lang/Enum { + public static final field DOWN Lcom/pasich/encly/editor/state/HeadMove; + public static final field FIELD_END Lcom/pasich/encly/editor/state/HeadMove; + public static final field FIELD_START Lcom/pasich/encly/editor/state/HeadMove; + public static final field NEXT Lcom/pasich/encly/editor/state/HeadMove; + public static final field NOTE_END Lcom/pasich/encly/editor/state/HeadMove; + public static final field NOTE_START Lcom/pasich/encly/editor/state/HeadMove; + public static final field PAGE_DOWN Lcom/pasich/encly/editor/state/HeadMove; + public static final field PAGE_UP Lcom/pasich/encly/editor/state/HeadMove; + public static final field PREVIOUS Lcom/pasich/encly/editor/state/HeadMove; + public static final field UP Lcom/pasich/encly/editor/state/HeadMove; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/HeadMove; + public static fun values ()[Lcom/pasich/encly/editor/state/HeadMove; +} + +public final class com/pasich/encly/editor/state/LineBreak { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)Lcom/pasich/encly/editor/state/LineBreak; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/LineBreak;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/LineBreak; + public fun equals (Ljava/lang/Object;)Z + public final fun getHead ()Ljava/lang/String; + public final fun getLines ()Ljava/util/List; + public final fun getTail ()Ljava/lang/String; + public fun hashCode ()I + public final fun isEnter ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/ListEditingKt { + public static final fun listBackspaceAtStart (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;)Z + public static final fun listLineBreak (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Lcom/pasich/encly/editor/state/LineBreak;)Ljava/lang/String; + public static final fun listTypingStep (Ljava/util/List;Ljava/util/List;)Lcom/pasich/encly/editor/state/TypingStep; +} + +public final class com/pasich/encly/editor/state/ListNestingKt { + public static final fun canMoveListItem (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;I)Z + public static final fun canShiftListItem (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Z)Z + public static final fun interactedListItem (Lcom/pasich/encly/editor/state/BlockEditorState;)Lkotlin/Pair; + public static final fun moveListItem (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;I)Z + public static final fun setNestedListKind (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Lcom/pasich/encly/editor/model/BlockType;)Z + public static final fun shiftListItem (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Z)Z +} + +public final class com/pasich/encly/editor/state/NoteFind { + public static final field $stable I + public fun ()V + public final fun close ()V + public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun next ()V + public final fun open ()V + public final fun previous ()V + public final fun refresh (Ljava/util/List;)V + public final fun search (Ljava/lang/String;Ljava/util/List;)V +} + +public final class com/pasich/encly/editor/state/NoteTemplate : java/lang/Enum { + public static final field BLANK Lcom/pasich/encly/editor/state/NoteTemplate; + public static final field CHECKLIST Lcom/pasich/encly/editor/state/NoteTemplate; + public static final field JOURNAL Lcom/pasich/encly/editor/state/NoteTemplate; + public static final field MEETING Lcom/pasich/encly/editor/state/NoteTemplate; + public static final field PROJECT Lcom/pasich/encly/editor/state/NoteTemplate; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Lcom/pasich/encly/editor/EditorString; + public final fun getParts ()Ljava/util/List; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/NoteTemplate; + public static fun values ()[Lcom/pasich/encly/editor/state/NoteTemplate; +} + +public final class com/pasich/encly/editor/state/NoteTemplatesKt { + public static final fun applyTemplate (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/util/List;)Z + public static final fun blocks (Lcom/pasich/encly/editor/state/NoteTemplate;Lkotlin/jvm/functions/Function1;)Ljava/util/List; + public static final fun isBlankNote (Lcom/pasich/encly/editor/state/BlockEditorState;)Z +} + +public final class com/pasich/encly/editor/state/PasteEditingKt { + public static final fun pasteCopied (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/SelectedBlocks { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun clear ()V + public final fun extend (Z)V + public final fun getCount ()I + public final fun getIds ()Lkotlinx/coroutines/flow/StateFlow; + public final fun isActive ()Z + public final fun picked ()Ljava/util/List; + public final fun select (Ljava/lang/String;)V + public final fun selectAll ()V + public final fun toggle (Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/state/SelectedBlocksEditsKt { + public static final fun deleteSelectedBlocks (Lcom/pasich/encly/editor/state/BlockEditorState;)Z + public static final fun pickedPlainText (Lcom/pasich/encly/editor/state/BlockEditorState;)Ljava/lang/String; + public static final fun plainText (Ljava/util/List;)Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/Shortcut { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Z)V + public synthetic fun (Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun copy (Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Z)Lcom/pasich/encly/editor/state/Shortcut; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/Shortcut;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;ZILjava/lang/Object;)Lcom/pasich/encly/editor/state/Shortcut; + public fun equals (Ljava/lang/Object;)Z + public final fun getChecked ()Z + public final fun getMarker ()Ljava/lang/String; + public final fun getType ()Lcom/pasich/encly/editor/model/BlockType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/SlashCommand : java/lang/Enum { + public static final field BULLETS Lcom/pasich/encly/editor/state/SlashCommand; + public static final field CHECKLIST Lcom/pasich/encly/editor/state/SlashCommand; + public static final field CODE Lcom/pasich/encly/editor/state/SlashCommand; + public static final field DIVIDER Lcom/pasich/encly/editor/state/SlashCommand; + public static final field H1 Lcom/pasich/encly/editor/state/SlashCommand; + public static final field H2 Lcom/pasich/encly/editor/state/SlashCommand; + public static final field H3 Lcom/pasich/encly/editor/state/SlashCommand; + public static final field H4 Lcom/pasich/encly/editor/state/SlashCommand; + public static final field LINK Lcom/pasich/encly/editor/state/SlashCommand; + public static final field NUMBERED Lcom/pasich/encly/editor/state/SlashCommand; + public static final field QUOTE Lcom/pasich/encly/editor/state/SlashCommand; + public static final field TABLE Lcom/pasich/encly/editor/state/SlashCommand; + public static final field TEXT Lcom/pasich/encly/editor/state/SlashCommand; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/SlashCommand; + public static fun values ()[Lcom/pasich/encly/editor/state/SlashCommand; +} + +public final class com/pasich/encly/editor/state/SlashCommandsKt { + public static final fun applySlash (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Lcom/pasich/encly/editor/state/SlashCommand;)Z +} + +public final class com/pasich/encly/editor/state/SlashMenu { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;IZ)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()I + public final fun component5 ()Z + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;IZ)Lcom/pasich/encly/editor/state/SlashMenu; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/SlashMenu;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;IZILjava/lang/Object;)Lcom/pasich/encly/editor/state/SlashMenu; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockId ()Ljava/lang/String; + public final fun getCommitted ()Z + public final fun getHighlighted ()I + public final fun getHighlightedCommand ()Lcom/pasich/encly/editor/state/SlashCommand; + public final fun getMatches ()Ljava/util/List; + public final fun getQuery ()Ljava/lang/String; + public fun hashCode ()I + public final fun isShown ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/SlashPicker { + public static final field $stable I + public fun ()V + public final fun commit ()V + public final fun dismiss ()V + public final fun getMenu ()Lkotlinx/coroutines/flow/StateFlow; + public final fun moveHighlight (I)V + public final fun onText (Ljava/lang/String;Ljava/lang/String;)V + public final fun open (Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/state/TableEditingKt { + public static final fun tableTypingStep (Lcom/pasich/encly/editor/model/TableData;Lcom/pasich/encly/editor/model/TableData;)Lcom/pasich/encly/editor/state/TypingStep; +} + +public abstract interface class com/pasich/encly/editor/state/TemplatePart { +} + +public final class com/pasich/encly/editor/state/TemplatePart$Heading : com/pasich/encly/editor/state/TemplatePart { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/model/BlockType;Lcom/pasich/encly/editor/EditorString;)V + public final fun component1 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun component2 ()Lcom/pasich/encly/editor/EditorString; + public final fun copy (Lcom/pasich/encly/editor/model/BlockType;Lcom/pasich/encly/editor/EditorString;)Lcom/pasich/encly/editor/state/TemplatePart$Heading; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TemplatePart$Heading;Lcom/pasich/encly/editor/model/BlockType;Lcom/pasich/encly/editor/EditorString;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TemplatePart$Heading; + public fun equals (Ljava/lang/Object;)Z + public final fun getText ()Lcom/pasich/encly/editor/EditorString; + public final fun getType ()Lcom/pasich/encly/editor/model/BlockType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TemplatePart$ListOf : com/pasich/encly/editor/state/TemplatePart { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/model/BlockType;)V + public final fun component1 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun copy (Lcom/pasich/encly/editor/model/BlockType;)Lcom/pasich/encly/editor/state/TemplatePart$ListOf; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TemplatePart$ListOf;Lcom/pasich/encly/editor/model/BlockType;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TemplatePart$ListOf; + public fun equals (Ljava/lang/Object;)Z + public final fun getType ()Lcom/pasich/encly/editor/model/BlockType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TemplatePart$Paragraph : com/pasich/encly/editor/state/TemplatePart { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/state/TemplatePart$Paragraph; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TextDiff { + public static final field $stable I + public fun (ILjava/lang/String;Ljava/lang/String;)V + public final fun component1 ()I + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (ILjava/lang/String;Ljava/lang/String;)Lcom/pasich/encly/editor/state/TextDiff; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TextDiff;ILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TextDiff; + public fun equals (Ljava/lang/Object;)Z + public final fun getEnd ()I + public final fun getInserted ()Ljava/lang/String; + public final fun getRemoved ()Ljava/lang/String; + public final fun getStart ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TextEditsKt { + public static final fun textDiff (Ljava/lang/String;Ljava/lang/String;I)Lcom/pasich/encly/editor/state/TextDiff; + public static synthetic fun textDiff$default (Ljava/lang/String;Ljava/lang/String;IILjava/lang/Object;)Lcom/pasich/encly/editor/state/TextDiff; +} + +public final class com/pasich/encly/editor/state/TextPoint { + public static final field $stable I + public fun (Ljava/lang/String;ILjava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()I + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;ILjava/lang/String;)Lcom/pasich/encly/editor/state/TextPoint; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TextPoint;Ljava/lang/String;ILjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TextPoint; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockId ()Ljava/lang/String; + public final fun getItemId ()Ljava/lang/String; + public final fun getOffset ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TextRangeEnds { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;)V + public final fun component1 ()Lcom/pasich/encly/editor/state/TextPoint; + public final fun component2 ()Lcom/pasich/encly/editor/state/TextPoint; + public final fun copy (Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;)Lcom/pasich/encly/editor/state/TextRangeEnds; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TextRangeEnds;Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TextRangeEnds; + public fun equals (Ljava/lang/Object;)Z + public final fun getAnchor ()Lcom/pasich/encly/editor/state/TextPoint; + public final fun getHead ()Lcom/pasich/encly/editor/state/TextPoint; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TextRangeSelection { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun blockCount ()I + public final fun clear ()V + public final fun coverOf (Ljava/lang/String;Ljava/lang/String;I)Lkotlin/Pair; + public final fun extend (Lcom/pasich/encly/editor/state/HeadMove;Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;Z)Z + public final fun extendTo (Lcom/pasich/encly/editor/state/TextPoint;)V + public final fun getEnds ()Lkotlinx/coroutines/flow/StateFlow; + public final fun isActive ()Z + public final fun isCrossField ()Z + public final fun moveHead (Lcom/pasich/encly/editor/state/HeadMove;)V + public final fun orderedPoints ()Lkotlin/Pair; + public final fun select (Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;)V + public final fun selectAll ()V +} + +public final class com/pasich/encly/editor/state/TextRangeSelectionKt { + public static final fun canReplaceTextRange (Lcom/pasich/encly/editor/state/BlockEditorState;)Z + public static final fun replaceTextRange (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)Z + public static final fun textRangeBlocks (Lcom/pasich/encly/editor/state/BlockEditorState;)Ljava/util/List; + public static final fun textRangePlainText (Lcom/pasich/encly/editor/state/BlockEditorState;)Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TypingKind : java/lang/Enum { + public static final field DELETE Lcom/pasich/encly/editor/state/TypingKind; + public static final field INSERT Lcom/pasich/encly/editor/state/TypingKind; + public static final field REPLACE Lcom/pasich/encly/editor/state/TypingKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/TypingKind; + public static fun values ()[Lcom/pasich/encly/editor/state/TypingKind; +} + +public final class com/pasich/encly/editor/state/TypingStep { + public static final field $stable I + public fun (Ljava/lang/String;Lcom/pasich/encly/editor/state/TypingKind;Z)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lcom/pasich/encly/editor/state/TypingKind; + public final fun component3 ()Z + public final fun copy (Ljava/lang/String;Lcom/pasich/encly/editor/state/TypingKind;Z)Lcom/pasich/encly/editor/state/TypingStep; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TypingStep;Ljava/lang/String;Lcom/pasich/encly/editor/state/TypingKind;ZILjava/lang/Object;)Lcom/pasich/encly/editor/state/TypingStep; + public fun equals (Ljava/lang/Object;)Z + public final fun getField ()Ljava/lang/String; + public final fun getKind ()Lcom/pasich/encly/editor/state/TypingKind; + public final fun getStartsWord ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorFontFamilies { + public static final field $stable I + public fun ()V + public fun (Landroidx/compose/ui/text/font/FontFamily;Landroidx/compose/ui/text/font/FontFamily;)V + public synthetic fun (Landroidx/compose/ui/text/font/FontFamily;Landroidx/compose/ui/text/font/FontFamily;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Landroidx/compose/ui/text/font/FontFamily; + public final fun component2 ()Landroidx/compose/ui/text/font/FontFamily; + public final fun copy (Landroidx/compose/ui/text/font/FontFamily;Landroidx/compose/ui/text/font/FontFamily;)Lcom/pasich/encly/editor/theme/EditorFontFamilies; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/theme/EditorFontFamilies;Landroidx/compose/ui/text/font/FontFamily;Landroidx/compose/ui/text/font/FontFamily;ILjava/lang/Object;)Lcom/pasich/encly/editor/theme/EditorFontFamilies; + public fun equals (Ljava/lang/Object;)Z + public final fun getBody ()Landroidx/compose/ui/text/font/FontFamily; + public final fun getHeading ()Landroidx/compose/ui/text/font/FontFamily; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorFontSizes { + public static final field $stable I + public fun ()V + public synthetic fun (JJJJJJJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-XSAIIZE ()J + public final fun component2-XSAIIZE ()J + public final fun component3-XSAIIZE ()J + public final fun component4-XSAIIZE ()J + public final fun component5-XSAIIZE ()J + public final fun component6-XSAIIZE ()J + public final fun component7-XSAIIZE ()J + public final fun copy-TIfWF00 (JJJJJJJ)Lcom/pasich/encly/editor/theme/EditorFontSizes; + public static synthetic fun copy-TIfWF00$default (Lcom/pasich/encly/editor/theme/EditorFontSizes;JJJJJJJILjava/lang/Object;)Lcom/pasich/encly/editor/theme/EditorFontSizes; + public fun equals (Ljava/lang/Object;)Z + public final fun getH1-XSAIIZE ()J + public final fun getH2-XSAIIZE ()J + public final fun getH3-XSAIIZE ()J + public final fun getH4-XSAIIZE ()J + public final fun getList-XSAIIZE ()J + public final fun getQuote-XSAIIZE ()J + public final fun getTextBlock-XSAIIZE ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorIcons { + public static final field $stable I + public fun ()V + public fun (Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;)V + public synthetic fun (Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class com/pasich/encly/editor/theme/EditorLineSpacing : java/lang/Enum { + public static final field COMFORTABLE Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public static final field COMPACT Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public static final field DEFAULT Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public static fun values ()[Lcom/pasich/encly/editor/theme/EditorLineSpacing; +} + +public final class com/pasich/encly/editor/theme/EditorSpacing { + public static final field $stable I + public fun ()V + public synthetic fun (FFFFFFFFFFFFFFFFFFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (FFFFFFFFFFFFFFFFFFFFLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-D9Ej5fM ()F + public final fun component10-D9Ej5fM ()F + public final fun component11-D9Ej5fM ()F + public final fun component12-D9Ej5fM ()F + public final fun component13-D9Ej5fM ()F + public final fun component14-D9Ej5fM ()F + public final fun component15-D9Ej5fM ()F + public final fun component16-D9Ej5fM ()F + public final fun component17-D9Ej5fM ()F + public final fun component18-D9Ej5fM ()F + public final fun component19-D9Ej5fM ()F + public final fun component2-D9Ej5fM ()F + public final fun component20-D9Ej5fM ()F + public final fun component3-D9Ej5fM ()F + public final fun component4-D9Ej5fM ()F + public final fun component5-D9Ej5fM ()F + public final fun component6-D9Ej5fM ()F + public final fun component7-D9Ej5fM ()F + public final fun component8-D9Ej5fM ()F + public final fun component9-D9Ej5fM ()F + public final fun copy-DwpW0Ck (FFFFFFFFFFFFFFFFFFFF)Lcom/pasich/encly/editor/theme/EditorSpacing; + public static synthetic fun copy-DwpW0Ck$default (Lcom/pasich/encly/editor/theme/EditorSpacing;FFFFFFFFFFFFFFFFFFFFILjava/lang/Object;)Lcom/pasich/encly/editor/theme/EditorSpacing; + public fun equals (Ljava/lang/Object;)Z + public final fun getButtonHeight-D9Ej5fM ()F + public final fun getFieldGap-D9Ej5fM ()F + public final fun getGutter-D9Ej5fM ()F + public final fun getHairline-D9Ej5fM ()F + public final fun getIcon-D9Ej5fM ()F + public final fun getIconMedium-D9Ej5fM ()F + public final fun getIconSmall-D9Ej5fM ()F + public final fun getL-D9Ej5fM ()F + public final fun getLabelGap-D9Ej5fM ()F + public final fun getM-D9Ej5fM ()F + public final fun getMinTouchTarget-D9Ej5fM ()F + public final fun getRowGap-D9Ej5fM ()F + public final fun getRowHeight-D9Ej5fM ()F + public final fun getS-D9Ej5fM ()F + public final fun getStroke-D9Ej5fM ()F + public final fun getTapArea-D9Ej5fM ()F + public final fun getTextGap-D9Ej5fM ()F + public final fun getTileSmall-D9Ej5fM ()F + public final fun getXs-D9Ej5fM ()F + public final fun getXxs-D9Ej5fM ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorTheme { + public static final field $stable I + public fun ()V + public fun (Lcom/pasich/encly/editor/theme/EditorSpacing;Lcom/pasich/encly/editor/theme/EditorFontSizes;Lcom/pasich/encly/editor/theme/EditorFontFamilies;Lcom/pasich/encly/editor/theme/EditorLineSpacing;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Lcom/pasich/encly/editor/theme/EditorIcons;)V + public synthetic fun (Lcom/pasich/encly/editor/theme/EditorSpacing;Lcom/pasich/encly/editor/theme/EditorFontSizes;Lcom/pasich/encly/editor/theme/EditorFontFamilies;Lcom/pasich/encly/editor/theme/EditorLineSpacing;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Lcom/pasich/encly/editor/theme/EditorIcons;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/pasich/encly/editor/theme/EditorSpacing; + public final fun component2 ()Lcom/pasich/encly/editor/theme/EditorFontSizes; + public final fun component3 ()Lcom/pasich/encly/editor/theme/EditorFontFamilies; + public final fun component4 ()Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public final fun component5 ()Landroidx/compose/ui/text/TextStyle; + public final fun component6 ()Landroidx/compose/ui/text/TextStyle; + public final fun component7 ()Lcom/pasich/encly/editor/theme/EditorIcons; + public final fun copy (Lcom/pasich/encly/editor/theme/EditorSpacing;Lcom/pasich/encly/editor/theme/EditorFontSizes;Lcom/pasich/encly/editor/theme/EditorFontFamilies;Lcom/pasich/encly/editor/theme/EditorLineSpacing;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Lcom/pasich/encly/editor/theme/EditorIcons;)Lcom/pasich/encly/editor/theme/EditorTheme; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/theme/EditorTheme;Lcom/pasich/encly/editor/theme/EditorSpacing;Lcom/pasich/encly/editor/theme/EditorFontSizes;Lcom/pasich/encly/editor/theme/EditorFontFamilies;Lcom/pasich/encly/editor/theme/EditorLineSpacing;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Lcom/pasich/encly/editor/theme/EditorIcons;ILjava/lang/Object;)Lcom/pasich/encly/editor/theme/EditorTheme; + public fun equals (Ljava/lang/Object;)Z + public final fun getDataStyle ()Landroidx/compose/ui/text/TextStyle; + public final fun getFontFamilies ()Lcom/pasich/encly/editor/theme/EditorFontFamilies; + public final fun getFontSizes ()Lcom/pasich/encly/editor/theme/EditorFontSizes; + public final fun getIcons ()Lcom/pasich/encly/editor/theme/EditorIcons; + public final fun getLineSpacing ()Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public final fun getQuoteStyle ()Landroidx/compose/ui/text/TextStyle; + public final fun getSpacing ()Lcom/pasich/encly/editor/theme/EditorSpacing; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorThemeKt { + public static final fun getLocalEditorTheme ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + +public final class com/pasich/encly/editor/theme/NoteSpacingKt { + public static final fun blockGap-3ABfNKs (Lcom/pasich/encly/editor/theme/EditorLineSpacing;F)F + public static final fun isCompact (Lcom/pasich/encly/editor/theme/EditorLineSpacing;)Z +} + +public final class com/pasich/encly/editor/ui/BlockFocusHooksKt { + public static final fun rememberBlockFocusRegistry (Ljava/util/List;Landroidx/compose/runtime/Composer;I)Lcom/pasich/encly/editor/focus/BlockFocusRegistry; +} + +public final class com/pasich/encly/editor/ui/EditorBlocksHost { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/ui/EditorController;Lcom/pasich/encly/editor/focus/BlockFocusRegistry;Lcom/pasich/encly/editor/ui/FindHighlights;Lkotlin/jvm/functions/Function2;)V +} + +public final class com/pasich/encly/editor/ui/EditorBlocksHostKt { + public static final fun editorBlocks (Landroidx/compose/foundation/lazy/LazyListScope;Lcom/pasich/encly/editor/ui/EditorBlocksHost;Ljava/util/List;ZLjava/util/Set;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun editorBlocks$default (Landroidx/compose/foundation/lazy/LazyListScope;Lcom/pasich/encly/editor/ui/EditorBlocksHost;Ljava/util/List;ZLjava/util/Set;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V +} + +public abstract interface class com/pasich/encly/editor/ui/EditorController { + public abstract fun addBlockAfter (ILcom/pasich/encly/editor/model/BlockType;)V + public abstract fun addBlockToEnd ()V + public abstract fun applySlash (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/SlashCommand;)V + public abstract fun copyTextRange (Z)Z + public abstract fun cutTextRange ()Z + public abstract fun duplicateBlock (Lcom/pasich/encly/editor/model/Block;)V + public abstract fun editLink (Lcom/pasich/encly/editor/model/Block$LinkBlock;)V + public abstract fun getBlocks ()Ljava/util/List; + public abstract fun getInteractedBlockId ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getSelectedBlocks ()Lcom/pasich/encly/editor/state/SelectedBlocks; + public abstract fun getSlashPicker ()Lcom/pasich/encly/editor/state/SlashPicker; + public abstract fun getTextRange ()Lcom/pasich/encly/editor/state/TextRangeSelection; + public abstract fun indexOfBlock (Lcom/pasich/encly/editor/model/Block;)I + public abstract fun interactedBlockPosition ()I + public abstract fun moveBlock (Z)V + public abstract fun moveBlockOf (Lcom/pasich/encly/editor/model/Block;Z)V + public abstract fun onBackspaceAtStart (Lcom/pasich/encly/editor/model/Block;)Z + public abstract fun onBlockFocusLost (Lcom/pasich/encly/editor/model/Block;)V + public abstract fun onBlockFocused (Lcom/pasich/encly/editor/model/Block;)V + public abstract fun onBlockInteraction (Lcom/pasich/encly/editor/model/Block;)V + public abstract fun onBlockTextChanged (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;)V + public abstract fun onCaretMoved (Lcom/pasich/encly/editor/model/Block;I)V + public abstract fun onLineBreak (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/LineBreak;)Ljava/lang/String; + public abstract fun onLinkChanged (Lcom/pasich/encly/editor/model/Block$LinkBlock;Lcom/pasich/encly/editor/model/LinkDataBlock;)V + public abstract fun onLinkPasted (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Lcom/pasich/encly/editor/model/LinkDataBlock;)Z + public abstract fun onListBackspaceAtStart (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/lang/String;)Z + public abstract fun onListItemCaret (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;I)V + public abstract fun onListItemMove (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/lang/String;I)Z + public abstract fun onListItemShift (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/lang/String;Z)Z + public abstract fun onListItemsChanged (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/util/List;Z)V + public abstract fun onListLineBreak (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/lang/String;Lcom/pasich/encly/editor/state/LineBreak;)Ljava/lang/String; + public abstract fun onPasteCopied (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + public abstract fun onShortcut (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Lcom/pasich/encly/editor/state/Shortcut;)Z + public abstract fun onSlashTyped (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Z)V + public abstract fun onTableChanged (Lcom/pasich/encly/editor/model/Block$TableBlock;Lcom/pasich/encly/editor/model/TableData;Z)V + public abstract fun removeBlock (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/BlockRemoveAction;)V + public abstract fun replaceBlock (ILcom/pasich/encly/editor/model/Block;)Z + public abstract fun replaceTextRange (Ljava/lang/String;)Z + public abstract fun selectAllText ()Z + public abstract fun toggleChecklistLock (Lcom/pasich/encly/editor/model/Block;)V +} + +public final class com/pasich/encly/editor/ui/FindHighlights { + public static final field $stable I + public fun (Landroidx/compose/runtime/State;)V + public final fun reveal (Lcom/pasich/encly/editor/state/FindMatch;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/editor/ui/SelectedBlocksActions { + public static final field $stable I + public fun (ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public final fun getCanEdit ()Z + public final fun getOnCopy ()Lkotlin/jvm/functions/Function0; + public final fun getOnCopyMarkdown ()Lkotlin/jvm/functions/Function0; + public final fun getOnCut ()Lkotlin/jvm/functions/Function0; + public final fun getOnDelete ()Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/editor/ui/SelectedBlocksBarKt { + public static final fun SelectedBlocksBar (Lcom/pasich/encly/editor/state/SelectedBlocks;Lcom/pasich/encly/editor/ui/SelectedBlocksActions;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun TextRangeBar (Lcom/pasich/encly/editor/state/TextRangeSelection;Lcom/pasich/encly/editor/ui/SelectedBlocksActions;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/pasich/encly/editor/ui/TapBelowBlocksKt { + public static final fun rememberWriteAtEnd (Lcom/pasich/encly/editor/ui/EditorController;Landroidx/compose/runtime/Composer;I)Lkotlin/jvm/functions/Function0; + public static final fun tapBelowBlocks (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;ZLkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; +} + +public final class com/pasich/encly/editor/ui/TextRangeOverlayKt { + public static final fun TextRangeOverlay (Lcom/pasich/encly/editor/ui/EditorBlocksHost;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/pasich/encly/editor/ui/blocks/NoteLinkKt { + public static final fun isOpenableLink (Ljava/lang/String;)Z +} + +public final class com/pasich/encly/editor/ui/blocks/TextBlockKt { + public static final fun getWritingKeyboard ()Landroidx/compose/foundation/text/KeyboardOptions; +} + +public final class com/pasich/encly/editor/ui/blocks/TextSyncKt { + public static final fun rememberSyncedTextFieldState (Lkotlin/jvm/functions/Function0;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)Landroidx/compose/foundation/text/input/TextFieldState; +} + +public final class com/pasich/encly/editor/ui/components/ComposableSingletons$EditorSheetKt { + public static final field INSTANCE Lcom/pasich/encly/editor/ui/components/ComposableSingletons$EditorSheetKt; + public fun ()V + public final fun getLambda$1043769206$Encly_editor ()Lkotlin/jvm/functions/Function2; +} + +public final class com/pasich/encly/editor/ui/sheets/BlockSheetKind : java/lang/Enum { + public static final field LINK Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind; + public static final field OTHER Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind; + public static fun values ()[Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind; +} + +public final class com/pasich/encly/editor/ui/sheets/BlockSheetKindKt { + public static final fun EditorBlockSheet (Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind;Ljava/lang/String;ZLcom/pasich/encly/editor/ui/EditorController;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V +} + diff --git a/editor/api/desktop/editor.api b/editor/api/desktop/editor.api new file mode 100644 index 00000000..2e24bf83 --- /dev/null +++ b/editor/api/desktop/editor.api @@ -0,0 +1,1184 @@ +public abstract interface class com/pasich/encly/editor/EditorClipboard { + public static final field None Lcom/pasich/encly/editor/EditorClipboard$None; + public abstract fun copy (Ljava/lang/String;Ljava/lang/String;Z)V +} + +public final class com/pasich/encly/editor/EditorClipboard$None : com/pasich/encly/editor/EditorClipboard { + public fun copy (Ljava/lang/String;Ljava/lang/String;Z)V +} + +public abstract interface class com/pasich/encly/editor/EditorLogger { + public static final field None Lcom/pasich/encly/editor/EditorLogger$None; + public abstract fun warn (Ljava/lang/String;Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/EditorLogger$None : com/pasich/encly/editor/EditorLogger { + public fun warn (Ljava/lang/String;Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/EditorServicesKt { + public static final fun EditorProvider (Lcom/pasich/encly/editor/EditorStrings;Lcom/pasich/encly/editor/theme/EditorTheme;Lcom/pasich/encly/editor/EditorClipboard;Lcom/pasich/encly/editor/LinkOpener;Lcom/pasich/encly/editor/EditorLogger;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun getLocalEditorStrings ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + +public final class com/pasich/encly/editor/EditorString : java/lang/Enum { + public static final field BLOCK_BULLETED_LIST Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_CHECKLIST Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_CODE Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_DELETE Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_DUPLICATE Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_HEADING Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_HEADING_LEVEL Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_LINK Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_LIST Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_MOVE_DOWN Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_MOVE_UP Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_NUMBERED_LIST Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_SELECT Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_SEPARATOR Lcom/pasich/encly/editor/EditorString; + public static final field BLOCK_TABLE Lcom/pasich/encly/editor/EditorString; + public static final field CHECKLIST_LOCK Lcom/pasich/encly/editor/EditorString; + public static final field CHECKLIST_UNLOCK Lcom/pasich/encly/editor/EditorString; + public static final field ENTER_URL Lcom/pasich/encly/editor/EditorString; + public static final field LINK_ADD Lcom/pasich/encly/editor/EditorString; + public static final field LINK_BLOCKED Lcom/pasich/encly/editor/EditorString; + public static final field LINK_CONTENT_FAILED Lcom/pasich/encly/editor/EditorString; + public static final field LINK_COPY Lcom/pasich/encly/editor/EditorString; + public static final field LINK_EDIT Lcom/pasich/encly/editor/EditorString; + public static final field LINK_OPEN Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_INDENT Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_MOVE_DOWN Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_MOVE_UP Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_OUTDENT Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_PLACEHOLDER Lcom/pasich/encly/editor/EditorString; + public static final field LIST_ITEM_REORDER Lcom/pasich/encly/editor/EditorString; + public static final field MORE_OPTIONS Lcom/pasich/encly/editor/EditorString; + public static final field PRESS_TO_EDIT Lcom/pasich/encly/editor/EditorString; + public static final field QUOTE Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_CLOSE Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_COPY Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_COPY_MARKDOWN Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_COUNT Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_CUT Lcom/pasich/encly/editor/EditorString; + public static final field SELECTION_DELETE Lcom/pasich/encly/editor/EditorString; + public static final field SLASH_MENU_TITLE Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_ADD_COLUMN Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_ADD_ROW Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_CELL_OPTIONS Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_HEADER_ROW Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_REMOVE_COLUMN Lcom/pasich/encly/editor/EditorString; + public static final field TABLE_REMOVE_ROW Lcom/pasich/encly/editor/EditorString; + public static final field TAP_AGAIN_TO_DELETE Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_ACTION_ITEMS Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_AGENDA Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_ATTENDEES Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_BLANK Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_CHECKLIST_TODO Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_JOURNAL Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_JOURNAL_GRATITUDE Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_JOURNAL_TODAY Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_JOURNAL_TOMORROW Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_MEETING Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_NOTES Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT_DECISIONS Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT_GOAL Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT_QUESTIONS Lcom/pasich/encly/editor/EditorString; + public static final field TEMPLATE_PROJECT_TASKS Lcom/pasich/encly/editor/EditorString; + public static final field TEXT Lcom/pasich/encly/editor/EditorString; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/EditorString; + public static fun values ()[Lcom/pasich/encly/editor/EditorString; +} + +public abstract interface class com/pasich/encly/editor/EditorStrings { + public abstract fun heading (I)Ljava/lang/String; + public abstract fun selectionCount (I)Ljava/lang/String; + public abstract fun text (Lcom/pasich/encly/editor/EditorString;)Ljava/lang/String; +} + +public abstract interface class com/pasich/encly/editor/LinkOpener { + public static final field None Lcom/pasich/encly/editor/LinkOpener$None; + public abstract fun open (Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/LinkOpener$None : com/pasich/encly/editor/LinkOpener { + public fun open (Ljava/lang/String;)V +} + +public abstract interface class com/pasich/encly/editor/MarkdownBlockParser { + public abstract fun parsePaste (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/util/List; +} + +public final class com/pasich/encly/editor/codec/BlockConverter { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/codec/BlockConverter; + public final fun blocksToJson (Ljava/util/List;)Ljava/lang/String; + public final fun jsonToBlocks (Ljava/lang/String;)Ljava/util/List; + public final fun jsonToBlocksOrNull (Ljava/lang/String;Lcom/pasich/encly/editor/EditorLogger;)Ljava/util/List; + public static synthetic fun jsonToBlocksOrNull$default (Lcom/pasich/encly/editor/codec/BlockConverter;Ljava/lang/String;Lcom/pasich/encly/editor/EditorLogger;ILjava/lang/Object;)Ljava/util/List; +} + +public final class com/pasich/encly/editor/codec/ImportBlocks { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/codec/ImportBlocks; + public final fun code (Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block; + public final fun heading (Ljava/lang/String;I)Lcom/pasich/encly/editor/model/Block; + public final fun list (Lcom/pasich/encly/editor/model/BlockType;Ljava/util/List;)Lcom/pasich/encly/editor/model/Block; + public final fun paragraphs (Ljava/lang/String;)Ljava/util/List; + public final fun quote (Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block; + public final fun table (Ljava/util/List;Z)Lcom/pasich/encly/editor/model/Block; + public final fun text (Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block; +} + +public final class com/pasich/encly/editor/codec/InlineHtml { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/codec/InlineHtml; + public final fun toPlainText (Ljava/lang/String;Z)Ljava/lang/String; + public static synthetic fun toPlainText$default (Lcom/pasich/encly/editor/codec/InlineHtml;Ljava/lang/String;ZILjava/lang/Object;)Ljava/lang/String; +} + +public final class com/pasich/encly/editor/codec/MarkdownBlockMapper : com/pasich/encly/editor/MarkdownBlockParser { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/codec/MarkdownBlockMapper; + public fun parsePaste (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/util/List; + public final fun toBlocks (Ljava/lang/String;)Ljava/util/List; +} + +public final class com/pasich/encly/editor/focus/BlockFocusRegistry { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/EditorLogger;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lcom/pasich/encly/editor/EditorLogger;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun focus (Lcom/pasich/encly/editor/state/FocusRequest;)Z + public final fun focus (Ljava/lang/String;Z)Z + public static synthetic fun focus$default (Lcom/pasich/encly/editor/focus/BlockFocusRegistry;Ljava/lang/String;ZILjava/lang/Object;)Z + public final fun focusWhenComposed (Lcom/pasich/encly/editor/state/FocusRequest;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun focusWhenComposed$default (Lcom/pasich/encly/editor/focus/BlockFocusRegistry;Lcom/pasich/encly/editor/state/FocusRequest;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public abstract class com/pasich/encly/editor/model/Block { + public static final field $stable I + public abstract fun getId ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$CodeBlock : com/pasich/encly/editor/model/Block, com/pasich/encly/editor/model/TextualBlock { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$CodeBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$CodeBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$CodeBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$HBlock : com/pasich/encly/editor/model/Block, com/pasich/encly/editor/model/TextualBlock { + public static final field $stable I + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$HBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$HBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$HBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockType ()Lcom/pasich/encly/editor/model/BlockType; + public fun getId ()Ljava/lang/String; + public fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$LinkBlock : com/pasich/encly/editor/model/Block { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$LinkBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$LinkBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$LinkBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlock ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun getId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$ListBlock : com/pasich/encly/editor/model/Block { + public static final field $stable I + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;)Lcom/pasich/encly/editor/model/Block$ListBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$ListBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$ListBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockType ()Lcom/pasich/encly/editor/model/BlockType; + public fun getId ()Ljava/lang/String; + public final fun getItems ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun getLocked ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$QuoteBlock : com/pasich/encly/editor/model/Block, com/pasich/encly/editor/model/TextualBlock { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$QuoteBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$QuoteBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$QuoteBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$SeparatorBlock : com/pasich/encly/editor/model/Block { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$SeparatorBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$SeparatorBlock;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$SeparatorBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$TableBlock : com/pasich/encly/editor/model/Block { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$TableBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$TableBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$TableBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public final fun getTable ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/Block$TextBlock : com/pasich/encly/editor/model/Block, com/pasich/encly/editor/model/TextualBlock { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/EditorString;Ljava/lang/String;)V + public synthetic fun (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/EditorString;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun component2 ()Lcom/pasich/encly/editor/EditorString; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/EditorString;Ljava/lang/String;)Lcom/pasich/encly/editor/model/Block$TextBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/Block$TextBlock;Lkotlinx/coroutines/flow/MutableStateFlow;Lcom/pasich/encly/editor/EditorString;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/Block$TextBlock; + public fun equals (Ljava/lang/Object;)Z + public fun getId ()Ljava/lang/String; + public final fun getPlaceholder ()Lcom/pasich/encly/editor/EditorString; + public fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/BlockFactory { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/model/BlockFactory; + public final fun createBlock (Lcom/pasich/encly/editor/model/BlockType;)Lcom/pasich/encly/editor/model/Block; +} + +public final class com/pasich/encly/editor/model/BlockKt { + public static final fun hasContent (Lcom/pasich/encly/editor/model/Block;)Z +} + +public final class com/pasich/encly/editor/model/BlockType : java/lang/Enum { + public static final field CODE Lcom/pasich/encly/editor/model/BlockType; + public static final field H1 Lcom/pasich/encly/editor/model/BlockType; + public static final field H2 Lcom/pasich/encly/editor/model/BlockType; + public static final field H3 Lcom/pasich/encly/editor/model/BlockType; + public static final field H4 Lcom/pasich/encly/editor/model/BlockType; + public static final field LINK Lcom/pasich/encly/editor/model/BlockType; + public static final field LIST_BULLET Lcom/pasich/encly/editor/model/BlockType; + public static final field LIST_CHECK Lcom/pasich/encly/editor/model/BlockType; + public static final field LIST_NUMBER Lcom/pasich/encly/editor/model/BlockType; + public static final field QUOTE Lcom/pasich/encly/editor/model/BlockType; + public static final field SEPARATOR Lcom/pasich/encly/editor/model/BlockType; + public static final field TABLE Lcom/pasich/encly/editor/model/BlockType; + public static final field TEXT Lcom/pasich/encly/editor/model/BlockType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/model/BlockType; + public static fun values ()[Lcom/pasich/encly/editor/model/BlockType; +} + +public final class com/pasich/encly/editor/model/ItemListBlock { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;ZLjava/lang/String;ILcom/pasich/encly/editor/model/BlockType;)V + public synthetic fun (Ljava/lang/String;ZLjava/lang/String;ILcom/pasich/encly/editor/model/BlockType;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Z + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()I + public final fun component5 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun copy (Ljava/lang/String;ZLjava/lang/String;ILcom/pasich/encly/editor/model/BlockType;)Lcom/pasich/encly/editor/model/ItemListBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/ItemListBlock;Ljava/lang/String;ZLjava/lang/String;ILcom/pasich/encly/editor/model/BlockType;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/ItemListBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getKind ()Lcom/pasich/encly/editor/model/BlockType; + public final fun getLevel ()I + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCheck ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/LinkDataBlock { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)Lcom/pasich/encly/editor/model/LinkDataBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/LinkDataBlock;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lcom/pasich/encly/editor/model/LinkDataBlock; + public fun equals (Ljava/lang/Object;)Z + public final fun getImageUrl ()Ljava/lang/String; + public final fun getTitle ()Ljava/lang/String; + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public final fun isError ()Z + public final fun setError (Z)V + public final fun setImageUrl (Ljava/lang/String;)V + public final fun setTitle (Ljava/lang/String;)V + public final fun setUrl (Ljava/lang/String;)V + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/ListLevelsKt { + public static final field MAX_LIST_LEVEL I + public static final fun isListKind (Lcom/pasich/encly/editor/model/BlockType;)Z + public static final fun kindIn (Lcom/pasich/encly/editor/model/ItemListBlock;Lcom/pasich/encly/editor/model/BlockType;)Lcom/pasich/encly/editor/model/BlockType; + public static final fun normalizedLevels (Ljava/util/List;Lcom/pasich/encly/editor/model/BlockType;)Ljava/util/List; + public static final fun numbers (Ljava/util/List;Lcom/pasich/encly/editor/model/BlockType;)Ljava/util/List; + public static final fun siblingStarts (Ljava/util/List;I)Ljava/util/List; + public static final fun subtreeEnd (Ljava/util/List;I)I +} + +public final class com/pasich/encly/editor/model/ListMovesKt { + public static final fun movedBy (Ljava/util/List;II)Lkotlin/Pair; + public static final fun shifted (Ljava/util/List;IZLcom/pasich/encly/editor/model/BlockType;)Ljava/util/List; + public static final fun siblingOffsetAt (Ljava/util/List;ILjava/util/List;Ljava/util/List;F)I + public static final fun withSiblingsOfKind (Ljava/util/List;ILcom/pasich/encly/editor/model/BlockType;Lcom/pasich/encly/editor/model/BlockType;)Ljava/util/List; +} + +public final class com/pasich/encly/editor/model/TableCell { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/pasich/encly/editor/model/TableCell; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/TableCell;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableCell; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/model/TableData { + public static final field $stable I + public static final field Companion Lcom/pasich/encly/editor/model/TableData$Companion; + public fun (Ljava/util/List;Z)V + public synthetic fun (Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addColumn (I)Lcom/pasich/encly/editor/model/TableData; + public final fun addRow (I)Lcom/pasich/encly/editor/model/TableData; + public final fun cell (Ljava/lang/String;)Lcom/pasich/encly/editor/model/TableCell; + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Z + public final fun copy (Ljava/util/List;Z)Lcom/pasich/encly/editor/model/TableData; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/TableData;Ljava/util/List;ZILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableData; + public fun equals (Ljava/lang/Object;)Z + public final fun getColumnCount ()I + public final fun getHeader ()Z + public final fun getRowCount ()I + public final fun getRows ()Ljava/util/List; + public fun hashCode ()I + public final fun locate (Ljava/lang/String;)Lkotlin/Pair; + public final fun removeColumn (I)Lcom/pasich/encly/editor/model/TableData; + public final fun removeRow (I)Lcom/pasich/encly/editor/model/TableData; + public fun toString ()Ljava/lang/String; + public final fun valueAt (II)Ljava/lang/String; + public final fun withCell (Ljava/lang/String;Ljava/lang/String;)Lcom/pasich/encly/editor/model/TableData; + public final fun withHeader (Z)Lcom/pasich/encly/editor/model/TableData; +} + +public final class com/pasich/encly/editor/model/TableData$Companion { + public final fun empty (II)Lcom/pasich/encly/editor/model/TableData; + public static synthetic fun empty$default (Lcom/pasich/encly/editor/model/TableData$Companion;IIILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableData; + public final fun of (Ljava/util/List;Z)Lcom/pasich/encly/editor/model/TableData; + public static synthetic fun of$default (Lcom/pasich/encly/editor/model/TableData$Companion;Ljava/util/List;ZILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableData; +} + +public final class com/pasich/encly/editor/model/TableRow { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Lcom/pasich/encly/editor/model/TableRow; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/TableRow;Ljava/util/List;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/TableRow; + public fun equals (Ljava/lang/Object;)Z + public final fun getCells ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/pasich/encly/editor/model/TextualBlock { + public abstract fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; +} + +public final class com/pasich/encly/editor/state/BlockDuplicateKt { + public static final fun duplicateBlockOf (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)Z + public static final fun duplicateInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;)V +} + +public final class com/pasich/encly/editor/state/BlockEditingKt { + public static final fun applyShortcut (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Lcom/pasich/encly/editor/state/Shortcut;)Z + public static final fun backspaceAtStart (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)Z + public static final fun breakLine (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Lcom/pasich/encly/editor/state/LineBreak;)Ljava/lang/String; + public static final fun pasteLink (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Lcom/pasich/encly/editor/model/LinkDataBlock;)Z +} + +public final class com/pasich/encly/editor/state/BlockEditorState { + public static final field $stable I + public fun ()V + public fun (Lcom/pasich/encly/editor/MarkdownBlockParser;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lcom/pasich/encly/editor/MarkdownBlockParser;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addBlock (Lcom/pasich/encly/editor/model/Block;)V + public final fun changeValue (Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/Object;ZLcom/pasich/encly/editor/state/TypingStep;)V + public static synthetic fun changeValue$default (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Lkotlinx/coroutines/flow/MutableStateFlow;Ljava/lang/Object;ZLcom/pasich/encly/editor/state/TypingStep;ILjava/lang/Object;)V + public final fun getBlocks ()Ljava/util/List; + public final fun getCanRedo ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getCanUndo ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getContentChanges ()Lkotlinx/coroutines/flow/Flow; + public final fun getFind ()Lcom/pasich/encly/editor/state/NoteFind; + public final fun getMarkdown ()Lcom/pasich/encly/editor/MarkdownBlockParser; + public final fun getPickedBlocks ()Lcom/pasich/encly/editor/state/SelectedBlocks; + public final fun getSelection ()Lcom/pasich/encly/editor/state/BlockSelection; + public final fun getSlash ()Lcom/pasich/encly/editor/state/SlashPicker; + public final fun getTextRange ()Lcom/pasich/encly/editor/state/TextRangeSelection; + public final fun load (Ljava/util/List;)V + public final fun redo ()V + public final fun rememberCopied (Ljava/lang/String;Ljava/util/List;)V + public final fun removeBlock (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/BlockRemoveAction;Z)V + public static synthetic fun removeBlock$default (Lcom/pasich/encly/editor/state/BlockEditorState;Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/BlockRemoveAction;ZILjava/lang/Object;)V + public final fun replaceBlock (ILcom/pasich/encly/editor/model/Block;)Z + public final fun undo ()V +} + +public final class com/pasich/encly/editor/state/BlockEditorToolbarKt { + public static final fun addBlockAfter (Lcom/pasich/encly/editor/state/BlockEditorState;ILcom/pasich/encly/editor/model/Block;)V + public static final fun addBlockToEnd (Lcom/pasich/encly/editor/state/BlockEditorState;)V + public static final fun canMoveInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;Z)Z + public static final fun canRemoveInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;)Z + public static final fun indexOf (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)I + public static final fun interactedBlockPosition (Lcom/pasich/encly/editor/state/BlockEditorState;)I + public static final fun moveBlockOf (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Z)V + public static final fun moveInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;Z)V + public static final fun removeInteracted (Lcom/pasich/encly/editor/state/BlockEditorState;)V +} + +public final class com/pasich/encly/editor/state/BlockRemoveAction : java/lang/Enum { + public static final field REMOVE Lcom/pasich/encly/editor/state/BlockRemoveAction; + public static final field REMOVE_BACKSPACE Lcom/pasich/encly/editor/state/BlockRemoveAction; + public static final field REMOVE_BACKSPACE_LIST Lcom/pasich/encly/editor/state/BlockRemoveAction; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/BlockRemoveAction; + public static fun values ()[Lcom/pasich/encly/editor/state/BlockRemoveAction; +} + +public final class com/pasich/encly/editor/state/BlockSelection { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun getFocusedBlockId ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getImmediateFocus ()Lkotlin/jvm/functions/Function1; + public final fun getInteractedBlockId ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getInteractedIndex ()I + public final fun getRequests ()Lkotlinx/coroutines/flow/Flow; + public final fun onCaret (Ljava/lang/String;I)V + public final fun onFocusLost (Ljava/lang/String;)V + public final fun onFocused (Ljava/lang/String;)V + public final fun onInteraction (Ljava/lang/String;)V + public final fun onItemCaret (Ljava/lang/String;Ljava/lang/String;I)V + public final fun setImmediateFocus (Lkotlin/jvm/functions/Function1;)V +} + +public final class com/pasich/encly/editor/state/BlockToolsKt { + public static final fun applyTool (Lcom/pasich/encly/editor/state/BlockEditorState;Lcom/pasich/encly/editor/model/BlockType;Z)V + public static synthetic fun applyTool$default (Lcom/pasich/encly/editor/state/BlockEditorState;Lcom/pasich/encly/editor/model/BlockType;ZILjava/lang/Object;)V + public static final fun focusFirstBlock (Lcom/pasich/encly/editor/state/BlockEditorState;)V + public static final fun focusWorkingBlock (Lcom/pasich/encly/editor/state/BlockEditorState;)V + public static final fun getHEADING_TOOL ()Lcom/pasich/encly/editor/model/BlockType; + public static final fun toggleChecklistLock (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)Z + public static final fun toolType (Lcom/pasich/encly/editor/model/Block;)Lcom/pasich/encly/editor/model/BlockType; +} + +public final class com/pasich/encly/editor/state/FindMatch { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;II)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()I + public final fun component4 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;II)Lcom/pasich/encly/editor/state/FindMatch; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/FindMatch;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/Object;)Lcom/pasich/encly/editor/state/FindMatch; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockId ()Ljava/lang/String; + public final fun getEnd ()I + public final fun getField ()Ljava/lang/String; + public final fun getItemId ()Ljava/lang/String; + public final fun getStart ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/FindState { + public static final field $stable I + public fun ()V + public fun (ZLjava/lang/String;Ljava/util/List;I)V + public synthetic fun (ZLjava/lang/String;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()I + public final fun copy (ZLjava/lang/String;Ljava/util/List;I)Lcom/pasich/encly/editor/state/FindState; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/FindState;ZLjava/lang/String;Ljava/util/List;IILjava/lang/Object;)Lcom/pasich/encly/editor/state/FindState; + public fun equals (Ljava/lang/Object;)Z + public final fun getCurrent ()I + public final fun getCurrentMatch ()Lcom/pasich/encly/editor/state/FindMatch; + public final fun getMatches ()Ljava/util/List; + public final fun getQuery ()Ljava/lang/String; + public fun hashCode ()I + public final fun isOpen ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/FocusRequest { + public static final field $stable I + public fun (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;Ljava/lang/Float;Z)V + public synthetic fun (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;Ljava/lang/Float;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Z + public final fun component3 ()Ljava/lang/Integer; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/Float; + public final fun component6 ()Z + public final fun copy (Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;Ljava/lang/Float;Z)Lcom/pasich/encly/editor/state/FocusRequest; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/FocusRequest;Ljava/lang/String;ZLjava/lang/Integer;Ljava/lang/String;Ljava/lang/Float;ZILjava/lang/Object;)Lcom/pasich/encly/editor/state/FocusRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockId ()Ljava/lang/String; + public final fun getCaret ()Ljava/lang/Integer; + public final fun getCaretOffset ()Ljava/lang/Integer; + public final fun getCaretX ()Ljava/lang/Float; + public final fun getCursorToEnd ()Z + public final fun getFromBelow ()Z + public final fun getItemId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/HeadMove : java/lang/Enum { + public static final field DOWN Lcom/pasich/encly/editor/state/HeadMove; + public static final field FIELD_END Lcom/pasich/encly/editor/state/HeadMove; + public static final field FIELD_START Lcom/pasich/encly/editor/state/HeadMove; + public static final field NEXT Lcom/pasich/encly/editor/state/HeadMove; + public static final field NOTE_END Lcom/pasich/encly/editor/state/HeadMove; + public static final field NOTE_START Lcom/pasich/encly/editor/state/HeadMove; + public static final field PAGE_DOWN Lcom/pasich/encly/editor/state/HeadMove; + public static final field PAGE_UP Lcom/pasich/encly/editor/state/HeadMove; + public static final field PREVIOUS Lcom/pasich/encly/editor/state/HeadMove; + public static final field UP Lcom/pasich/encly/editor/state/HeadMove; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/HeadMove; + public static fun values ()[Lcom/pasich/encly/editor/state/HeadMove; +} + +public final class com/pasich/encly/editor/state/LineBreak { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)Lcom/pasich/encly/editor/state/LineBreak; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/LineBreak;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/LineBreak; + public fun equals (Ljava/lang/Object;)Z + public final fun getHead ()Ljava/lang/String; + public final fun getLines ()Ljava/util/List; + public final fun getTail ()Ljava/lang/String; + public fun hashCode ()I + public final fun isEnter ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/ListEditingKt { + public static final fun listBackspaceAtStart (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;)Z + public static final fun listLineBreak (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Lcom/pasich/encly/editor/state/LineBreak;)Ljava/lang/String; + public static final fun listTypingStep (Ljava/util/List;Ljava/util/List;)Lcom/pasich/encly/editor/state/TypingStep; +} + +public final class com/pasich/encly/editor/state/ListNestingKt { + public static final fun canMoveListItem (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;I)Z + public static final fun canShiftListItem (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Z)Z + public static final fun interactedListItem (Lcom/pasich/encly/editor/state/BlockEditorState;)Lkotlin/Pair; + public static final fun moveListItem (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;I)Z + public static final fun setNestedListKind (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Lcom/pasich/encly/editor/model/BlockType;)Z + public static final fun shiftListItem (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Z)Z +} + +public final class com/pasich/encly/editor/state/NoteFind { + public static final field $stable I + public fun ()V + public final fun close ()V + public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun next ()V + public final fun open ()V + public final fun previous ()V + public final fun refresh (Ljava/util/List;)V + public final fun search (Ljava/lang/String;Ljava/util/List;)V +} + +public final class com/pasich/encly/editor/state/NoteTemplate : java/lang/Enum { + public static final field BLANK Lcom/pasich/encly/editor/state/NoteTemplate; + public static final field CHECKLIST Lcom/pasich/encly/editor/state/NoteTemplate; + public static final field JOURNAL Lcom/pasich/encly/editor/state/NoteTemplate; + public static final field MEETING Lcom/pasich/encly/editor/state/NoteTemplate; + public static final field PROJECT Lcom/pasich/encly/editor/state/NoteTemplate; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Lcom/pasich/encly/editor/EditorString; + public final fun getParts ()Ljava/util/List; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/NoteTemplate; + public static fun values ()[Lcom/pasich/encly/editor/state/NoteTemplate; +} + +public final class com/pasich/encly/editor/state/NoteTemplatesKt { + public static final fun applyTemplate (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/util/List;)Z + public static final fun blocks (Lcom/pasich/encly/editor/state/NoteTemplate;Lkotlin/jvm/functions/Function1;)Ljava/util/List; + public static final fun isBlankNote (Lcom/pasich/encly/editor/state/BlockEditorState;)Z +} + +public final class com/pasich/encly/editor/state/PasteEditingKt { + public static final fun pasteCopied (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/SelectedBlocks { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun clear ()V + public final fun extend (Z)V + public final fun getCount ()I + public final fun getIds ()Lkotlinx/coroutines/flow/StateFlow; + public final fun isActive ()Z + public final fun picked ()Ljava/util/List; + public final fun select (Ljava/lang/String;)V + public final fun selectAll ()V + public final fun toggle (Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/state/SelectedBlocksEditsKt { + public static final fun deleteSelectedBlocks (Lcom/pasich/encly/editor/state/BlockEditorState;)Z + public static final fun pickedPlainText (Lcom/pasich/encly/editor/state/BlockEditorState;)Ljava/lang/String; + public static final fun plainText (Ljava/util/List;)Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/Shortcut { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Z)V + public synthetic fun (Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun copy (Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;Z)Lcom/pasich/encly/editor/state/Shortcut; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/Shortcut;Lcom/pasich/encly/editor/model/BlockType;Ljava/lang/String;ZILjava/lang/Object;)Lcom/pasich/encly/editor/state/Shortcut; + public fun equals (Ljava/lang/Object;)Z + public final fun getChecked ()Z + public final fun getMarker ()Ljava/lang/String; + public final fun getType ()Lcom/pasich/encly/editor/model/BlockType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/SlashCommand : java/lang/Enum { + public static final field BULLETS Lcom/pasich/encly/editor/state/SlashCommand; + public static final field CHECKLIST Lcom/pasich/encly/editor/state/SlashCommand; + public static final field CODE Lcom/pasich/encly/editor/state/SlashCommand; + public static final field DIVIDER Lcom/pasich/encly/editor/state/SlashCommand; + public static final field H1 Lcom/pasich/encly/editor/state/SlashCommand; + public static final field H2 Lcom/pasich/encly/editor/state/SlashCommand; + public static final field H3 Lcom/pasich/encly/editor/state/SlashCommand; + public static final field H4 Lcom/pasich/encly/editor/state/SlashCommand; + public static final field LINK Lcom/pasich/encly/editor/state/SlashCommand; + public static final field NUMBERED Lcom/pasich/encly/editor/state/SlashCommand; + public static final field QUOTE Lcom/pasich/encly/editor/state/SlashCommand; + public static final field TABLE Lcom/pasich/encly/editor/state/SlashCommand; + public static final field TEXT Lcom/pasich/encly/editor/state/SlashCommand; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/SlashCommand; + public static fun values ()[Lcom/pasich/encly/editor/state/SlashCommand; +} + +public final class com/pasich/encly/editor/state/SlashCommandsKt { + public static final fun applySlash (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;Lcom/pasich/encly/editor/state/SlashCommand;)Z +} + +public final class com/pasich/encly/editor/state/SlashMenu { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;IZ)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()I + public final fun component5 ()Z + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;IZ)Lcom/pasich/encly/editor/state/SlashMenu; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/SlashMenu;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;IZILjava/lang/Object;)Lcom/pasich/encly/editor/state/SlashMenu; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockId ()Ljava/lang/String; + public final fun getCommitted ()Z + public final fun getHighlighted ()I + public final fun getHighlightedCommand ()Lcom/pasich/encly/editor/state/SlashCommand; + public final fun getMatches ()Ljava/util/List; + public final fun getQuery ()Ljava/lang/String; + public fun hashCode ()I + public final fun isShown ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/SlashPicker { + public static final field $stable I + public fun ()V + public final fun commit ()V + public final fun dismiss ()V + public final fun getMenu ()Lkotlinx/coroutines/flow/StateFlow; + public final fun moveHighlight (I)V + public final fun onText (Ljava/lang/String;Ljava/lang/String;)V + public final fun open (Ljava/lang/String;)V +} + +public final class com/pasich/encly/editor/state/TableEditingKt { + public static final fun tableTypingStep (Lcom/pasich/encly/editor/model/TableData;Lcom/pasich/encly/editor/model/TableData;)Lcom/pasich/encly/editor/state/TypingStep; +} + +public abstract interface class com/pasich/encly/editor/state/TemplatePart { +} + +public final class com/pasich/encly/editor/state/TemplatePart$Heading : com/pasich/encly/editor/state/TemplatePart { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/model/BlockType;Lcom/pasich/encly/editor/EditorString;)V + public final fun component1 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun component2 ()Lcom/pasich/encly/editor/EditorString; + public final fun copy (Lcom/pasich/encly/editor/model/BlockType;Lcom/pasich/encly/editor/EditorString;)Lcom/pasich/encly/editor/state/TemplatePart$Heading; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TemplatePart$Heading;Lcom/pasich/encly/editor/model/BlockType;Lcom/pasich/encly/editor/EditorString;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TemplatePart$Heading; + public fun equals (Ljava/lang/Object;)Z + public final fun getText ()Lcom/pasich/encly/editor/EditorString; + public final fun getType ()Lcom/pasich/encly/editor/model/BlockType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TemplatePart$ListOf : com/pasich/encly/editor/state/TemplatePart { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/model/BlockType;)V + public final fun component1 ()Lcom/pasich/encly/editor/model/BlockType; + public final fun copy (Lcom/pasich/encly/editor/model/BlockType;)Lcom/pasich/encly/editor/state/TemplatePart$ListOf; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TemplatePart$ListOf;Lcom/pasich/encly/editor/model/BlockType;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TemplatePart$ListOf; + public fun equals (Ljava/lang/Object;)Z + public final fun getType ()Lcom/pasich/encly/editor/model/BlockType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TemplatePart$Paragraph : com/pasich/encly/editor/state/TemplatePart { + public static final field $stable I + public static final field INSTANCE Lcom/pasich/encly/editor/state/TemplatePart$Paragraph; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TextDiff { + public static final field $stable I + public fun (ILjava/lang/String;Ljava/lang/String;)V + public final fun component1 ()I + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (ILjava/lang/String;Ljava/lang/String;)Lcom/pasich/encly/editor/state/TextDiff; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TextDiff;ILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TextDiff; + public fun equals (Ljava/lang/Object;)Z + public final fun getEnd ()I + public final fun getInserted ()Ljava/lang/String; + public final fun getRemoved ()Ljava/lang/String; + public final fun getStart ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TextEditsKt { + public static final fun textDiff (Ljava/lang/String;Ljava/lang/String;I)Lcom/pasich/encly/editor/state/TextDiff; + public static synthetic fun textDiff$default (Ljava/lang/String;Ljava/lang/String;IILjava/lang/Object;)Lcom/pasich/encly/editor/state/TextDiff; +} + +public final class com/pasich/encly/editor/state/TextPoint { + public static final field $stable I + public fun (Ljava/lang/String;ILjava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()I + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;ILjava/lang/String;)Lcom/pasich/encly/editor/state/TextPoint; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TextPoint;Ljava/lang/String;ILjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TextPoint; + public fun equals (Ljava/lang/Object;)Z + public final fun getBlockId ()Ljava/lang/String; + public final fun getItemId ()Ljava/lang/String; + public final fun getOffset ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TextRangeEnds { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;)V + public final fun component1 ()Lcom/pasich/encly/editor/state/TextPoint; + public final fun component2 ()Lcom/pasich/encly/editor/state/TextPoint; + public final fun copy (Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;)Lcom/pasich/encly/editor/state/TextRangeEnds; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TextRangeEnds;Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;ILjava/lang/Object;)Lcom/pasich/encly/editor/state/TextRangeEnds; + public fun equals (Ljava/lang/Object;)Z + public final fun getAnchor ()Lcom/pasich/encly/editor/state/TextPoint; + public final fun getHead ()Lcom/pasich/encly/editor/state/TextPoint; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TextRangeSelection { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun blockCount ()I + public final fun clear ()V + public final fun coverOf (Ljava/lang/String;Ljava/lang/String;I)Lkotlin/Pair; + public final fun extend (Lcom/pasich/encly/editor/state/HeadMove;Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;Z)Z + public final fun extendTo (Lcom/pasich/encly/editor/state/TextPoint;)V + public final fun getEnds ()Lkotlinx/coroutines/flow/StateFlow; + public final fun isActive ()Z + public final fun isCrossField ()Z + public final fun moveHead (Lcom/pasich/encly/editor/state/HeadMove;)V + public final fun orderedPoints ()Lkotlin/Pair; + public final fun select (Lcom/pasich/encly/editor/state/TextPoint;Lcom/pasich/encly/editor/state/TextPoint;)V + public final fun selectAll ()V +} + +public final class com/pasich/encly/editor/state/TextRangeSelectionKt { + public static final fun canReplaceTextRange (Lcom/pasich/encly/editor/state/BlockEditorState;)Z + public static final fun replaceTextRange (Lcom/pasich/encly/editor/state/BlockEditorState;Ljava/lang/String;)Z + public static final fun textRangeBlocks (Lcom/pasich/encly/editor/state/BlockEditorState;)Ljava/util/List; + public static final fun textRangePlainText (Lcom/pasich/encly/editor/state/BlockEditorState;)Ljava/lang/String; +} + +public final class com/pasich/encly/editor/state/TypingKind : java/lang/Enum { + public static final field DELETE Lcom/pasich/encly/editor/state/TypingKind; + public static final field INSERT Lcom/pasich/encly/editor/state/TypingKind; + public static final field REPLACE Lcom/pasich/encly/editor/state/TypingKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/state/TypingKind; + public static fun values ()[Lcom/pasich/encly/editor/state/TypingKind; +} + +public final class com/pasich/encly/editor/state/TypingStep { + public static final field $stable I + public fun (Ljava/lang/String;Lcom/pasich/encly/editor/state/TypingKind;Z)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lcom/pasich/encly/editor/state/TypingKind; + public final fun component3 ()Z + public final fun copy (Ljava/lang/String;Lcom/pasich/encly/editor/state/TypingKind;Z)Lcom/pasich/encly/editor/state/TypingStep; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/state/TypingStep;Ljava/lang/String;Lcom/pasich/encly/editor/state/TypingKind;ZILjava/lang/Object;)Lcom/pasich/encly/editor/state/TypingStep; + public fun equals (Ljava/lang/Object;)Z + public final fun getField ()Ljava/lang/String; + public final fun getKind ()Lcom/pasich/encly/editor/state/TypingKind; + public final fun getStartsWord ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorFontFamilies { + public static final field $stable I + public fun ()V + public fun (Landroidx/compose/ui/text/font/FontFamily;Landroidx/compose/ui/text/font/FontFamily;)V + public synthetic fun (Landroidx/compose/ui/text/font/FontFamily;Landroidx/compose/ui/text/font/FontFamily;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Landroidx/compose/ui/text/font/FontFamily; + public final fun component2 ()Landroidx/compose/ui/text/font/FontFamily; + public final fun copy (Landroidx/compose/ui/text/font/FontFamily;Landroidx/compose/ui/text/font/FontFamily;)Lcom/pasich/encly/editor/theme/EditorFontFamilies; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/theme/EditorFontFamilies;Landroidx/compose/ui/text/font/FontFamily;Landroidx/compose/ui/text/font/FontFamily;ILjava/lang/Object;)Lcom/pasich/encly/editor/theme/EditorFontFamilies; + public fun equals (Ljava/lang/Object;)Z + public final fun getBody ()Landroidx/compose/ui/text/font/FontFamily; + public final fun getHeading ()Landroidx/compose/ui/text/font/FontFamily; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorFontSizes { + public static final field $stable I + public fun ()V + public synthetic fun (JJJJJJJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-XSAIIZE ()J + public final fun component2-XSAIIZE ()J + public final fun component3-XSAIIZE ()J + public final fun component4-XSAIIZE ()J + public final fun component5-XSAIIZE ()J + public final fun component6-XSAIIZE ()J + public final fun component7-XSAIIZE ()J + public final fun copy-TIfWF00 (JJJJJJJ)Lcom/pasich/encly/editor/theme/EditorFontSizes; + public static synthetic fun copy-TIfWF00$default (Lcom/pasich/encly/editor/theme/EditorFontSizes;JJJJJJJILjava/lang/Object;)Lcom/pasich/encly/editor/theme/EditorFontSizes; + public fun equals (Ljava/lang/Object;)Z + public final fun getH1-XSAIIZE ()J + public final fun getH2-XSAIIZE ()J + public final fun getH3-XSAIIZE ()J + public final fun getH4-XSAIIZE ()J + public final fun getList-XSAIIZE ()J + public final fun getQuote-XSAIIZE ()J + public final fun getTextBlock-XSAIIZE ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorIcons { + public static final field $stable I + public fun ()V + public fun (Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;)V + public synthetic fun (Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/vector/ImageVector;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class com/pasich/encly/editor/theme/EditorLineSpacing : java/lang/Enum { + public static final field COMFORTABLE Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public static final field COMPACT Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public static final field DEFAULT Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public static fun values ()[Lcom/pasich/encly/editor/theme/EditorLineSpacing; +} + +public final class com/pasich/encly/editor/theme/EditorSpacing { + public static final field $stable I + public fun ()V + public synthetic fun (FFFFFFFFFFFFFFFFFFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (FFFFFFFFFFFFFFFFFFFFLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-D9Ej5fM ()F + public final fun component10-D9Ej5fM ()F + public final fun component11-D9Ej5fM ()F + public final fun component12-D9Ej5fM ()F + public final fun component13-D9Ej5fM ()F + public final fun component14-D9Ej5fM ()F + public final fun component15-D9Ej5fM ()F + public final fun component16-D9Ej5fM ()F + public final fun component17-D9Ej5fM ()F + public final fun component18-D9Ej5fM ()F + public final fun component19-D9Ej5fM ()F + public final fun component2-D9Ej5fM ()F + public final fun component20-D9Ej5fM ()F + public final fun component3-D9Ej5fM ()F + public final fun component4-D9Ej5fM ()F + public final fun component5-D9Ej5fM ()F + public final fun component6-D9Ej5fM ()F + public final fun component7-D9Ej5fM ()F + public final fun component8-D9Ej5fM ()F + public final fun component9-D9Ej5fM ()F + public final fun copy-DwpW0Ck (FFFFFFFFFFFFFFFFFFFF)Lcom/pasich/encly/editor/theme/EditorSpacing; + public static synthetic fun copy-DwpW0Ck$default (Lcom/pasich/encly/editor/theme/EditorSpacing;FFFFFFFFFFFFFFFFFFFFILjava/lang/Object;)Lcom/pasich/encly/editor/theme/EditorSpacing; + public fun equals (Ljava/lang/Object;)Z + public final fun getButtonHeight-D9Ej5fM ()F + public final fun getFieldGap-D9Ej5fM ()F + public final fun getGutter-D9Ej5fM ()F + public final fun getHairline-D9Ej5fM ()F + public final fun getIcon-D9Ej5fM ()F + public final fun getIconMedium-D9Ej5fM ()F + public final fun getIconSmall-D9Ej5fM ()F + public final fun getL-D9Ej5fM ()F + public final fun getLabelGap-D9Ej5fM ()F + public final fun getM-D9Ej5fM ()F + public final fun getMinTouchTarget-D9Ej5fM ()F + public final fun getRowGap-D9Ej5fM ()F + public final fun getRowHeight-D9Ej5fM ()F + public final fun getS-D9Ej5fM ()F + public final fun getStroke-D9Ej5fM ()F + public final fun getTapArea-D9Ej5fM ()F + public final fun getTextGap-D9Ej5fM ()F + public final fun getTileSmall-D9Ej5fM ()F + public final fun getXs-D9Ej5fM ()F + public final fun getXxs-D9Ej5fM ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorTheme { + public static final field $stable I + public fun ()V + public fun (Lcom/pasich/encly/editor/theme/EditorSpacing;Lcom/pasich/encly/editor/theme/EditorFontSizes;Lcom/pasich/encly/editor/theme/EditorFontFamilies;Lcom/pasich/encly/editor/theme/EditorLineSpacing;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Lcom/pasich/encly/editor/theme/EditorIcons;)V + public synthetic fun (Lcom/pasich/encly/editor/theme/EditorSpacing;Lcom/pasich/encly/editor/theme/EditorFontSizes;Lcom/pasich/encly/editor/theme/EditorFontFamilies;Lcom/pasich/encly/editor/theme/EditorLineSpacing;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Lcom/pasich/encly/editor/theme/EditorIcons;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/pasich/encly/editor/theme/EditorSpacing; + public final fun component2 ()Lcom/pasich/encly/editor/theme/EditorFontSizes; + public final fun component3 ()Lcom/pasich/encly/editor/theme/EditorFontFamilies; + public final fun component4 ()Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public final fun component5 ()Landroidx/compose/ui/text/TextStyle; + public final fun component6 ()Landroidx/compose/ui/text/TextStyle; + public final fun component7 ()Lcom/pasich/encly/editor/theme/EditorIcons; + public final fun copy (Lcom/pasich/encly/editor/theme/EditorSpacing;Lcom/pasich/encly/editor/theme/EditorFontSizes;Lcom/pasich/encly/editor/theme/EditorFontFamilies;Lcom/pasich/encly/editor/theme/EditorLineSpacing;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Lcom/pasich/encly/editor/theme/EditorIcons;)Lcom/pasich/encly/editor/theme/EditorTheme; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/theme/EditorTheme;Lcom/pasich/encly/editor/theme/EditorSpacing;Lcom/pasich/encly/editor/theme/EditorFontSizes;Lcom/pasich/encly/editor/theme/EditorFontFamilies;Lcom/pasich/encly/editor/theme/EditorLineSpacing;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Lcom/pasich/encly/editor/theme/EditorIcons;ILjava/lang/Object;)Lcom/pasich/encly/editor/theme/EditorTheme; + public fun equals (Ljava/lang/Object;)Z + public final fun getDataStyle ()Landroidx/compose/ui/text/TextStyle; + public final fun getFontFamilies ()Lcom/pasich/encly/editor/theme/EditorFontFamilies; + public final fun getFontSizes ()Lcom/pasich/encly/editor/theme/EditorFontSizes; + public final fun getIcons ()Lcom/pasich/encly/editor/theme/EditorIcons; + public final fun getLineSpacing ()Lcom/pasich/encly/editor/theme/EditorLineSpacing; + public final fun getQuoteStyle ()Landroidx/compose/ui/text/TextStyle; + public final fun getSpacing ()Lcom/pasich/encly/editor/theme/EditorSpacing; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/pasich/encly/editor/theme/EditorThemeKt { + public static final fun getLocalEditorTheme ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + +public final class com/pasich/encly/editor/theme/NoteSpacingKt { + public static final fun blockGap-3ABfNKs (Lcom/pasich/encly/editor/theme/EditorLineSpacing;F)F + public static final fun isCompact (Lcom/pasich/encly/editor/theme/EditorLineSpacing;)Z +} + +public final class com/pasich/encly/editor/ui/BlockFocusHooksKt { + public static final fun rememberBlockFocusRegistry (Ljava/util/List;Landroidx/compose/runtime/Composer;I)Lcom/pasich/encly/editor/focus/BlockFocusRegistry; +} + +public final class com/pasich/encly/editor/ui/EditorBlocksHost { + public static final field $stable I + public fun (Lcom/pasich/encly/editor/ui/EditorController;Lcom/pasich/encly/editor/focus/BlockFocusRegistry;Lcom/pasich/encly/editor/ui/FindHighlights;Lkotlin/jvm/functions/Function2;)V +} + +public final class com/pasich/encly/editor/ui/EditorBlocksHostKt { + public static final fun editorBlocks (Landroidx/compose/foundation/lazy/LazyListScope;Lcom/pasich/encly/editor/ui/EditorBlocksHost;Ljava/util/List;ZLjava/util/Set;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun editorBlocks$default (Landroidx/compose/foundation/lazy/LazyListScope;Lcom/pasich/encly/editor/ui/EditorBlocksHost;Ljava/util/List;ZLjava/util/Set;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V +} + +public abstract interface class com/pasich/encly/editor/ui/EditorController { + public abstract fun addBlockAfter (ILcom/pasich/encly/editor/model/BlockType;)V + public abstract fun addBlockToEnd ()V + public abstract fun applySlash (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/SlashCommand;)V + public abstract fun copyTextRange (Z)Z + public abstract fun cutTextRange ()Z + public abstract fun duplicateBlock (Lcom/pasich/encly/editor/model/Block;)V + public abstract fun editLink (Lcom/pasich/encly/editor/model/Block$LinkBlock;)V + public abstract fun getBlocks ()Ljava/util/List; + public abstract fun getInteractedBlockId ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getSelectedBlocks ()Lcom/pasich/encly/editor/state/SelectedBlocks; + public abstract fun getSlashPicker ()Lcom/pasich/encly/editor/state/SlashPicker; + public abstract fun getTextRange ()Lcom/pasich/encly/editor/state/TextRangeSelection; + public abstract fun indexOfBlock (Lcom/pasich/encly/editor/model/Block;)I + public abstract fun interactedBlockPosition ()I + public abstract fun moveBlock (Z)V + public abstract fun moveBlockOf (Lcom/pasich/encly/editor/model/Block;Z)V + public abstract fun onBackspaceAtStart (Lcom/pasich/encly/editor/model/Block;)Z + public abstract fun onBlockFocusLost (Lcom/pasich/encly/editor/model/Block;)V + public abstract fun onBlockFocused (Lcom/pasich/encly/editor/model/Block;)V + public abstract fun onBlockInteraction (Lcom/pasich/encly/editor/model/Block;)V + public abstract fun onBlockTextChanged (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;)V + public abstract fun onCaretMoved (Lcom/pasich/encly/editor/model/Block;I)V + public abstract fun onLineBreak (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/LineBreak;)Ljava/lang/String; + public abstract fun onLinkChanged (Lcom/pasich/encly/editor/model/Block$LinkBlock;Lcom/pasich/encly/editor/model/LinkDataBlock;)V + public abstract fun onLinkPasted (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Lcom/pasich/encly/editor/model/LinkDataBlock;)Z + public abstract fun onListBackspaceAtStart (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/lang/String;)Z + public abstract fun onListItemCaret (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;I)V + public abstract fun onListItemMove (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/lang/String;I)Z + public abstract fun onListItemShift (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/lang/String;Z)Z + public abstract fun onListItemsChanged (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/util/List;Z)V + public abstract fun onListLineBreak (Lcom/pasich/encly/editor/model/Block$ListBlock;Ljava/lang/String;Lcom/pasich/encly/editor/state/LineBreak;)Ljava/lang/String; + public abstract fun onPasteCopied (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + public abstract fun onShortcut (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Lcom/pasich/encly/editor/state/Shortcut;)Z + public abstract fun onSlashTyped (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Z)V + public abstract fun onTableChanged (Lcom/pasich/encly/editor/model/Block$TableBlock;Lcom/pasich/encly/editor/model/TableData;Z)V + public abstract fun removeBlock (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/BlockRemoveAction;)V + public abstract fun replaceBlock (ILcom/pasich/encly/editor/model/Block;)Z + public abstract fun replaceTextRange (Ljava/lang/String;)Z + public abstract fun selectAllText ()Z + public abstract fun toggleChecklistLock (Lcom/pasich/encly/editor/model/Block;)V +} + +public final class com/pasich/encly/editor/ui/FindHighlights { + public static final field $stable I + public fun (Landroidx/compose/runtime/State;)V + public final fun reveal (Lcom/pasich/encly/editor/state/FindMatch;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/pasich/encly/editor/ui/SelectedBlocksActions { + public static final field $stable I + public fun (ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public final fun getCanEdit ()Z + public final fun getOnCopy ()Lkotlin/jvm/functions/Function0; + public final fun getOnCopyMarkdown ()Lkotlin/jvm/functions/Function0; + public final fun getOnCut ()Lkotlin/jvm/functions/Function0; + public final fun getOnDelete ()Lkotlin/jvm/functions/Function0; +} + +public final class com/pasich/encly/editor/ui/SelectedBlocksBarKt { + public static final fun SelectedBlocksBar (Lcom/pasich/encly/editor/state/SelectedBlocks;Lcom/pasich/encly/editor/ui/SelectedBlocksActions;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun TextRangeBar (Lcom/pasich/encly/editor/state/TextRangeSelection;Lcom/pasich/encly/editor/ui/SelectedBlocksActions;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/pasich/encly/editor/ui/TapBelowBlocksKt { + public static final fun rememberWriteAtEnd (Lcom/pasich/encly/editor/ui/EditorController;Landroidx/compose/runtime/Composer;I)Lkotlin/jvm/functions/Function0; + public static final fun tapBelowBlocks (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/lazy/LazyListState;ZLkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; +} + +public final class com/pasich/encly/editor/ui/TextRangeOverlayKt { + public static final fun TextRangeOverlay (Lcom/pasich/encly/editor/ui/EditorBlocksHost;Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/pasich/encly/editor/ui/blocks/NoteLinkKt { + public static final fun isOpenableLink (Ljava/lang/String;)Z +} + +public final class com/pasich/encly/editor/ui/blocks/TextBlockKt { + public static final fun getWritingKeyboard ()Landroidx/compose/foundation/text/KeyboardOptions; +} + +public final class com/pasich/encly/editor/ui/blocks/TextSyncKt { + public static final fun rememberSyncedTextFieldState (Lkotlin/jvm/functions/Function0;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)Landroidx/compose/foundation/text/input/TextFieldState; +} + +public final class com/pasich/encly/editor/ui/components/ComposableSingletons$EditorSheetKt { + public static final field INSTANCE Lcom/pasich/encly/editor/ui/components/ComposableSingletons$EditorSheetKt; + public fun ()V + public final fun getLambda$1043769206$Encly_editor ()Lkotlin/jvm/functions/Function2; +} + +public final class com/pasich/encly/editor/ui/sheets/BlockSheetKind : java/lang/Enum { + public static final field LINK Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind; + public static final field OTHER Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind; + public static fun values ()[Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind; +} + +public final class com/pasich/encly/editor/ui/sheets/BlockSheetKindKt { + public static final fun EditorBlockSheet (Lcom/pasich/encly/editor/ui/sheets/BlockSheetKind;Ljava/lang/String;ZLcom/pasich/encly/editor/ui/EditorController;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V +} + diff --git a/editor/api/editor.klib.api b/editor/api/editor.klib.api new file mode 100644 index 00000000..5a8f4f2e --- /dev/null +++ b/editor/api/editor.klib.api @@ -0,0 +1,1357 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final enum class com.pasich.encly.editor.model/BlockType : kotlin/Enum { // com.pasich.encly.editor.model/BlockType|null[0] + enum entry CODE // com.pasich.encly.editor.model/BlockType.CODE|null[0] + enum entry H1 // com.pasich.encly.editor.model/BlockType.H1|null[0] + enum entry H2 // com.pasich.encly.editor.model/BlockType.H2|null[0] + enum entry H3 // com.pasich.encly.editor.model/BlockType.H3|null[0] + enum entry H4 // com.pasich.encly.editor.model/BlockType.H4|null[0] + enum entry LINK // com.pasich.encly.editor.model/BlockType.LINK|null[0] + enum entry LIST_BULLET // com.pasich.encly.editor.model/BlockType.LIST_BULLET|null[0] + enum entry LIST_CHECK // com.pasich.encly.editor.model/BlockType.LIST_CHECK|null[0] + enum entry LIST_NUMBER // com.pasich.encly.editor.model/BlockType.LIST_NUMBER|null[0] + enum entry QUOTE // com.pasich.encly.editor.model/BlockType.QUOTE|null[0] + enum entry SEPARATOR // com.pasich.encly.editor.model/BlockType.SEPARATOR|null[0] + enum entry TABLE // com.pasich.encly.editor.model/BlockType.TABLE|null[0] + enum entry TEXT // com.pasich.encly.editor.model/BlockType.TEXT|null[0] + + final val entries // com.pasich.encly.editor.model/BlockType.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor.model/BlockType.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.model/BlockType.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor.model/BlockType.values|values#static(){}[0] +} + +final enum class com.pasich.encly.editor.state/BlockRemoveAction : kotlin/Enum { // com.pasich.encly.editor.state/BlockRemoveAction|null[0] + enum entry REMOVE // com.pasich.encly.editor.state/BlockRemoveAction.REMOVE|null[0] + enum entry REMOVE_BACKSPACE // com.pasich.encly.editor.state/BlockRemoveAction.REMOVE_BACKSPACE|null[0] + enum entry REMOVE_BACKSPACE_LIST // com.pasich.encly.editor.state/BlockRemoveAction.REMOVE_BACKSPACE_LIST|null[0] + + final val entries // com.pasich.encly.editor.state/BlockRemoveAction.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor.state/BlockRemoveAction.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor.state/BlockRemoveAction // com.pasich.encly.editor.state/BlockRemoveAction.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor.state/BlockRemoveAction.values|values#static(){}[0] +} + +final enum class com.pasich.encly.editor.state/HeadMove : kotlin/Enum { // com.pasich.encly.editor.state/HeadMove|null[0] + enum entry DOWN // com.pasich.encly.editor.state/HeadMove.DOWN|null[0] + enum entry FIELD_END // com.pasich.encly.editor.state/HeadMove.FIELD_END|null[0] + enum entry FIELD_START // com.pasich.encly.editor.state/HeadMove.FIELD_START|null[0] + enum entry NEXT // com.pasich.encly.editor.state/HeadMove.NEXT|null[0] + enum entry NOTE_END // com.pasich.encly.editor.state/HeadMove.NOTE_END|null[0] + enum entry NOTE_START // com.pasich.encly.editor.state/HeadMove.NOTE_START|null[0] + enum entry PAGE_DOWN // com.pasich.encly.editor.state/HeadMove.PAGE_DOWN|null[0] + enum entry PAGE_UP // com.pasich.encly.editor.state/HeadMove.PAGE_UP|null[0] + enum entry PREVIOUS // com.pasich.encly.editor.state/HeadMove.PREVIOUS|null[0] + enum entry UP // com.pasich.encly.editor.state/HeadMove.UP|null[0] + + final val entries // com.pasich.encly.editor.state/HeadMove.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor.state/HeadMove.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor.state/HeadMove // com.pasich.encly.editor.state/HeadMove.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor.state/HeadMove.values|values#static(){}[0] +} + +final enum class com.pasich.encly.editor.state/NoteTemplate : kotlin/Enum { // com.pasich.encly.editor.state/NoteTemplate|null[0] + enum entry BLANK // com.pasich.encly.editor.state/NoteTemplate.BLANK|null[0] + enum entry CHECKLIST // com.pasich.encly.editor.state/NoteTemplate.CHECKLIST|null[0] + enum entry JOURNAL // com.pasich.encly.editor.state/NoteTemplate.JOURNAL|null[0] + enum entry MEETING // com.pasich.encly.editor.state/NoteTemplate.MEETING|null[0] + enum entry PROJECT // com.pasich.encly.editor.state/NoteTemplate.PROJECT|null[0] + + final val entries // com.pasich.encly.editor.state/NoteTemplate.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor.state/NoteTemplate.entries.|#static(){}[0] + final val label // com.pasich.encly.editor.state/NoteTemplate.label|{}label[0] + final fun (): com.pasich.encly.editor/EditorString // com.pasich.encly.editor.state/NoteTemplate.label.|(){}[0] + final val parts // com.pasich.encly.editor.state/NoteTemplate.parts|{}parts[0] + final fun (): kotlin.collections/List // com.pasich.encly.editor.state/NoteTemplate.parts.|(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor.state/NoteTemplate // com.pasich.encly.editor.state/NoteTemplate.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor.state/NoteTemplate.values|values#static(){}[0] +} + +final enum class com.pasich.encly.editor.state/SlashCommand : kotlin/Enum { // com.pasich.encly.editor.state/SlashCommand|null[0] + enum entry BULLETS // com.pasich.encly.editor.state/SlashCommand.BULLETS|null[0] + enum entry CHECKLIST // com.pasich.encly.editor.state/SlashCommand.CHECKLIST|null[0] + enum entry CODE // com.pasich.encly.editor.state/SlashCommand.CODE|null[0] + enum entry DIVIDER // com.pasich.encly.editor.state/SlashCommand.DIVIDER|null[0] + enum entry H1 // com.pasich.encly.editor.state/SlashCommand.H1|null[0] + enum entry H2 // com.pasich.encly.editor.state/SlashCommand.H2|null[0] + enum entry H3 // com.pasich.encly.editor.state/SlashCommand.H3|null[0] + enum entry H4 // com.pasich.encly.editor.state/SlashCommand.H4|null[0] + enum entry LINK // com.pasich.encly.editor.state/SlashCommand.LINK|null[0] + enum entry NUMBERED // com.pasich.encly.editor.state/SlashCommand.NUMBERED|null[0] + enum entry QUOTE // com.pasich.encly.editor.state/SlashCommand.QUOTE|null[0] + enum entry TABLE // com.pasich.encly.editor.state/SlashCommand.TABLE|null[0] + enum entry TEXT // com.pasich.encly.editor.state/SlashCommand.TEXT|null[0] + + final val entries // com.pasich.encly.editor.state/SlashCommand.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor.state/SlashCommand.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor.state/SlashCommand // com.pasich.encly.editor.state/SlashCommand.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor.state/SlashCommand.values|values#static(){}[0] +} + +final enum class com.pasich.encly.editor.state/TypingKind : kotlin/Enum { // com.pasich.encly.editor.state/TypingKind|null[0] + enum entry DELETE // com.pasich.encly.editor.state/TypingKind.DELETE|null[0] + enum entry INSERT // com.pasich.encly.editor.state/TypingKind.INSERT|null[0] + enum entry REPLACE // com.pasich.encly.editor.state/TypingKind.REPLACE|null[0] + + final val entries // com.pasich.encly.editor.state/TypingKind.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor.state/TypingKind.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor.state/TypingKind // com.pasich.encly.editor.state/TypingKind.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor.state/TypingKind.values|values#static(){}[0] +} + +final enum class com.pasich.encly.editor.theme/EditorLineSpacing : kotlin/Enum { // com.pasich.encly.editor.theme/EditorLineSpacing|null[0] + enum entry COMFORTABLE // com.pasich.encly.editor.theme/EditorLineSpacing.COMFORTABLE|null[0] + enum entry COMPACT // com.pasich.encly.editor.theme/EditorLineSpacing.COMPACT|null[0] + enum entry DEFAULT // com.pasich.encly.editor.theme/EditorLineSpacing.DEFAULT|null[0] + + final val entries // com.pasich.encly.editor.theme/EditorLineSpacing.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor.theme/EditorLineSpacing.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor.theme/EditorLineSpacing // com.pasich.encly.editor.theme/EditorLineSpacing.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor.theme/EditorLineSpacing.values|values#static(){}[0] +} + +final enum class com.pasich.encly.editor.ui.sheets/BlockSheetKind : kotlin/Enum { // com.pasich.encly.editor.ui.sheets/BlockSheetKind|null[0] + enum entry LINK // com.pasich.encly.editor.ui.sheets/BlockSheetKind.LINK|null[0] + enum entry OTHER // com.pasich.encly.editor.ui.sheets/BlockSheetKind.OTHER|null[0] + + final val entries // com.pasich.encly.editor.ui.sheets/BlockSheetKind.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor.ui.sheets/BlockSheetKind.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor.ui.sheets/BlockSheetKind // com.pasich.encly.editor.ui.sheets/BlockSheetKind.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor.ui.sheets/BlockSheetKind.values|values#static(){}[0] +} + +final enum class com.pasich.encly.editor/EditorString : kotlin/Enum { // com.pasich.encly.editor/EditorString|null[0] + enum entry BLOCK_BULLETED_LIST // com.pasich.encly.editor/EditorString.BLOCK_BULLETED_LIST|null[0] + enum entry BLOCK_CHECKLIST // com.pasich.encly.editor/EditorString.BLOCK_CHECKLIST|null[0] + enum entry BLOCK_CODE // com.pasich.encly.editor/EditorString.BLOCK_CODE|null[0] + enum entry BLOCK_DELETE // com.pasich.encly.editor/EditorString.BLOCK_DELETE|null[0] + enum entry BLOCK_DUPLICATE // com.pasich.encly.editor/EditorString.BLOCK_DUPLICATE|null[0] + enum entry BLOCK_HEADING // com.pasich.encly.editor/EditorString.BLOCK_HEADING|null[0] + enum entry BLOCK_HEADING_LEVEL // com.pasich.encly.editor/EditorString.BLOCK_HEADING_LEVEL|null[0] + enum entry BLOCK_LINK // com.pasich.encly.editor/EditorString.BLOCK_LINK|null[0] + enum entry BLOCK_LIST // com.pasich.encly.editor/EditorString.BLOCK_LIST|null[0] + enum entry BLOCK_MOVE_DOWN // com.pasich.encly.editor/EditorString.BLOCK_MOVE_DOWN|null[0] + enum entry BLOCK_MOVE_UP // com.pasich.encly.editor/EditorString.BLOCK_MOVE_UP|null[0] + enum entry BLOCK_NUMBERED_LIST // com.pasich.encly.editor/EditorString.BLOCK_NUMBERED_LIST|null[0] + enum entry BLOCK_SELECT // com.pasich.encly.editor/EditorString.BLOCK_SELECT|null[0] + enum entry BLOCK_SEPARATOR // com.pasich.encly.editor/EditorString.BLOCK_SEPARATOR|null[0] + enum entry BLOCK_TABLE // com.pasich.encly.editor/EditorString.BLOCK_TABLE|null[0] + enum entry CHECKLIST_LOCK // com.pasich.encly.editor/EditorString.CHECKLIST_LOCK|null[0] + enum entry CHECKLIST_UNLOCK // com.pasich.encly.editor/EditorString.CHECKLIST_UNLOCK|null[0] + enum entry ENTER_URL // com.pasich.encly.editor/EditorString.ENTER_URL|null[0] + enum entry LINK_ADD // com.pasich.encly.editor/EditorString.LINK_ADD|null[0] + enum entry LINK_BLOCKED // com.pasich.encly.editor/EditorString.LINK_BLOCKED|null[0] + enum entry LINK_CONTENT_FAILED // com.pasich.encly.editor/EditorString.LINK_CONTENT_FAILED|null[0] + enum entry LINK_COPY // com.pasich.encly.editor/EditorString.LINK_COPY|null[0] + enum entry LINK_EDIT // com.pasich.encly.editor/EditorString.LINK_EDIT|null[0] + enum entry LINK_OPEN // com.pasich.encly.editor/EditorString.LINK_OPEN|null[0] + enum entry LIST_ITEM_INDENT // com.pasich.encly.editor/EditorString.LIST_ITEM_INDENT|null[0] + enum entry LIST_ITEM_MOVE_DOWN // com.pasich.encly.editor/EditorString.LIST_ITEM_MOVE_DOWN|null[0] + enum entry LIST_ITEM_MOVE_UP // com.pasich.encly.editor/EditorString.LIST_ITEM_MOVE_UP|null[0] + enum entry LIST_ITEM_OUTDENT // com.pasich.encly.editor/EditorString.LIST_ITEM_OUTDENT|null[0] + enum entry LIST_ITEM_PLACEHOLDER // com.pasich.encly.editor/EditorString.LIST_ITEM_PLACEHOLDER|null[0] + enum entry LIST_ITEM_REORDER // com.pasich.encly.editor/EditorString.LIST_ITEM_REORDER|null[0] + enum entry MORE_OPTIONS // com.pasich.encly.editor/EditorString.MORE_OPTIONS|null[0] + enum entry PRESS_TO_EDIT // com.pasich.encly.editor/EditorString.PRESS_TO_EDIT|null[0] + enum entry QUOTE // com.pasich.encly.editor/EditorString.QUOTE|null[0] + enum entry SELECTION_CLOSE // com.pasich.encly.editor/EditorString.SELECTION_CLOSE|null[0] + enum entry SELECTION_COPY // com.pasich.encly.editor/EditorString.SELECTION_COPY|null[0] + enum entry SELECTION_COPY_MARKDOWN // com.pasich.encly.editor/EditorString.SELECTION_COPY_MARKDOWN|null[0] + enum entry SELECTION_COUNT // com.pasich.encly.editor/EditorString.SELECTION_COUNT|null[0] + enum entry SELECTION_CUT // com.pasich.encly.editor/EditorString.SELECTION_CUT|null[0] + enum entry SELECTION_DELETE // com.pasich.encly.editor/EditorString.SELECTION_DELETE|null[0] + enum entry SLASH_MENU_TITLE // com.pasich.encly.editor/EditorString.SLASH_MENU_TITLE|null[0] + enum entry TABLE_ADD_COLUMN // com.pasich.encly.editor/EditorString.TABLE_ADD_COLUMN|null[0] + enum entry TABLE_ADD_ROW // com.pasich.encly.editor/EditorString.TABLE_ADD_ROW|null[0] + enum entry TABLE_CELL_OPTIONS // com.pasich.encly.editor/EditorString.TABLE_CELL_OPTIONS|null[0] + enum entry TABLE_HEADER_ROW // com.pasich.encly.editor/EditorString.TABLE_HEADER_ROW|null[0] + enum entry TABLE_REMOVE_COLUMN // com.pasich.encly.editor/EditorString.TABLE_REMOVE_COLUMN|null[0] + enum entry TABLE_REMOVE_ROW // com.pasich.encly.editor/EditorString.TABLE_REMOVE_ROW|null[0] + enum entry TAP_AGAIN_TO_DELETE // com.pasich.encly.editor/EditorString.TAP_AGAIN_TO_DELETE|null[0] + enum entry TEMPLATE_ACTION_ITEMS // com.pasich.encly.editor/EditorString.TEMPLATE_ACTION_ITEMS|null[0] + enum entry TEMPLATE_AGENDA // com.pasich.encly.editor/EditorString.TEMPLATE_AGENDA|null[0] + enum entry TEMPLATE_ATTENDEES // com.pasich.encly.editor/EditorString.TEMPLATE_ATTENDEES|null[0] + enum entry TEMPLATE_BLANK // com.pasich.encly.editor/EditorString.TEMPLATE_BLANK|null[0] + enum entry TEMPLATE_CHECKLIST_TODO // com.pasich.encly.editor/EditorString.TEMPLATE_CHECKLIST_TODO|null[0] + enum entry TEMPLATE_JOURNAL // com.pasich.encly.editor/EditorString.TEMPLATE_JOURNAL|null[0] + enum entry TEMPLATE_JOURNAL_GRATITUDE // com.pasich.encly.editor/EditorString.TEMPLATE_JOURNAL_GRATITUDE|null[0] + enum entry TEMPLATE_JOURNAL_TODAY // com.pasich.encly.editor/EditorString.TEMPLATE_JOURNAL_TODAY|null[0] + enum entry TEMPLATE_JOURNAL_TOMORROW // com.pasich.encly.editor/EditorString.TEMPLATE_JOURNAL_TOMORROW|null[0] + enum entry TEMPLATE_MEETING // com.pasich.encly.editor/EditorString.TEMPLATE_MEETING|null[0] + enum entry TEMPLATE_NOTES // com.pasich.encly.editor/EditorString.TEMPLATE_NOTES|null[0] + enum entry TEMPLATE_PROJECT // com.pasich.encly.editor/EditorString.TEMPLATE_PROJECT|null[0] + enum entry TEMPLATE_PROJECT_DECISIONS // com.pasich.encly.editor/EditorString.TEMPLATE_PROJECT_DECISIONS|null[0] + enum entry TEMPLATE_PROJECT_GOAL // com.pasich.encly.editor/EditorString.TEMPLATE_PROJECT_GOAL|null[0] + enum entry TEMPLATE_PROJECT_QUESTIONS // com.pasich.encly.editor/EditorString.TEMPLATE_PROJECT_QUESTIONS|null[0] + enum entry TEMPLATE_PROJECT_TASKS // com.pasich.encly.editor/EditorString.TEMPLATE_PROJECT_TASKS|null[0] + enum entry TEXT // com.pasich.encly.editor/EditorString.TEXT|null[0] + + final val entries // com.pasich.encly.editor/EditorString.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.editor/EditorString.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.editor/EditorString // com.pasich.encly.editor/EditorString.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.editor/EditorString.values|values#static(){}[0] +} + +abstract fun interface com.pasich.encly.editor/EditorClipboard { // com.pasich.encly.editor/EditorClipboard|null[0] + abstract fun copy(kotlin/String, kotlin/String, kotlin/Boolean) // com.pasich.encly.editor/EditorClipboard.copy|copy(kotlin.String;kotlin.String;kotlin.Boolean){}[0] + + final object None : com.pasich.encly.editor/EditorClipboard { // com.pasich.encly.editor/EditorClipboard.None|null[0] + final fun copy(kotlin/String, kotlin/String, kotlin/Boolean) // com.pasich.encly.editor/EditorClipboard.None.copy|copy(kotlin.String;kotlin.String;kotlin.Boolean){}[0] + } +} + +abstract fun interface com.pasich.encly.editor/EditorLogger { // com.pasich.encly.editor/EditorLogger|null[0] + abstract fun warn(kotlin/String, kotlin/String) // com.pasich.encly.editor/EditorLogger.warn|warn(kotlin.String;kotlin.String){}[0] + + final object None : com.pasich.encly.editor/EditorLogger { // com.pasich.encly.editor/EditorLogger.None|null[0] + final fun warn(kotlin/String, kotlin/String) // com.pasich.encly.editor/EditorLogger.None.warn|warn(kotlin.String;kotlin.String){}[0] + } +} + +abstract fun interface com.pasich.encly.editor/LinkOpener { // com.pasich.encly.editor/LinkOpener|null[0] + abstract fun open(kotlin/String) // com.pasich.encly.editor/LinkOpener.open|open(kotlin.String){}[0] + + final object None : com.pasich.encly.editor/LinkOpener { // com.pasich.encly.editor/LinkOpener.None|null[0] + final fun open(kotlin/String) // com.pasich.encly.editor/LinkOpener.None.open|open(kotlin.String){}[0] + } +} + +abstract fun interface com.pasich.encly.editor/MarkdownBlockParser { // com.pasich.encly.editor/MarkdownBlockParser|null[0] + abstract fun parsePaste(kotlin/String, kotlin/Boolean, kotlin/Function1): kotlin.collections/List // com.pasich.encly.editor/MarkdownBlockParser.parsePaste|parsePaste(kotlin.String;kotlin.Boolean;kotlin.Function1){}[0] +} + +abstract interface com.pasich.encly.editor.model/TextualBlock { // com.pasich.encly.editor.model/TextualBlock|null[0] + abstract val text // com.pasich.encly.editor.model/TextualBlock.text|{}text[0] + abstract fun (): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/TextualBlock.text.|(){}[0] +} + +abstract interface com.pasich.encly.editor.ui/EditorController { // com.pasich.encly.editor.ui/EditorController|null[0] + abstract val blocks // com.pasich.encly.editor.ui/EditorController.blocks|{}blocks[0] + abstract fun (): kotlin.collections/List // com.pasich.encly.editor.ui/EditorController.blocks.|(){}[0] + abstract val interactedBlockId // com.pasich.encly.editor.ui/EditorController.interactedBlockId|{}interactedBlockId[0] + abstract fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.editor.ui/EditorController.interactedBlockId.|(){}[0] + abstract val selectedBlocks // com.pasich.encly.editor.ui/EditorController.selectedBlocks|{}selectedBlocks[0] + abstract fun (): com.pasich.encly.editor.state/SelectedBlocks // com.pasich.encly.editor.ui/EditorController.selectedBlocks.|(){}[0] + abstract val slashPicker // com.pasich.encly.editor.ui/EditorController.slashPicker|{}slashPicker[0] + abstract fun (): com.pasich.encly.editor.state/SlashPicker // com.pasich.encly.editor.ui/EditorController.slashPicker.|(){}[0] + abstract val textRange // com.pasich.encly.editor.ui/EditorController.textRange|{}textRange[0] + abstract fun (): com.pasich.encly.editor.state/TextRangeSelection // com.pasich.encly.editor.ui/EditorController.textRange.|(){}[0] + + abstract fun addBlockAfter(kotlin/Int, com.pasich.encly.editor.model/BlockType) // com.pasich.encly.editor.ui/EditorController.addBlockAfter|addBlockAfter(kotlin.Int;com.pasich.encly.editor.model.BlockType){}[0] + abstract fun addBlockToEnd() // com.pasich.encly.editor.ui/EditorController.addBlockToEnd|addBlockToEnd(){}[0] + abstract fun applySlash(com.pasich.encly.editor.model/Block, com.pasich.encly.editor.state/SlashCommand) // com.pasich.encly.editor.ui/EditorController.applySlash|applySlash(com.pasich.encly.editor.model.Block;com.pasich.encly.editor.state.SlashCommand){}[0] + abstract fun copyTextRange(kotlin/Boolean): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.copyTextRange|copyTextRange(kotlin.Boolean){}[0] + abstract fun cutTextRange(): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.cutTextRange|cutTextRange(){}[0] + abstract fun duplicateBlock(com.pasich.encly.editor.model/Block) // com.pasich.encly.editor.ui/EditorController.duplicateBlock|duplicateBlock(com.pasich.encly.editor.model.Block){}[0] + abstract fun editLink(com.pasich.encly.editor.model/Block.LinkBlock) // com.pasich.encly.editor.ui/EditorController.editLink|editLink(com.pasich.encly.editor.model.Block.LinkBlock){}[0] + abstract fun indexOfBlock(com.pasich.encly.editor.model/Block): kotlin/Int // com.pasich.encly.editor.ui/EditorController.indexOfBlock|indexOfBlock(com.pasich.encly.editor.model.Block){}[0] + abstract fun interactedBlockPosition(): kotlin/Int // com.pasich.encly.editor.ui/EditorController.interactedBlockPosition|interactedBlockPosition(){}[0] + abstract fun moveBlock(kotlin/Boolean) // com.pasich.encly.editor.ui/EditorController.moveBlock|moveBlock(kotlin.Boolean){}[0] + abstract fun moveBlockOf(com.pasich.encly.editor.model/Block, kotlin/Boolean) // com.pasich.encly.editor.ui/EditorController.moveBlockOf|moveBlockOf(com.pasich.encly.editor.model.Block;kotlin.Boolean){}[0] + abstract fun onBackspaceAtStart(com.pasich.encly.editor.model/Block): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.onBackspaceAtStart|onBackspaceAtStart(com.pasich.encly.editor.model.Block){}[0] + abstract fun onBlockFocusLost(com.pasich.encly.editor.model/Block) // com.pasich.encly.editor.ui/EditorController.onBlockFocusLost|onBlockFocusLost(com.pasich.encly.editor.model.Block){}[0] + abstract fun onBlockFocused(com.pasich.encly.editor.model/Block) // com.pasich.encly.editor.ui/EditorController.onBlockFocused|onBlockFocused(com.pasich.encly.editor.model.Block){}[0] + abstract fun onBlockInteraction(com.pasich.encly.editor.model/Block) // com.pasich.encly.editor.ui/EditorController.onBlockInteraction|onBlockInteraction(com.pasich.encly.editor.model.Block){}[0] + abstract fun onBlockTextChanged(com.pasich.encly.editor.model/Block, kotlin/String) // com.pasich.encly.editor.ui/EditorController.onBlockTextChanged|onBlockTextChanged(com.pasich.encly.editor.model.Block;kotlin.String){}[0] + abstract fun onCaretMoved(com.pasich.encly.editor.model/Block, kotlin/Int) // com.pasich.encly.editor.ui/EditorController.onCaretMoved|onCaretMoved(com.pasich.encly.editor.model.Block;kotlin.Int){}[0] + abstract fun onLineBreak(com.pasich.encly.editor.model/Block, com.pasich.encly.editor.state/LineBreak): kotlin/String? // com.pasich.encly.editor.ui/EditorController.onLineBreak|onLineBreak(com.pasich.encly.editor.model.Block;com.pasich.encly.editor.state.LineBreak){}[0] + abstract fun onLinkChanged(com.pasich.encly.editor.model/Block.LinkBlock, com.pasich.encly.editor.model/LinkDataBlock) // com.pasich.encly.editor.ui/EditorController.onLinkChanged|onLinkChanged(com.pasich.encly.editor.model.Block.LinkBlock;com.pasich.encly.editor.model.LinkDataBlock){}[0] + abstract fun onLinkPasted(com.pasich.encly.editor.model/Block, kotlin/String, com.pasich.encly.editor.model/LinkDataBlock): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.onLinkPasted|onLinkPasted(com.pasich.encly.editor.model.Block;kotlin.String;com.pasich.encly.editor.model.LinkDataBlock){}[0] + abstract fun onListBackspaceAtStart(com.pasich.encly.editor.model/Block.ListBlock, kotlin/String): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.onListBackspaceAtStart|onListBackspaceAtStart(com.pasich.encly.editor.model.Block.ListBlock;kotlin.String){}[0] + abstract fun onListItemCaret(com.pasich.encly.editor.model/Block, kotlin/String, kotlin/Int) // com.pasich.encly.editor.ui/EditorController.onListItemCaret|onListItemCaret(com.pasich.encly.editor.model.Block;kotlin.String;kotlin.Int){}[0] + abstract fun onListItemMove(com.pasich.encly.editor.model/Block.ListBlock, kotlin/String, kotlin/Int): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.onListItemMove|onListItemMove(com.pasich.encly.editor.model.Block.ListBlock;kotlin.String;kotlin.Int){}[0] + abstract fun onListItemShift(com.pasich.encly.editor.model/Block.ListBlock, kotlin/String, kotlin/Boolean): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.onListItemShift|onListItemShift(com.pasich.encly.editor.model.Block.ListBlock;kotlin.String;kotlin.Boolean){}[0] + abstract fun onListItemsChanged(com.pasich.encly.editor.model/Block.ListBlock, kotlin.collections/List, kotlin/Boolean) // com.pasich.encly.editor.ui/EditorController.onListItemsChanged|onListItemsChanged(com.pasich.encly.editor.model.Block.ListBlock;kotlin.collections.List;kotlin.Boolean){}[0] + abstract fun onListLineBreak(com.pasich.encly.editor.model/Block.ListBlock, kotlin/String, com.pasich.encly.editor.state/LineBreak): kotlin/String? // com.pasich.encly.editor.ui/EditorController.onListLineBreak|onListLineBreak(com.pasich.encly.editor.model.Block.ListBlock;kotlin.String;com.pasich.encly.editor.state.LineBreak){}[0] + abstract fun onPasteCopied(com.pasich.encly.editor.model/Block, kotlin/String?, kotlin/String, kotlin/String, kotlin/String): kotlin/String? // com.pasich.encly.editor.ui/EditorController.onPasteCopied|onPasteCopied(com.pasich.encly.editor.model.Block;kotlin.String?;kotlin.String;kotlin.String;kotlin.String){}[0] + abstract fun onShortcut(com.pasich.encly.editor.model/Block, kotlin/String, com.pasich.encly.editor.state/Shortcut): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.onShortcut|onShortcut(com.pasich.encly.editor.model.Block;kotlin.String;com.pasich.encly.editor.state.Shortcut){}[0] + abstract fun onSlashTyped(com.pasich.encly.editor.model/Block, kotlin/String, kotlin/Boolean) // com.pasich.encly.editor.ui/EditorController.onSlashTyped|onSlashTyped(com.pasich.encly.editor.model.Block;kotlin.String;kotlin.Boolean){}[0] + abstract fun onTableChanged(com.pasich.encly.editor.model/Block.TableBlock, com.pasich.encly.editor.model/TableData, kotlin/Boolean) // com.pasich.encly.editor.ui/EditorController.onTableChanged|onTableChanged(com.pasich.encly.editor.model.Block.TableBlock;com.pasich.encly.editor.model.TableData;kotlin.Boolean){}[0] + abstract fun removeBlock(com.pasich.encly.editor.model/Block, com.pasich.encly.editor.state/BlockRemoveAction) // com.pasich.encly.editor.ui/EditorController.removeBlock|removeBlock(com.pasich.encly.editor.model.Block;com.pasich.encly.editor.state.BlockRemoveAction){}[0] + abstract fun replaceBlock(kotlin/Int, com.pasich.encly.editor.model/Block): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.replaceBlock|replaceBlock(kotlin.Int;com.pasich.encly.editor.model.Block){}[0] + abstract fun replaceTextRange(kotlin/String): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.replaceTextRange|replaceTextRange(kotlin.String){}[0] + abstract fun selectAllText(): kotlin/Boolean // com.pasich.encly.editor.ui/EditorController.selectAllText|selectAllText(){}[0] + abstract fun toggleChecklistLock(com.pasich.encly.editor.model/Block) // com.pasich.encly.editor.ui/EditorController.toggleChecklistLock|toggleChecklistLock(com.pasich.encly.editor.model.Block){}[0] +} + +abstract interface com.pasich.encly.editor/EditorStrings { // com.pasich.encly.editor/EditorStrings|null[0] + abstract fun heading(kotlin/Int): kotlin/String // com.pasich.encly.editor/EditorStrings.heading|heading(kotlin.Int){}[0] + abstract fun selectionCount(kotlin/Int): kotlin/String // com.pasich.encly.editor/EditorStrings.selectionCount|selectionCount(kotlin.Int){}[0] + abstract fun text(com.pasich.encly.editor/EditorString): kotlin/String // com.pasich.encly.editor/EditorStrings.text|text(com.pasich.encly.editor.EditorString){}[0] +} + +sealed interface com.pasich.encly.editor.state/TemplatePart { // com.pasich.encly.editor.state/TemplatePart|null[0] + final class Heading : com.pasich.encly.editor.state/TemplatePart { // com.pasich.encly.editor.state/TemplatePart.Heading|null[0] + constructor (com.pasich.encly.editor.model/BlockType, com.pasich.encly.editor/EditorString) // com.pasich.encly.editor.state/TemplatePart.Heading.|(com.pasich.encly.editor.model.BlockType;com.pasich.encly.editor.EditorString){}[0] + + final val text // com.pasich.encly.editor.state/TemplatePart.Heading.text|{}text[0] + final fun (): com.pasich.encly.editor/EditorString // com.pasich.encly.editor.state/TemplatePart.Heading.text.|(){}[0] + final val type // com.pasich.encly.editor.state/TemplatePart.Heading.type|{}type[0] + final fun (): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.state/TemplatePart.Heading.type.|(){}[0] + + final fun component1(): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.state/TemplatePart.Heading.component1|component1(){}[0] + final fun component2(): com.pasich.encly.editor/EditorString // com.pasich.encly.editor.state/TemplatePart.Heading.component2|component2(){}[0] + final fun copy(com.pasich.encly.editor.model/BlockType = ..., com.pasich.encly.editor/EditorString = ...): com.pasich.encly.editor.state/TemplatePart.Heading // com.pasich.encly.editor.state/TemplatePart.Heading.copy|copy(com.pasich.encly.editor.model.BlockType;com.pasich.encly.editor.EditorString){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/TemplatePart.Heading.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/TemplatePart.Heading.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/TemplatePart.Heading.toString|toString(){}[0] + } + + final class ListOf : com.pasich.encly.editor.state/TemplatePart { // com.pasich.encly.editor.state/TemplatePart.ListOf|null[0] + constructor (com.pasich.encly.editor.model/BlockType) // com.pasich.encly.editor.state/TemplatePart.ListOf.|(com.pasich.encly.editor.model.BlockType){}[0] + + final val type // com.pasich.encly.editor.state/TemplatePart.ListOf.type|{}type[0] + final fun (): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.state/TemplatePart.ListOf.type.|(){}[0] + + final fun component1(): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.state/TemplatePart.ListOf.component1|component1(){}[0] + final fun copy(com.pasich.encly.editor.model/BlockType = ...): com.pasich.encly.editor.state/TemplatePart.ListOf // com.pasich.encly.editor.state/TemplatePart.ListOf.copy|copy(com.pasich.encly.editor.model.BlockType){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/TemplatePart.ListOf.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/TemplatePart.ListOf.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/TemplatePart.ListOf.toString|toString(){}[0] + } + + final object Paragraph : com.pasich.encly.editor.state/TemplatePart { // com.pasich.encly.editor.state/TemplatePart.Paragraph|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/TemplatePart.Paragraph.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/TemplatePart.Paragraph.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/TemplatePart.Paragraph.toString|toString(){}[0] + } +} + +final class com.pasich.encly.editor.focus/BlockFocusRegistry { // com.pasich.encly.editor.focus/BlockFocusRegistry|null[0] + constructor (com.pasich.encly.editor/EditorLogger = ..., kotlin/Function0>) // com.pasich.encly.editor.focus/BlockFocusRegistry.|(com.pasich.encly.editor.EditorLogger;kotlin.Function0>){}[0] + + final fun focus(com.pasich.encly.editor.state/FocusRequest): kotlin/Boolean // com.pasich.encly.editor.focus/BlockFocusRegistry.focus|focus(com.pasich.encly.editor.state.FocusRequest){}[0] + final fun focus(kotlin/String, kotlin/Boolean = ...): kotlin/Boolean // com.pasich.encly.editor.focus/BlockFocusRegistry.focus|focus(kotlin.String;kotlin.Boolean){}[0] + final suspend fun focusWhenComposed(com.pasich.encly.editor.state/FocusRequest, kotlin.coroutines/SuspendFunction1 = ...) // com.pasich.encly.editor.focus/BlockFocusRegistry.focusWhenComposed|focusWhenComposed(com.pasich.encly.editor.state.FocusRequest;kotlin.coroutines.SuspendFunction1){}[0] +} + +final class com.pasich.encly.editor.model/ItemListBlock { // com.pasich.encly.editor.model/ItemListBlock|null[0] + constructor (kotlin/String = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int = ..., com.pasich.encly.editor.model/BlockType? = ...) // com.pasich.encly.editor.model/ItemListBlock.|(kotlin.String;kotlin.Boolean;kotlin.String;kotlin.Int;com.pasich.encly.editor.model.BlockType?){}[0] + + final val id // com.pasich.encly.editor.model/ItemListBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/ItemListBlock.id.|(){}[0] + final val isCheck // com.pasich.encly.editor.model/ItemListBlock.isCheck|{}isCheck[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.model/ItemListBlock.isCheck.|(){}[0] + final val kind // com.pasich.encly.editor.model/ItemListBlock.kind|{}kind[0] + final fun (): com.pasich.encly.editor.model/BlockType? // com.pasich.encly.editor.model/ItemListBlock.kind.|(){}[0] + final val level // com.pasich.encly.editor.model/ItemListBlock.level|{}level[0] + final fun (): kotlin/Int // com.pasich.encly.editor.model/ItemListBlock.level.|(){}[0] + final val value // com.pasich.encly.editor.model/ItemListBlock.value|{}value[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/ItemListBlock.value.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.model/ItemListBlock.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // com.pasich.encly.editor.model/ItemListBlock.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.editor.model/ItemListBlock.component3|component3(){}[0] + final fun component4(): kotlin/Int // com.pasich.encly.editor.model/ItemListBlock.component4|component4(){}[0] + final fun component5(): com.pasich.encly.editor.model/BlockType? // com.pasich.encly.editor.model/ItemListBlock.component5|component5(){}[0] + final fun copy(kotlin/String = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int = ..., com.pasich.encly.editor.model/BlockType? = ...): com.pasich.encly.editor.model/ItemListBlock // com.pasich.encly.editor.model/ItemListBlock.copy|copy(kotlin.String;kotlin.Boolean;kotlin.String;kotlin.Int;com.pasich.encly.editor.model.BlockType?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/ItemListBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/ItemListBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/ItemListBlock.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.model/LinkDataBlock { // com.pasich.encly.editor.model/LinkDataBlock|null[0] + constructor (kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/Boolean = ...) // com.pasich.encly.editor.model/LinkDataBlock.|(kotlin.String;kotlin.String;kotlin.String;kotlin.Boolean){}[0] + + final var imageUrl // com.pasich.encly.editor.model/LinkDataBlock.imageUrl|{}imageUrl[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/LinkDataBlock.imageUrl.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.editor.model/LinkDataBlock.imageUrl.|(kotlin.String){}[0] + final var isError // com.pasich.encly.editor.model/LinkDataBlock.isError|{}isError[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.model/LinkDataBlock.isError.|(){}[0] + final fun (kotlin/Boolean) // com.pasich.encly.editor.model/LinkDataBlock.isError.|(kotlin.Boolean){}[0] + final var title // com.pasich.encly.editor.model/LinkDataBlock.title|{}title[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/LinkDataBlock.title.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.editor.model/LinkDataBlock.title.|(kotlin.String){}[0] + final var url // com.pasich.encly.editor.model/LinkDataBlock.url|{}url[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/LinkDataBlock.url.|(){}[0] + final fun (kotlin/String) // com.pasich.encly.editor.model/LinkDataBlock.url.|(kotlin.String){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.model/LinkDataBlock.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.model/LinkDataBlock.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.editor.model/LinkDataBlock.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // com.pasich.encly.editor.model/LinkDataBlock.component4|component4(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/Boolean = ...): com.pasich.encly.editor.model/LinkDataBlock // com.pasich.encly.editor.model/LinkDataBlock.copy|copy(kotlin.String;kotlin.String;kotlin.String;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/LinkDataBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/LinkDataBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/LinkDataBlock.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.model/TableCell { // com.pasich.encly.editor.model/TableCell|null[0] + constructor (kotlin/String = ..., kotlin/String = ...) // com.pasich.encly.editor.model/TableCell.|(kotlin.String;kotlin.String){}[0] + + final val id // com.pasich.encly.editor.model/TableCell.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/TableCell.id.|(){}[0] + final val value // com.pasich.encly.editor.model/TableCell.value|{}value[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/TableCell.value.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.model/TableCell.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.model/TableCell.component2|component2(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ...): com.pasich.encly.editor.model/TableCell // com.pasich.encly.editor.model/TableCell.copy|copy(kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/TableCell.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/TableCell.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/TableCell.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.model/TableData { // com.pasich.encly.editor.model/TableData|null[0] + constructor (kotlin.collections/List, kotlin/Boolean = ...) // com.pasich.encly.editor.model/TableData.|(kotlin.collections.List;kotlin.Boolean){}[0] + + final val columnCount // com.pasich.encly.editor.model/TableData.columnCount|{}columnCount[0] + final fun (): kotlin/Int // com.pasich.encly.editor.model/TableData.columnCount.|(){}[0] + final val header // com.pasich.encly.editor.model/TableData.header|{}header[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.model/TableData.header.|(){}[0] + final val rowCount // com.pasich.encly.editor.model/TableData.rowCount|{}rowCount[0] + final fun (): kotlin/Int // com.pasich.encly.editor.model/TableData.rowCount.|(){}[0] + final val rows // com.pasich.encly.editor.model/TableData.rows|{}rows[0] + final fun (): kotlin.collections/List // com.pasich.encly.editor.model/TableData.rows.|(){}[0] + + final fun addColumn(kotlin/Int): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.addColumn|addColumn(kotlin.Int){}[0] + final fun addRow(kotlin/Int): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.addRow|addRow(kotlin.Int){}[0] + final fun cell(kotlin/String): com.pasich.encly.editor.model/TableCell? // com.pasich.encly.editor.model/TableData.cell|cell(kotlin.String){}[0] + final fun component1(): kotlin.collections/List // com.pasich.encly.editor.model/TableData.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // com.pasich.encly.editor.model/TableData.component2|component2(){}[0] + final fun copy(kotlin.collections/List = ..., kotlin/Boolean = ...): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.copy|copy(kotlin.collections.List;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/TableData.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/TableData.hashCode|hashCode(){}[0] + final fun locate(kotlin/String): kotlin/Pair? // com.pasich.encly.editor.model/TableData.locate|locate(kotlin.String){}[0] + final fun removeColumn(kotlin/Int): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.removeColumn|removeColumn(kotlin.Int){}[0] + final fun removeRow(kotlin/Int): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.removeRow|removeRow(kotlin.Int){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/TableData.toString|toString(){}[0] + final fun valueAt(kotlin/Int, kotlin/Int): kotlin/String // com.pasich.encly.editor.model/TableData.valueAt|valueAt(kotlin.Int;kotlin.Int){}[0] + final fun withCell(kotlin/String, kotlin/String): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.withCell|withCell(kotlin.String;kotlin.String){}[0] + final fun withHeader(kotlin/Boolean): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.withHeader|withHeader(kotlin.Boolean){}[0] + + final object Companion { // com.pasich.encly.editor.model/TableData.Companion|null[0] + final fun empty(kotlin/Int = ..., kotlin/Int = ...): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.Companion.empty|empty(kotlin.Int;kotlin.Int){}[0] + final fun of(kotlin.collections/List>, kotlin/Boolean = ...): com.pasich.encly.editor.model/TableData // com.pasich.encly.editor.model/TableData.Companion.of|of(kotlin.collections.List>;kotlin.Boolean){}[0] + } +} + +final class com.pasich.encly.editor.model/TableRow { // com.pasich.encly.editor.model/TableRow|null[0] + constructor (kotlin.collections/List) // com.pasich.encly.editor.model/TableRow.|(kotlin.collections.List){}[0] + + final val cells // com.pasich.encly.editor.model/TableRow.cells|{}cells[0] + final fun (): kotlin.collections/List // com.pasich.encly.editor.model/TableRow.cells.|(){}[0] + + final fun component1(): kotlin.collections/List // com.pasich.encly.editor.model/TableRow.component1|component1(){}[0] + final fun copy(kotlin.collections/List = ...): com.pasich.encly.editor.model/TableRow // com.pasich.encly.editor.model/TableRow.copy|copy(kotlin.collections.List){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/TableRow.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/TableRow.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/TableRow.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/BlockEditorState { // com.pasich.encly.editor.state/BlockEditorState|null[0] + constructor (com.pasich.encly.editor/MarkdownBlockParser = ..., kotlin/Function0 = ...) // com.pasich.encly.editor.state/BlockEditorState.|(com.pasich.encly.editor.MarkdownBlockParser;kotlin.Function0){}[0] + + final val blocks // com.pasich.encly.editor.state/BlockEditorState.blocks|{}blocks[0] + final fun (): kotlin.collections/List // com.pasich.encly.editor.state/BlockEditorState.blocks.|(){}[0] + final val canRedo // com.pasich.encly.editor.state/BlockEditorState.canRedo|{}canRedo[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.editor.state/BlockEditorState.canRedo.|(){}[0] + final val canUndo // com.pasich.encly.editor.state/BlockEditorState.canUndo|{}canUndo[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.editor.state/BlockEditorState.canUndo.|(){}[0] + final val contentChanges // com.pasich.encly.editor.state/BlockEditorState.contentChanges|{}contentChanges[0] + final fun (): kotlinx.coroutines.flow/Flow // com.pasich.encly.editor.state/BlockEditorState.contentChanges.|(){}[0] + final val find // com.pasich.encly.editor.state/BlockEditorState.find|{}find[0] + final fun (): com.pasich.encly.editor.state/NoteFind // com.pasich.encly.editor.state/BlockEditorState.find.|(){}[0] + final val markdown // com.pasich.encly.editor.state/BlockEditorState.markdown|{}markdown[0] + final fun (): com.pasich.encly.editor/MarkdownBlockParser // com.pasich.encly.editor.state/BlockEditorState.markdown.|(){}[0] + final val pickedBlocks // com.pasich.encly.editor.state/BlockEditorState.pickedBlocks|{}pickedBlocks[0] + final fun (): com.pasich.encly.editor.state/SelectedBlocks // com.pasich.encly.editor.state/BlockEditorState.pickedBlocks.|(){}[0] + final val selection // com.pasich.encly.editor.state/BlockEditorState.selection|{}selection[0] + final fun (): com.pasich.encly.editor.state/BlockSelection // com.pasich.encly.editor.state/BlockEditorState.selection.|(){}[0] + final val slash // com.pasich.encly.editor.state/BlockEditorState.slash|{}slash[0] + final fun (): com.pasich.encly.editor.state/SlashPicker // com.pasich.encly.editor.state/BlockEditorState.slash.|(){}[0] + final val textRange // com.pasich.encly.editor.state/BlockEditorState.textRange|{}textRange[0] + final fun (): com.pasich.encly.editor.state/TextRangeSelection // com.pasich.encly.editor.state/BlockEditorState.textRange.|(){}[0] + + final fun <#A1: kotlin/Any?> changeValue(kotlin/String, kotlinx.coroutines.flow/MutableStateFlow<#A1>, #A1, kotlin/Boolean, com.pasich.encly.editor.state/TypingStep? = ...) // com.pasich.encly.editor.state/BlockEditorState.changeValue|changeValue(kotlin.String;kotlinx.coroutines.flow.MutableStateFlow<0:0>;0:0;kotlin.Boolean;com.pasich.encly.editor.state.TypingStep?){0§}[0] + final fun addBlock(com.pasich.encly.editor.model/Block) // com.pasich.encly.editor.state/BlockEditorState.addBlock|addBlock(com.pasich.encly.editor.model.Block){}[0] + final fun load(kotlin.collections/List) // com.pasich.encly.editor.state/BlockEditorState.load|load(kotlin.collections.List){}[0] + final fun redo() // com.pasich.encly.editor.state/BlockEditorState.redo|redo(){}[0] + final fun rememberCopied(kotlin/String, kotlin.collections/List) // com.pasich.encly.editor.state/BlockEditorState.rememberCopied|rememberCopied(kotlin.String;kotlin.collections.List){}[0] + final fun removeBlock(com.pasich.encly.editor.model/Block, com.pasich.encly.editor.state/BlockRemoveAction, kotlin/Boolean = ...) // com.pasich.encly.editor.state/BlockEditorState.removeBlock|removeBlock(com.pasich.encly.editor.model.Block;com.pasich.encly.editor.state.BlockRemoveAction;kotlin.Boolean){}[0] + final fun replaceBlock(kotlin/Int, com.pasich.encly.editor.model/Block): kotlin/Boolean // com.pasich.encly.editor.state/BlockEditorState.replaceBlock|replaceBlock(kotlin.Int;com.pasich.encly.editor.model.Block){}[0] + final fun undo() // com.pasich.encly.editor.state/BlockEditorState.undo|undo(){}[0] +} + +final class com.pasich.encly.editor.state/BlockSelection { // com.pasich.encly.editor.state/BlockSelection|null[0] + constructor (kotlin.collections/List) // com.pasich.encly.editor.state/BlockSelection.|(kotlin.collections.List){}[0] + + final val focusedBlockId // com.pasich.encly.editor.state/BlockSelection.focusedBlockId|{}focusedBlockId[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.editor.state/BlockSelection.focusedBlockId.|(){}[0] + final val interactedBlockId // com.pasich.encly.editor.state/BlockSelection.interactedBlockId|{}interactedBlockId[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.editor.state/BlockSelection.interactedBlockId.|(){}[0] + final val interactedIndex // com.pasich.encly.editor.state/BlockSelection.interactedIndex|{}interactedIndex[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/BlockSelection.interactedIndex.|(){}[0] + final val requests // com.pasich.encly.editor.state/BlockSelection.requests|{}requests[0] + final fun (): kotlinx.coroutines.flow/Flow // com.pasich.encly.editor.state/BlockSelection.requests.|(){}[0] + + final var immediateFocus // com.pasich.encly.editor.state/BlockSelection.immediateFocus|{}immediateFocus[0] + final fun (): kotlin/Function1? // com.pasich.encly.editor.state/BlockSelection.immediateFocus.|(){}[0] + final fun (kotlin/Function1?) // com.pasich.encly.editor.state/BlockSelection.immediateFocus.|(kotlin.Function1?){}[0] + + final fun onCaret(kotlin/String, kotlin/Int) // com.pasich.encly.editor.state/BlockSelection.onCaret|onCaret(kotlin.String;kotlin.Int){}[0] + final fun onFocusLost(kotlin/String) // com.pasich.encly.editor.state/BlockSelection.onFocusLost|onFocusLost(kotlin.String){}[0] + final fun onFocused(kotlin/String) // com.pasich.encly.editor.state/BlockSelection.onFocused|onFocused(kotlin.String){}[0] + final fun onInteraction(kotlin/String) // com.pasich.encly.editor.state/BlockSelection.onInteraction|onInteraction(kotlin.String){}[0] + final fun onItemCaret(kotlin/String, kotlin/String, kotlin/Int) // com.pasich.encly.editor.state/BlockSelection.onItemCaret|onItemCaret(kotlin.String;kotlin.String;kotlin.Int){}[0] +} + +final class com.pasich.encly.editor.state/FindMatch { // com.pasich.encly.editor.state/FindMatch|null[0] + constructor (kotlin/String, kotlin/String?, kotlin/Int, kotlin/Int) // com.pasich.encly.editor.state/FindMatch.|(kotlin.String;kotlin.String?;kotlin.Int;kotlin.Int){}[0] + + final val blockId // com.pasich.encly.editor.state/FindMatch.blockId|{}blockId[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/FindMatch.blockId.|(){}[0] + final val end // com.pasich.encly.editor.state/FindMatch.end|{}end[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/FindMatch.end.|(){}[0] + final val field // com.pasich.encly.editor.state/FindMatch.field|{}field[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/FindMatch.field.|(){}[0] + final val itemId // com.pasich.encly.editor.state/FindMatch.itemId|{}itemId[0] + final fun (): kotlin/String? // com.pasich.encly.editor.state/FindMatch.itemId.|(){}[0] + final val start // com.pasich.encly.editor.state/FindMatch.start|{}start[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/FindMatch.start.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.state/FindMatch.component1|component1(){}[0] + final fun component2(): kotlin/String? // com.pasich.encly.editor.state/FindMatch.component2|component2(){}[0] + final fun component3(): kotlin/Int // com.pasich.encly.editor.state/FindMatch.component3|component3(){}[0] + final fun component4(): kotlin/Int // com.pasich.encly.editor.state/FindMatch.component4|component4(){}[0] + final fun copy(kotlin/String = ..., kotlin/String? = ..., kotlin/Int = ..., kotlin/Int = ...): com.pasich.encly.editor.state/FindMatch // com.pasich.encly.editor.state/FindMatch.copy|copy(kotlin.String;kotlin.String?;kotlin.Int;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/FindMatch.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/FindMatch.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/FindMatch.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/FindState { // com.pasich.encly.editor.state/FindState|null[0] + constructor (kotlin/Boolean = ..., kotlin/String = ..., kotlin.collections/List = ..., kotlin/Int = ...) // com.pasich.encly.editor.state/FindState.|(kotlin.Boolean;kotlin.String;kotlin.collections.List;kotlin.Int){}[0] + + final val current // com.pasich.encly.editor.state/FindState.current|{}current[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/FindState.current.|(){}[0] + final val currentMatch // com.pasich.encly.editor.state/FindState.currentMatch|{}currentMatch[0] + final fun (): com.pasich.encly.editor.state/FindMatch? // com.pasich.encly.editor.state/FindState.currentMatch.|(){}[0] + final val isOpen // com.pasich.encly.editor.state/FindState.isOpen|{}isOpen[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/FindState.isOpen.|(){}[0] + final val matches // com.pasich.encly.editor.state/FindState.matches|{}matches[0] + final fun (): kotlin.collections/List // com.pasich.encly.editor.state/FindState.matches.|(){}[0] + final val query // com.pasich.encly.editor.state/FindState.query|{}query[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/FindState.query.|(){}[0] + + final fun component1(): kotlin/Boolean // com.pasich.encly.editor.state/FindState.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.state/FindState.component2|component2(){}[0] + final fun component3(): kotlin.collections/List // com.pasich.encly.editor.state/FindState.component3|component3(){}[0] + final fun component4(): kotlin/Int // com.pasich.encly.editor.state/FindState.component4|component4(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin/String = ..., kotlin.collections/List = ..., kotlin/Int = ...): com.pasich.encly.editor.state/FindState // com.pasich.encly.editor.state/FindState.copy|copy(kotlin.Boolean;kotlin.String;kotlin.collections.List;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/FindState.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/FindState.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/FindState.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/FocusRequest { // com.pasich.encly.editor.state/FocusRequest|null[0] + constructor (kotlin/String, kotlin/Boolean = ..., kotlin/Int? = ..., kotlin/String? = ..., kotlin/Float? = ..., kotlin/Boolean = ...) // com.pasich.encly.editor.state/FocusRequest.|(kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.String?;kotlin.Float?;kotlin.Boolean){}[0] + + final val blockId // com.pasich.encly.editor.state/FocusRequest.blockId|{}blockId[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/FocusRequest.blockId.|(){}[0] + final val caret // com.pasich.encly.editor.state/FocusRequest.caret|{}caret[0] + final fun (): kotlin/Int? // com.pasich.encly.editor.state/FocusRequest.caret.|(){}[0] + final val caretOffset // com.pasich.encly.editor.state/FocusRequest.caretOffset|{}caretOffset[0] + final fun (): kotlin/Int? // com.pasich.encly.editor.state/FocusRequest.caretOffset.|(){}[0] + final val caretX // com.pasich.encly.editor.state/FocusRequest.caretX|{}caretX[0] + final fun (): kotlin/Float? // com.pasich.encly.editor.state/FocusRequest.caretX.|(){}[0] + final val cursorToEnd // com.pasich.encly.editor.state/FocusRequest.cursorToEnd|{}cursorToEnd[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/FocusRequest.cursorToEnd.|(){}[0] + final val fromBelow // com.pasich.encly.editor.state/FocusRequest.fromBelow|{}fromBelow[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/FocusRequest.fromBelow.|(){}[0] + final val itemId // com.pasich.encly.editor.state/FocusRequest.itemId|{}itemId[0] + final fun (): kotlin/String? // com.pasich.encly.editor.state/FocusRequest.itemId.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.state/FocusRequest.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // com.pasich.encly.editor.state/FocusRequest.component2|component2(){}[0] + final fun component3(): kotlin/Int? // com.pasich.encly.editor.state/FocusRequest.component3|component3(){}[0] + final fun component4(): kotlin/String? // com.pasich.encly.editor.state/FocusRequest.component4|component4(){}[0] + final fun component5(): kotlin/Float? // com.pasich.encly.editor.state/FocusRequest.component5|component5(){}[0] + final fun component6(): kotlin/Boolean // com.pasich.encly.editor.state/FocusRequest.component6|component6(){}[0] + final fun copy(kotlin/String = ..., kotlin/Boolean = ..., kotlin/Int? = ..., kotlin/String? = ..., kotlin/Float? = ..., kotlin/Boolean = ...): com.pasich.encly.editor.state/FocusRequest // com.pasich.encly.editor.state/FocusRequest.copy|copy(kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.String?;kotlin.Float?;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/FocusRequest.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/FocusRequest.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/FocusRequest.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/LineBreak { // com.pasich.encly.editor.state/LineBreak|null[0] + constructor (kotlin/String, kotlin.collections/List, kotlin/String) // com.pasich.encly.editor.state/LineBreak.|(kotlin.String;kotlin.collections.List;kotlin.String){}[0] + + final val head // com.pasich.encly.editor.state/LineBreak.head|{}head[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/LineBreak.head.|(){}[0] + final val isEnter // com.pasich.encly.editor.state/LineBreak.isEnter|{}isEnter[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/LineBreak.isEnter.|(){}[0] + final val lines // com.pasich.encly.editor.state/LineBreak.lines|{}lines[0] + final fun (): kotlin.collections/List // com.pasich.encly.editor.state/LineBreak.lines.|(){}[0] + final val tail // com.pasich.encly.editor.state/LineBreak.tail|{}tail[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/LineBreak.tail.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.state/LineBreak.component1|component1(){}[0] + final fun component2(): kotlin.collections/List // com.pasich.encly.editor.state/LineBreak.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.editor.state/LineBreak.component3|component3(){}[0] + final fun copy(kotlin/String = ..., kotlin.collections/List = ..., kotlin/String = ...): com.pasich.encly.editor.state/LineBreak // com.pasich.encly.editor.state/LineBreak.copy|copy(kotlin.String;kotlin.collections.List;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/LineBreak.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/LineBreak.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/LineBreak.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/NoteFind { // com.pasich.encly.editor.state/NoteFind|null[0] + constructor () // com.pasich.encly.editor.state/NoteFind.|(){}[0] + + final val state // com.pasich.encly.editor.state/NoteFind.state|{}state[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.editor.state/NoteFind.state.|(){}[0] + + final fun close() // com.pasich.encly.editor.state/NoteFind.close|close(){}[0] + final fun next() // com.pasich.encly.editor.state/NoteFind.next|next(){}[0] + final fun open() // com.pasich.encly.editor.state/NoteFind.open|open(){}[0] + final fun previous() // com.pasich.encly.editor.state/NoteFind.previous|previous(){}[0] + final fun refresh(kotlin.collections/List) // com.pasich.encly.editor.state/NoteFind.refresh|refresh(kotlin.collections.List){}[0] + final fun search(kotlin/String, kotlin.collections/List) // com.pasich.encly.editor.state/NoteFind.search|search(kotlin.String;kotlin.collections.List){}[0] +} + +final class com.pasich.encly.editor.state/SelectedBlocks { // com.pasich.encly.editor.state/SelectedBlocks|null[0] + constructor (kotlin.collections/List) // com.pasich.encly.editor.state/SelectedBlocks.|(kotlin.collections.List){}[0] + + final val count // com.pasich.encly.editor.state/SelectedBlocks.count|{}count[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/SelectedBlocks.count.|(){}[0] + final val ids // com.pasich.encly.editor.state/SelectedBlocks.ids|{}ids[0] + final fun (): kotlinx.coroutines.flow/StateFlow> // com.pasich.encly.editor.state/SelectedBlocks.ids.|(){}[0] + final val isActive // com.pasich.encly.editor.state/SelectedBlocks.isActive|{}isActive[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/SelectedBlocks.isActive.|(){}[0] + + final fun clear() // com.pasich.encly.editor.state/SelectedBlocks.clear|clear(){}[0] + final fun extend(kotlin/Boolean) // com.pasich.encly.editor.state/SelectedBlocks.extend|extend(kotlin.Boolean){}[0] + final fun picked(): kotlin.collections/List // com.pasich.encly.editor.state/SelectedBlocks.picked|picked(){}[0] + final fun select(kotlin/String) // com.pasich.encly.editor.state/SelectedBlocks.select|select(kotlin.String){}[0] + final fun selectAll() // com.pasich.encly.editor.state/SelectedBlocks.selectAll|selectAll(){}[0] + final fun toggle(kotlin/String) // com.pasich.encly.editor.state/SelectedBlocks.toggle|toggle(kotlin.String){}[0] +} + +final class com.pasich.encly.editor.state/Shortcut { // com.pasich.encly.editor.state/Shortcut|null[0] + constructor (com.pasich.encly.editor.model/BlockType, kotlin/String, kotlin/Boolean = ...) // com.pasich.encly.editor.state/Shortcut.|(com.pasich.encly.editor.model.BlockType;kotlin.String;kotlin.Boolean){}[0] + + final val checked // com.pasich.encly.editor.state/Shortcut.checked|{}checked[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/Shortcut.checked.|(){}[0] + final val marker // com.pasich.encly.editor.state/Shortcut.marker|{}marker[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/Shortcut.marker.|(){}[0] + final val type // com.pasich.encly.editor.state/Shortcut.type|{}type[0] + final fun (): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.state/Shortcut.type.|(){}[0] + + final fun component1(): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.state/Shortcut.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.state/Shortcut.component2|component2(){}[0] + final fun component3(): kotlin/Boolean // com.pasich.encly.editor.state/Shortcut.component3|component3(){}[0] + final fun copy(com.pasich.encly.editor.model/BlockType = ..., kotlin/String = ..., kotlin/Boolean = ...): com.pasich.encly.editor.state/Shortcut // com.pasich.encly.editor.state/Shortcut.copy|copy(com.pasich.encly.editor.model.BlockType;kotlin.String;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/Shortcut.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/Shortcut.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/Shortcut.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/SlashMenu { // com.pasich.encly.editor.state/SlashMenu|null[0] + constructor (kotlin/String, kotlin/String, kotlin.collections/List, kotlin/Int, kotlin/Boolean) // com.pasich.encly.editor.state/SlashMenu.|(kotlin.String;kotlin.String;kotlin.collections.List;kotlin.Int;kotlin.Boolean){}[0] + + final val blockId // com.pasich.encly.editor.state/SlashMenu.blockId|{}blockId[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/SlashMenu.blockId.|(){}[0] + final val committed // com.pasich.encly.editor.state/SlashMenu.committed|{}committed[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/SlashMenu.committed.|(){}[0] + final val highlighted // com.pasich.encly.editor.state/SlashMenu.highlighted|{}highlighted[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/SlashMenu.highlighted.|(){}[0] + final val highlightedCommand // com.pasich.encly.editor.state/SlashMenu.highlightedCommand|{}highlightedCommand[0] + final fun (): com.pasich.encly.editor.state/SlashCommand? // com.pasich.encly.editor.state/SlashMenu.highlightedCommand.|(){}[0] + final val isShown // com.pasich.encly.editor.state/SlashMenu.isShown|{}isShown[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/SlashMenu.isShown.|(){}[0] + final val matches // com.pasich.encly.editor.state/SlashMenu.matches|{}matches[0] + final fun (): kotlin.collections/List // com.pasich.encly.editor.state/SlashMenu.matches.|(){}[0] + final val query // com.pasich.encly.editor.state/SlashMenu.query|{}query[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/SlashMenu.query.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.state/SlashMenu.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.state/SlashMenu.component2|component2(){}[0] + final fun component3(): kotlin.collections/List // com.pasich.encly.editor.state/SlashMenu.component3|component3(){}[0] + final fun component4(): kotlin/Int // com.pasich.encly.editor.state/SlashMenu.component4|component4(){}[0] + final fun component5(): kotlin/Boolean // com.pasich.encly.editor.state/SlashMenu.component5|component5(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin.collections/List = ..., kotlin/Int = ..., kotlin/Boolean = ...): com.pasich.encly.editor.state/SlashMenu // com.pasich.encly.editor.state/SlashMenu.copy|copy(kotlin.String;kotlin.String;kotlin.collections.List;kotlin.Int;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/SlashMenu.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/SlashMenu.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/SlashMenu.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/SlashPicker { // com.pasich.encly.editor.state/SlashPicker|null[0] + constructor () // com.pasich.encly.editor.state/SlashPicker.|(){}[0] + + final val menu // com.pasich.encly.editor.state/SlashPicker.menu|{}menu[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.editor.state/SlashPicker.menu.|(){}[0] + + final fun commit() // com.pasich.encly.editor.state/SlashPicker.commit|commit(){}[0] + final fun dismiss() // com.pasich.encly.editor.state/SlashPicker.dismiss|dismiss(){}[0] + final fun moveHighlight(kotlin/Int) // com.pasich.encly.editor.state/SlashPicker.moveHighlight|moveHighlight(kotlin.Int){}[0] + final fun onText(kotlin/String, kotlin/String) // com.pasich.encly.editor.state/SlashPicker.onText|onText(kotlin.String;kotlin.String){}[0] + final fun open(kotlin/String) // com.pasich.encly.editor.state/SlashPicker.open|open(kotlin.String){}[0] +} + +final class com.pasich.encly.editor.state/TextDiff { // com.pasich.encly.editor.state/TextDiff|null[0] + constructor (kotlin/Int, kotlin/String, kotlin/String) // com.pasich.encly.editor.state/TextDiff.|(kotlin.Int;kotlin.String;kotlin.String){}[0] + + final val end // com.pasich.encly.editor.state/TextDiff.end|{}end[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/TextDiff.end.|(){}[0] + final val inserted // com.pasich.encly.editor.state/TextDiff.inserted|{}inserted[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/TextDiff.inserted.|(){}[0] + final val removed // com.pasich.encly.editor.state/TextDiff.removed|{}removed[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/TextDiff.removed.|(){}[0] + final val start // com.pasich.encly.editor.state/TextDiff.start|{}start[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/TextDiff.start.|(){}[0] + + final fun component1(): kotlin/Int // com.pasich.encly.editor.state/TextDiff.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.state/TextDiff.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.editor.state/TextDiff.component3|component3(){}[0] + final fun copy(kotlin/Int = ..., kotlin/String = ..., kotlin/String = ...): com.pasich.encly.editor.state/TextDiff // com.pasich.encly.editor.state/TextDiff.copy|copy(kotlin.Int;kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/TextDiff.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/TextDiff.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/TextDiff.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/TextPoint { // com.pasich.encly.editor.state/TextPoint|null[0] + constructor (kotlin/String, kotlin/Int, kotlin/String? = ...) // com.pasich.encly.editor.state/TextPoint.|(kotlin.String;kotlin.Int;kotlin.String?){}[0] + + final val blockId // com.pasich.encly.editor.state/TextPoint.blockId|{}blockId[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/TextPoint.blockId.|(){}[0] + final val itemId // com.pasich.encly.editor.state/TextPoint.itemId|{}itemId[0] + final fun (): kotlin/String? // com.pasich.encly.editor.state/TextPoint.itemId.|(){}[0] + final val offset // com.pasich.encly.editor.state/TextPoint.offset|{}offset[0] + final fun (): kotlin/Int // com.pasich.encly.editor.state/TextPoint.offset.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.state/TextPoint.component1|component1(){}[0] + final fun component2(): kotlin/Int // com.pasich.encly.editor.state/TextPoint.component2|component2(){}[0] + final fun component3(): kotlin/String? // com.pasich.encly.editor.state/TextPoint.component3|component3(){}[0] + final fun copy(kotlin/String = ..., kotlin/Int = ..., kotlin/String? = ...): com.pasich.encly.editor.state/TextPoint // com.pasich.encly.editor.state/TextPoint.copy|copy(kotlin.String;kotlin.Int;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/TextPoint.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/TextPoint.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/TextPoint.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/TextRangeEnds { // com.pasich.encly.editor.state/TextRangeEnds|null[0] + constructor (com.pasich.encly.editor.state/TextPoint, com.pasich.encly.editor.state/TextPoint) // com.pasich.encly.editor.state/TextRangeEnds.|(com.pasich.encly.editor.state.TextPoint;com.pasich.encly.editor.state.TextPoint){}[0] + + final val anchor // com.pasich.encly.editor.state/TextRangeEnds.anchor|{}anchor[0] + final fun (): com.pasich.encly.editor.state/TextPoint // com.pasich.encly.editor.state/TextRangeEnds.anchor.|(){}[0] + final val head // com.pasich.encly.editor.state/TextRangeEnds.head|{}head[0] + final fun (): com.pasich.encly.editor.state/TextPoint // com.pasich.encly.editor.state/TextRangeEnds.head.|(){}[0] + + final fun component1(): com.pasich.encly.editor.state/TextPoint // com.pasich.encly.editor.state/TextRangeEnds.component1|component1(){}[0] + final fun component2(): com.pasich.encly.editor.state/TextPoint // com.pasich.encly.editor.state/TextRangeEnds.component2|component2(){}[0] + final fun copy(com.pasich.encly.editor.state/TextPoint = ..., com.pasich.encly.editor.state/TextPoint = ...): com.pasich.encly.editor.state/TextRangeEnds // com.pasich.encly.editor.state/TextRangeEnds.copy|copy(com.pasich.encly.editor.state.TextPoint;com.pasich.encly.editor.state.TextPoint){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/TextRangeEnds.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/TextRangeEnds.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/TextRangeEnds.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.state/TextRangeSelection { // com.pasich.encly.editor.state/TextRangeSelection|null[0] + constructor (kotlin.collections/List) // com.pasich.encly.editor.state/TextRangeSelection.|(kotlin.collections.List){}[0] + + final val ends // com.pasich.encly.editor.state/TextRangeSelection.ends|{}ends[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.pasich.encly.editor.state/TextRangeSelection.ends.|(){}[0] + final val isActive // com.pasich.encly.editor.state/TextRangeSelection.isActive|{}isActive[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/TextRangeSelection.isActive.|(){}[0] + final val isCrossField // com.pasich.encly.editor.state/TextRangeSelection.isCrossField|{}isCrossField[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/TextRangeSelection.isCrossField.|(){}[0] + + final fun blockCount(): kotlin/Int // com.pasich.encly.editor.state/TextRangeSelection.blockCount|blockCount(){}[0] + final fun clear() // com.pasich.encly.editor.state/TextRangeSelection.clear|clear(){}[0] + final fun coverOf(kotlin/String, kotlin/String?, kotlin/Int): kotlin/Pair? // com.pasich.encly.editor.state/TextRangeSelection.coverOf|coverOf(kotlin.String;kotlin.String?;kotlin.Int){}[0] + final fun extend(com.pasich.encly.editor.state/HeadMove, com.pasich.encly.editor.state/TextPoint, com.pasich.encly.editor.state/TextPoint, kotlin/Boolean): kotlin/Boolean // com.pasich.encly.editor.state/TextRangeSelection.extend|extend(com.pasich.encly.editor.state.HeadMove;com.pasich.encly.editor.state.TextPoint;com.pasich.encly.editor.state.TextPoint;kotlin.Boolean){}[0] + final fun extendTo(com.pasich.encly.editor.state/TextPoint) // com.pasich.encly.editor.state/TextRangeSelection.extendTo|extendTo(com.pasich.encly.editor.state.TextPoint){}[0] + final fun moveHead(com.pasich.encly.editor.state/HeadMove) // com.pasich.encly.editor.state/TextRangeSelection.moveHead|moveHead(com.pasich.encly.editor.state.HeadMove){}[0] + final fun orderedPoints(): kotlin/Pair? // com.pasich.encly.editor.state/TextRangeSelection.orderedPoints|orderedPoints(){}[0] + final fun select(com.pasich.encly.editor.state/TextPoint, com.pasich.encly.editor.state/TextPoint) // com.pasich.encly.editor.state/TextRangeSelection.select|select(com.pasich.encly.editor.state.TextPoint;com.pasich.encly.editor.state.TextPoint){}[0] + final fun selectAll() // com.pasich.encly.editor.state/TextRangeSelection.selectAll|selectAll(){}[0] +} + +final class com.pasich.encly.editor.state/TypingStep { // com.pasich.encly.editor.state/TypingStep|null[0] + constructor (kotlin/String, com.pasich.encly.editor.state/TypingKind, kotlin/Boolean) // com.pasich.encly.editor.state/TypingStep.|(kotlin.String;com.pasich.encly.editor.state.TypingKind;kotlin.Boolean){}[0] + + final val field // com.pasich.encly.editor.state/TypingStep.field|{}field[0] + final fun (): kotlin/String // com.pasich.encly.editor.state/TypingStep.field.|(){}[0] + final val kind // com.pasich.encly.editor.state/TypingStep.kind|{}kind[0] + final fun (): com.pasich.encly.editor.state/TypingKind // com.pasich.encly.editor.state/TypingStep.kind.|(){}[0] + final val startsWord // com.pasich.encly.editor.state/TypingStep.startsWord|{}startsWord[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.state/TypingStep.startsWord.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.state/TypingStep.component1|component1(){}[0] + final fun component2(): com.pasich.encly.editor.state/TypingKind // com.pasich.encly.editor.state/TypingStep.component2|component2(){}[0] + final fun component3(): kotlin/Boolean // com.pasich.encly.editor.state/TypingStep.component3|component3(){}[0] + final fun copy(kotlin/String = ..., com.pasich.encly.editor.state/TypingKind = ..., kotlin/Boolean = ...): com.pasich.encly.editor.state/TypingStep // com.pasich.encly.editor.state/TypingStep.copy|copy(kotlin.String;com.pasich.encly.editor.state.TypingKind;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.state/TypingStep.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.state/TypingStep.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.state/TypingStep.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.theme/EditorFontFamilies { // com.pasich.encly.editor.theme/EditorFontFamilies|null[0] + constructor (androidx.compose.ui.text.font/FontFamily = ..., androidx.compose.ui.text.font/FontFamily = ...) // com.pasich.encly.editor.theme/EditorFontFamilies.|(androidx.compose.ui.text.font.FontFamily;androidx.compose.ui.text.font.FontFamily){}[0] + + final val body // com.pasich.encly.editor.theme/EditorFontFamilies.body|{}body[0] + final fun (): androidx.compose.ui.text.font/FontFamily // com.pasich.encly.editor.theme/EditorFontFamilies.body.|(){}[0] + final val heading // com.pasich.encly.editor.theme/EditorFontFamilies.heading|{}heading[0] + final fun (): androidx.compose.ui.text.font/FontFamily // com.pasich.encly.editor.theme/EditorFontFamilies.heading.|(){}[0] + + final fun component1(): androidx.compose.ui.text.font/FontFamily // com.pasich.encly.editor.theme/EditorFontFamilies.component1|component1(){}[0] + final fun component2(): androidx.compose.ui.text.font/FontFamily // com.pasich.encly.editor.theme/EditorFontFamilies.component2|component2(){}[0] + final fun copy(androidx.compose.ui.text.font/FontFamily = ..., androidx.compose.ui.text.font/FontFamily = ...): com.pasich.encly.editor.theme/EditorFontFamilies // com.pasich.encly.editor.theme/EditorFontFamilies.copy|copy(androidx.compose.ui.text.font.FontFamily;androidx.compose.ui.text.font.FontFamily){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.theme/EditorFontFamilies.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.theme/EditorFontFamilies.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.theme/EditorFontFamilies.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.theme/EditorFontSizes { // com.pasich.encly.editor.theme/EditorFontSizes|null[0] + constructor (androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ...) // com.pasich.encly.editor.theme/EditorFontSizes.|(androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit){}[0] + + final val h1 // com.pasich.encly.editor.theme/EditorFontSizes.h1|{}h1[0] + final fun (): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.h1.|(){}[0] + final val h2 // com.pasich.encly.editor.theme/EditorFontSizes.h2|{}h2[0] + final fun (): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.h2.|(){}[0] + final val h3 // com.pasich.encly.editor.theme/EditorFontSizes.h3|{}h3[0] + final fun (): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.h3.|(){}[0] + final val h4 // com.pasich.encly.editor.theme/EditorFontSizes.h4|{}h4[0] + final fun (): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.h4.|(){}[0] + final val list // com.pasich.encly.editor.theme/EditorFontSizes.list|{}list[0] + final fun (): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.list.|(){}[0] + final val quote // com.pasich.encly.editor.theme/EditorFontSizes.quote|{}quote[0] + final fun (): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.quote.|(){}[0] + final val textBlock // com.pasich.encly.editor.theme/EditorFontSizes.textBlock|{}textBlock[0] + final fun (): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.textBlock.|(){}[0] + + final fun component1(): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.component1|component1(){}[0] + final fun component2(): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.component2|component2(){}[0] + final fun component3(): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.component3|component3(){}[0] + final fun component4(): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.component4|component4(){}[0] + final fun component5(): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.component5|component5(){}[0] + final fun component6(): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.component6|component6(){}[0] + final fun component7(): androidx.compose.ui.unit/TextUnit // com.pasich.encly.editor.theme/EditorFontSizes.component7|component7(){}[0] + final fun copy(androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ..., androidx.compose.ui.unit/TextUnit = ...): com.pasich.encly.editor.theme/EditorFontSizes // com.pasich.encly.editor.theme/EditorFontSizes.copy|copy(androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.unit.TextUnit){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.theme/EditorFontSizes.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.theme/EditorFontSizes.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.theme/EditorFontSizes.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.theme/EditorIcons { // com.pasich.encly.editor.theme/EditorIcons|null[0] + constructor (androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ..., androidx.compose.ui.graphics.vector/ImageVector = ...) // com.pasich.encly.editor.theme/EditorIcons.|(androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector;androidx.compose.ui.graphics.vector.ImageVector){}[0] +} + +final class com.pasich.encly.editor.theme/EditorSpacing { // com.pasich.encly.editor.theme/EditorSpacing|null[0] + constructor (androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ...) // com.pasich.encly.editor.theme/EditorSpacing.|(androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp){}[0] + + final val buttonHeight // com.pasich.encly.editor.theme/EditorSpacing.buttonHeight|{}buttonHeight[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.buttonHeight.|(){}[0] + final val fieldGap // com.pasich.encly.editor.theme/EditorSpacing.fieldGap|{}fieldGap[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.fieldGap.|(){}[0] + final val gutter // com.pasich.encly.editor.theme/EditorSpacing.gutter|{}gutter[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.gutter.|(){}[0] + final val hairline // com.pasich.encly.editor.theme/EditorSpacing.hairline|{}hairline[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.hairline.|(){}[0] + final val icon // com.pasich.encly.editor.theme/EditorSpacing.icon|{}icon[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.icon.|(){}[0] + final val iconMedium // com.pasich.encly.editor.theme/EditorSpacing.iconMedium|{}iconMedium[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.iconMedium.|(){}[0] + final val iconSmall // com.pasich.encly.editor.theme/EditorSpacing.iconSmall|{}iconSmall[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.iconSmall.|(){}[0] + final val l // com.pasich.encly.editor.theme/EditorSpacing.l|{}l[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.l.|(){}[0] + final val labelGap // com.pasich.encly.editor.theme/EditorSpacing.labelGap|{}labelGap[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.labelGap.|(){}[0] + final val m // com.pasich.encly.editor.theme/EditorSpacing.m|{}m[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.m.|(){}[0] + final val minTouchTarget // com.pasich.encly.editor.theme/EditorSpacing.minTouchTarget|{}minTouchTarget[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.minTouchTarget.|(){}[0] + final val rowGap // com.pasich.encly.editor.theme/EditorSpacing.rowGap|{}rowGap[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.rowGap.|(){}[0] + final val rowHeight // com.pasich.encly.editor.theme/EditorSpacing.rowHeight|{}rowHeight[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.rowHeight.|(){}[0] + final val s // com.pasich.encly.editor.theme/EditorSpacing.s|{}s[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.s.|(){}[0] + final val stroke // com.pasich.encly.editor.theme/EditorSpacing.stroke|{}stroke[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.stroke.|(){}[0] + final val tapArea // com.pasich.encly.editor.theme/EditorSpacing.tapArea|{}tapArea[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.tapArea.|(){}[0] + final val textGap // com.pasich.encly.editor.theme/EditorSpacing.textGap|{}textGap[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.textGap.|(){}[0] + final val tileSmall // com.pasich.encly.editor.theme/EditorSpacing.tileSmall|{}tileSmall[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.tileSmall.|(){}[0] + final val xs // com.pasich.encly.editor.theme/EditorSpacing.xs|{}xs[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.xs.|(){}[0] + final val xxs // com.pasich.encly.editor.theme/EditorSpacing.xxs|{}xxs[0] + final fun (): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.xxs.|(){}[0] + + final fun component1(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component1|component1(){}[0] + final fun component10(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component10|component10(){}[0] + final fun component11(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component11|component11(){}[0] + final fun component12(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component12|component12(){}[0] + final fun component13(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component13|component13(){}[0] + final fun component14(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component14|component14(){}[0] + final fun component15(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component15|component15(){}[0] + final fun component16(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component16|component16(){}[0] + final fun component17(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component17|component17(){}[0] + final fun component18(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component18|component18(){}[0] + final fun component19(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component19|component19(){}[0] + final fun component2(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component2|component2(){}[0] + final fun component20(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component20|component20(){}[0] + final fun component3(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component3|component3(){}[0] + final fun component4(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component4|component4(){}[0] + final fun component5(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component5|component5(){}[0] + final fun component6(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component6|component6(){}[0] + final fun component7(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component7|component7(){}[0] + final fun component8(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component8|component8(){}[0] + final fun component9(): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/EditorSpacing.component9|component9(){}[0] + final fun copy(androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ..., androidx.compose.ui.unit/Dp = ...): com.pasich.encly.editor.theme/EditorSpacing // com.pasich.encly.editor.theme/EditorSpacing.copy|copy(androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.theme/EditorSpacing.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.theme/EditorSpacing.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.theme/EditorSpacing.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.theme/EditorTheme { // com.pasich.encly.editor.theme/EditorTheme|null[0] + constructor (com.pasich.encly.editor.theme/EditorSpacing = ..., com.pasich.encly.editor.theme/EditorFontSizes = ..., com.pasich.encly.editor.theme/EditorFontFamilies = ..., com.pasich.encly.editor.theme/EditorLineSpacing = ..., androidx.compose.ui.text/TextStyle = ..., androidx.compose.ui.text/TextStyle = ..., com.pasich.encly.editor.theme/EditorIcons = ...) // com.pasich.encly.editor.theme/EditorTheme.|(com.pasich.encly.editor.theme.EditorSpacing;com.pasich.encly.editor.theme.EditorFontSizes;com.pasich.encly.editor.theme.EditorFontFamilies;com.pasich.encly.editor.theme.EditorLineSpacing;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;com.pasich.encly.editor.theme.EditorIcons){}[0] + + final val dataStyle // com.pasich.encly.editor.theme/EditorTheme.dataStyle|{}dataStyle[0] + final fun (): androidx.compose.ui.text/TextStyle // com.pasich.encly.editor.theme/EditorTheme.dataStyle.|(){}[0] + final val fontFamilies // com.pasich.encly.editor.theme/EditorTheme.fontFamilies|{}fontFamilies[0] + final fun (): com.pasich.encly.editor.theme/EditorFontFamilies // com.pasich.encly.editor.theme/EditorTheme.fontFamilies.|(){}[0] + final val fontSizes // com.pasich.encly.editor.theme/EditorTheme.fontSizes|{}fontSizes[0] + final fun (): com.pasich.encly.editor.theme/EditorFontSizes // com.pasich.encly.editor.theme/EditorTheme.fontSizes.|(){}[0] + final val icons // com.pasich.encly.editor.theme/EditorTheme.icons|{}icons[0] + final fun (): com.pasich.encly.editor.theme/EditorIcons // com.pasich.encly.editor.theme/EditorTheme.icons.|(){}[0] + final val lineSpacing // com.pasich.encly.editor.theme/EditorTheme.lineSpacing|{}lineSpacing[0] + final fun (): com.pasich.encly.editor.theme/EditorLineSpacing // com.pasich.encly.editor.theme/EditorTheme.lineSpacing.|(){}[0] + final val quoteStyle // com.pasich.encly.editor.theme/EditorTheme.quoteStyle|{}quoteStyle[0] + final fun (): androidx.compose.ui.text/TextStyle // com.pasich.encly.editor.theme/EditorTheme.quoteStyle.|(){}[0] + final val spacing // com.pasich.encly.editor.theme/EditorTheme.spacing|{}spacing[0] + final fun (): com.pasich.encly.editor.theme/EditorSpacing // com.pasich.encly.editor.theme/EditorTheme.spacing.|(){}[0] + + final fun component1(): com.pasich.encly.editor.theme/EditorSpacing // com.pasich.encly.editor.theme/EditorTheme.component1|component1(){}[0] + final fun component2(): com.pasich.encly.editor.theme/EditorFontSizes // com.pasich.encly.editor.theme/EditorTheme.component2|component2(){}[0] + final fun component3(): com.pasich.encly.editor.theme/EditorFontFamilies // com.pasich.encly.editor.theme/EditorTheme.component3|component3(){}[0] + final fun component4(): com.pasich.encly.editor.theme/EditorLineSpacing // com.pasich.encly.editor.theme/EditorTheme.component4|component4(){}[0] + final fun component5(): androidx.compose.ui.text/TextStyle // com.pasich.encly.editor.theme/EditorTheme.component5|component5(){}[0] + final fun component6(): androidx.compose.ui.text/TextStyle // com.pasich.encly.editor.theme/EditorTheme.component6|component6(){}[0] + final fun component7(): com.pasich.encly.editor.theme/EditorIcons // com.pasich.encly.editor.theme/EditorTheme.component7|component7(){}[0] + final fun copy(com.pasich.encly.editor.theme/EditorSpacing = ..., com.pasich.encly.editor.theme/EditorFontSizes = ..., com.pasich.encly.editor.theme/EditorFontFamilies = ..., com.pasich.encly.editor.theme/EditorLineSpacing = ..., androidx.compose.ui.text/TextStyle = ..., androidx.compose.ui.text/TextStyle = ..., com.pasich.encly.editor.theme/EditorIcons = ...): com.pasich.encly.editor.theme/EditorTheme // com.pasich.encly.editor.theme/EditorTheme.copy|copy(com.pasich.encly.editor.theme.EditorSpacing;com.pasich.encly.editor.theme.EditorFontSizes;com.pasich.encly.editor.theme.EditorFontFamilies;com.pasich.encly.editor.theme.EditorLineSpacing;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;com.pasich.encly.editor.theme.EditorIcons){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.theme/EditorTheme.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.theme/EditorTheme.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.theme/EditorTheme.toString|toString(){}[0] +} + +final class com.pasich.encly.editor.ui/EditorBlocksHost { // com.pasich.encly.editor.ui/EditorBlocksHost|null[0] + constructor (com.pasich.encly.editor.ui/EditorController, com.pasich.encly.editor.focus/BlockFocusRegistry, com.pasich.encly.editor.ui/FindHighlights, kotlin/Function2) // com.pasich.encly.editor.ui/EditorBlocksHost.|(com.pasich.encly.editor.ui.EditorController;com.pasich.encly.editor.focus.BlockFocusRegistry;com.pasich.encly.editor.ui.FindHighlights;kotlin.Function2){}[0] +} + +final class com.pasich.encly.editor.ui/FindHighlights { // com.pasich.encly.editor.ui/FindHighlights|null[0] + constructor (androidx.compose.runtime/State) // com.pasich.encly.editor.ui/FindHighlights.|(androidx.compose.runtime.State){}[0] + + final suspend fun reveal(com.pasich.encly.editor.state/FindMatch): kotlin/Boolean // com.pasich.encly.editor.ui/FindHighlights.reveal|reveal(com.pasich.encly.editor.state.FindMatch){}[0] +} + +final class com.pasich.encly.editor.ui/SelectedBlocksActions { // com.pasich.encly.editor.ui/SelectedBlocksActions|null[0] + constructor (kotlin/Boolean, kotlin/Function0, kotlin/Function0, kotlin/Function0, kotlin/Function0) // com.pasich.encly.editor.ui/SelectedBlocksActions.|(kotlin.Boolean;kotlin.Function0;kotlin.Function0;kotlin.Function0;kotlin.Function0){}[0] + + final val canEdit // com.pasich.encly.editor.ui/SelectedBlocksActions.canEdit|{}canEdit[0] + final fun (): kotlin/Boolean // com.pasich.encly.editor.ui/SelectedBlocksActions.canEdit.|(){}[0] + final val onCopy // com.pasich.encly.editor.ui/SelectedBlocksActions.onCopy|{}onCopy[0] + final fun (): kotlin/Function0 // com.pasich.encly.editor.ui/SelectedBlocksActions.onCopy.|(){}[0] + final val onCopyMarkdown // com.pasich.encly.editor.ui/SelectedBlocksActions.onCopyMarkdown|{}onCopyMarkdown[0] + final fun (): kotlin/Function0 // com.pasich.encly.editor.ui/SelectedBlocksActions.onCopyMarkdown.|(){}[0] + final val onCut // com.pasich.encly.editor.ui/SelectedBlocksActions.onCut|{}onCut[0] + final fun (): kotlin/Function0 // com.pasich.encly.editor.ui/SelectedBlocksActions.onCut.|(){}[0] + final val onDelete // com.pasich.encly.editor.ui/SelectedBlocksActions.onDelete|{}onDelete[0] + final fun (): kotlin/Function0 // com.pasich.encly.editor.ui/SelectedBlocksActions.onDelete.|(){}[0] +} + +sealed class com.pasich.encly.editor.model/Block { // com.pasich.encly.editor.model/Block|null[0] + abstract val id // com.pasich.encly.editor.model/Block.id|{}id[0] + abstract fun (): kotlin/String // com.pasich.encly.editor.model/Block.id.|(){}[0] + + final class CodeBlock : com.pasich.encly.editor.model/Block, com.pasich.encly.editor.model/TextualBlock { // com.pasich.encly.editor.model/Block.CodeBlock|null[0] + constructor (kotlinx.coroutines.flow/MutableStateFlow = ..., kotlin/String = ...) // com.pasich.encly.editor.model/Block.CodeBlock.|(kotlinx.coroutines.flow.MutableStateFlow;kotlin.String){}[0] + + final val id // com.pasich.encly.editor.model/Block.CodeBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/Block.CodeBlock.id.|(){}[0] + final val text // com.pasich.encly.editor.model/Block.CodeBlock.text|{}text[0] + final fun (): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.CodeBlock.text.|(){}[0] + + final fun component1(): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.CodeBlock.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.model/Block.CodeBlock.component2|component2(){}[0] + final fun copy(kotlinx.coroutines.flow/MutableStateFlow = ..., kotlin/String = ...): com.pasich.encly.editor.model/Block.CodeBlock // com.pasich.encly.editor.model/Block.CodeBlock.copy|copy(kotlinx.coroutines.flow.MutableStateFlow;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/Block.CodeBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/Block.CodeBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/Block.CodeBlock.toString|toString(){}[0] + } + + final class HBlock : com.pasich.encly.editor.model/Block, com.pasich.encly.editor.model/TextualBlock { // com.pasich.encly.editor.model/Block.HBlock|null[0] + constructor (kotlinx.coroutines.flow/MutableStateFlow = ..., com.pasich.encly.editor.model/BlockType, kotlin/String = ...) // com.pasich.encly.editor.model/Block.HBlock.|(kotlinx.coroutines.flow.MutableStateFlow;com.pasich.encly.editor.model.BlockType;kotlin.String){}[0] + + final val blockType // com.pasich.encly.editor.model/Block.HBlock.blockType|{}blockType[0] + final fun (): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.model/Block.HBlock.blockType.|(){}[0] + final val id // com.pasich.encly.editor.model/Block.HBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/Block.HBlock.id.|(){}[0] + final val text // com.pasich.encly.editor.model/Block.HBlock.text|{}text[0] + final fun (): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.HBlock.text.|(){}[0] + + final fun component1(): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.HBlock.component1|component1(){}[0] + final fun component2(): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.model/Block.HBlock.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.editor.model/Block.HBlock.component3|component3(){}[0] + final fun copy(kotlinx.coroutines.flow/MutableStateFlow = ..., com.pasich.encly.editor.model/BlockType = ..., kotlin/String = ...): com.pasich.encly.editor.model/Block.HBlock // com.pasich.encly.editor.model/Block.HBlock.copy|copy(kotlinx.coroutines.flow.MutableStateFlow;com.pasich.encly.editor.model.BlockType;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/Block.HBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/Block.HBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/Block.HBlock.toString|toString(){}[0] + } + + final class LinkBlock : com.pasich.encly.editor.model/Block { // com.pasich.encly.editor.model/Block.LinkBlock|null[0] + constructor (kotlinx.coroutines.flow/MutableStateFlow = ..., kotlin/String = ...) // com.pasich.encly.editor.model/Block.LinkBlock.|(kotlinx.coroutines.flow.MutableStateFlow;kotlin.String){}[0] + + final val block // com.pasich.encly.editor.model/Block.LinkBlock.block|{}block[0] + final fun (): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.LinkBlock.block.|(){}[0] + final val id // com.pasich.encly.editor.model/Block.LinkBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/Block.LinkBlock.id.|(){}[0] + + final fun component1(): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.LinkBlock.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.model/Block.LinkBlock.component2|component2(){}[0] + final fun copy(kotlinx.coroutines.flow/MutableStateFlow = ..., kotlin/String = ...): com.pasich.encly.editor.model/Block.LinkBlock // com.pasich.encly.editor.model/Block.LinkBlock.copy|copy(kotlinx.coroutines.flow.MutableStateFlow;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/Block.LinkBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/Block.LinkBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/Block.LinkBlock.toString|toString(){}[0] + } + + final class ListBlock : com.pasich.encly.editor.model/Block { // com.pasich.encly.editor.model/Block.ListBlock|null[0] + constructor (kotlinx.coroutines.flow/MutableStateFlow> = ..., com.pasich.encly.editor.model/BlockType, kotlin/String = ..., kotlinx.coroutines.flow/MutableStateFlow = ...) // com.pasich.encly.editor.model/Block.ListBlock.|(kotlinx.coroutines.flow.MutableStateFlow>;com.pasich.encly.editor.model.BlockType;kotlin.String;kotlinx.coroutines.flow.MutableStateFlow){}[0] + + final val blockType // com.pasich.encly.editor.model/Block.ListBlock.blockType|{}blockType[0] + final fun (): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.model/Block.ListBlock.blockType.|(){}[0] + final val id // com.pasich.encly.editor.model/Block.ListBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/Block.ListBlock.id.|(){}[0] + final val items // com.pasich.encly.editor.model/Block.ListBlock.items|{}items[0] + final fun (): kotlinx.coroutines.flow/MutableStateFlow> // com.pasich.encly.editor.model/Block.ListBlock.items.|(){}[0] + final val locked // com.pasich.encly.editor.model/Block.ListBlock.locked|{}locked[0] + final fun (): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.ListBlock.locked.|(){}[0] + + final fun component1(): kotlinx.coroutines.flow/MutableStateFlow> // com.pasich.encly.editor.model/Block.ListBlock.component1|component1(){}[0] + final fun component2(): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.model/Block.ListBlock.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.editor.model/Block.ListBlock.component3|component3(){}[0] + final fun component4(): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.ListBlock.component4|component4(){}[0] + final fun copy(kotlinx.coroutines.flow/MutableStateFlow> = ..., com.pasich.encly.editor.model/BlockType = ..., kotlin/String = ..., kotlinx.coroutines.flow/MutableStateFlow = ...): com.pasich.encly.editor.model/Block.ListBlock // com.pasich.encly.editor.model/Block.ListBlock.copy|copy(kotlinx.coroutines.flow.MutableStateFlow>;com.pasich.encly.editor.model.BlockType;kotlin.String;kotlinx.coroutines.flow.MutableStateFlow){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/Block.ListBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/Block.ListBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/Block.ListBlock.toString|toString(){}[0] + } + + final class QuoteBlock : com.pasich.encly.editor.model/Block, com.pasich.encly.editor.model/TextualBlock { // com.pasich.encly.editor.model/Block.QuoteBlock|null[0] + constructor (kotlinx.coroutines.flow/MutableStateFlow = ..., kotlin/String = ...) // com.pasich.encly.editor.model/Block.QuoteBlock.|(kotlinx.coroutines.flow.MutableStateFlow;kotlin.String){}[0] + + final val id // com.pasich.encly.editor.model/Block.QuoteBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/Block.QuoteBlock.id.|(){}[0] + final val text // com.pasich.encly.editor.model/Block.QuoteBlock.text|{}text[0] + final fun (): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.QuoteBlock.text.|(){}[0] + + final fun component1(): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.QuoteBlock.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.model/Block.QuoteBlock.component2|component2(){}[0] + final fun copy(kotlinx.coroutines.flow/MutableStateFlow = ..., kotlin/String = ...): com.pasich.encly.editor.model/Block.QuoteBlock // com.pasich.encly.editor.model/Block.QuoteBlock.copy|copy(kotlinx.coroutines.flow.MutableStateFlow;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/Block.QuoteBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/Block.QuoteBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/Block.QuoteBlock.toString|toString(){}[0] + } + + final class SeparatorBlock : com.pasich.encly.editor.model/Block { // com.pasich.encly.editor.model/Block.SeparatorBlock|null[0] + constructor (kotlin/String = ...) // com.pasich.encly.editor.model/Block.SeparatorBlock.|(kotlin.String){}[0] + + final val id // com.pasich.encly.editor.model/Block.SeparatorBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/Block.SeparatorBlock.id.|(){}[0] + + final fun component1(): kotlin/String // com.pasich.encly.editor.model/Block.SeparatorBlock.component1|component1(){}[0] + final fun copy(kotlin/String = ...): com.pasich.encly.editor.model/Block.SeparatorBlock // com.pasich.encly.editor.model/Block.SeparatorBlock.copy|copy(kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/Block.SeparatorBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/Block.SeparatorBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/Block.SeparatorBlock.toString|toString(){}[0] + } + + final class TableBlock : com.pasich.encly.editor.model/Block { // com.pasich.encly.editor.model/Block.TableBlock|null[0] + constructor (kotlinx.coroutines.flow/MutableStateFlow = ..., kotlin/String = ...) // com.pasich.encly.editor.model/Block.TableBlock.|(kotlinx.coroutines.flow.MutableStateFlow;kotlin.String){}[0] + + final val id // com.pasich.encly.editor.model/Block.TableBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/Block.TableBlock.id.|(){}[0] + final val table // com.pasich.encly.editor.model/Block.TableBlock.table|{}table[0] + final fun (): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.TableBlock.table.|(){}[0] + + final fun component1(): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.TableBlock.component1|component1(){}[0] + final fun component2(): kotlin/String // com.pasich.encly.editor.model/Block.TableBlock.component2|component2(){}[0] + final fun copy(kotlinx.coroutines.flow/MutableStateFlow = ..., kotlin/String = ...): com.pasich.encly.editor.model/Block.TableBlock // com.pasich.encly.editor.model/Block.TableBlock.copy|copy(kotlinx.coroutines.flow.MutableStateFlow;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/Block.TableBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/Block.TableBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/Block.TableBlock.toString|toString(){}[0] + } + + final class TextBlock : com.pasich.encly.editor.model/Block, com.pasich.encly.editor.model/TextualBlock { // com.pasich.encly.editor.model/Block.TextBlock|null[0] + constructor (kotlinx.coroutines.flow/MutableStateFlow = ..., com.pasich.encly.editor/EditorString = ..., kotlin/String = ...) // com.pasich.encly.editor.model/Block.TextBlock.|(kotlinx.coroutines.flow.MutableStateFlow;com.pasich.encly.editor.EditorString;kotlin.String){}[0] + + final val id // com.pasich.encly.editor.model/Block.TextBlock.id|{}id[0] + final fun (): kotlin/String // com.pasich.encly.editor.model/Block.TextBlock.id.|(){}[0] + final val placeholder // com.pasich.encly.editor.model/Block.TextBlock.placeholder|{}placeholder[0] + final fun (): com.pasich.encly.editor/EditorString // com.pasich.encly.editor.model/Block.TextBlock.placeholder.|(){}[0] + final val text // com.pasich.encly.editor.model/Block.TextBlock.text|{}text[0] + final fun (): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.TextBlock.text.|(){}[0] + + final fun component1(): kotlinx.coroutines.flow/MutableStateFlow // com.pasich.encly.editor.model/Block.TextBlock.component1|component1(){}[0] + final fun component2(): com.pasich.encly.editor/EditorString // com.pasich.encly.editor.model/Block.TextBlock.component2|component2(){}[0] + final fun component3(): kotlin/String // com.pasich.encly.editor.model/Block.TextBlock.component3|component3(){}[0] + final fun copy(kotlinx.coroutines.flow/MutableStateFlow = ..., com.pasich.encly.editor/EditorString = ..., kotlin/String = ...): com.pasich.encly.editor.model/Block.TextBlock // com.pasich.encly.editor.model/Block.TextBlock.copy|copy(kotlinx.coroutines.flow.MutableStateFlow;com.pasich.encly.editor.EditorString;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.editor.model/Block.TextBlock.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.editor.model/Block.TextBlock.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.editor.model/Block.TextBlock.toString|toString(){}[0] + } +} + +final object com.pasich.encly.editor.codec/BlockConverter { // com.pasich.encly.editor.codec/BlockConverter|null[0] + final fun blocksToJson(kotlin.collections/List): kotlin/String // com.pasich.encly.editor.codec/BlockConverter.blocksToJson|blocksToJson(kotlin.collections.List){}[0] + final fun jsonToBlocks(kotlin/String): kotlin.collections/List // com.pasich.encly.editor.codec/BlockConverter.jsonToBlocks|jsonToBlocks(kotlin.String){}[0] + final fun jsonToBlocksOrNull(kotlin/String, com.pasich.encly.editor/EditorLogger = ...): kotlin.collections/List? // com.pasich.encly.editor.codec/BlockConverter.jsonToBlocksOrNull|jsonToBlocksOrNull(kotlin.String;com.pasich.encly.editor.EditorLogger){}[0] +} + +final object com.pasich.encly.editor.codec/ImportBlocks { // com.pasich.encly.editor.codec/ImportBlocks|null[0] + final fun code(kotlin/String): com.pasich.encly.editor.model/Block // com.pasich.encly.editor.codec/ImportBlocks.code|code(kotlin.String){}[0] + final fun heading(kotlin/String, kotlin/Int): com.pasich.encly.editor.model/Block // com.pasich.encly.editor.codec/ImportBlocks.heading|heading(kotlin.String;kotlin.Int){}[0] + final fun list(com.pasich.encly.editor.model/BlockType, kotlin.collections/List): com.pasich.encly.editor.model/Block // com.pasich.encly.editor.codec/ImportBlocks.list|list(com.pasich.encly.editor.model.BlockType;kotlin.collections.List){}[0] + final fun paragraphs(kotlin/String): kotlin.collections/List // com.pasich.encly.editor.codec/ImportBlocks.paragraphs|paragraphs(kotlin.String){}[0] + final fun quote(kotlin/String): com.pasich.encly.editor.model/Block // com.pasich.encly.editor.codec/ImportBlocks.quote|quote(kotlin.String){}[0] + final fun table(kotlin.collections/List>, kotlin/Boolean): com.pasich.encly.editor.model/Block // com.pasich.encly.editor.codec/ImportBlocks.table|table(kotlin.collections.List>;kotlin.Boolean){}[0] + final fun text(kotlin/String): com.pasich.encly.editor.model/Block // com.pasich.encly.editor.codec/ImportBlocks.text|text(kotlin.String){}[0] +} + +final object com.pasich.encly.editor.codec/InlineHtml { // com.pasich.encly.editor.codec/InlineHtml|null[0] + final fun toPlainText(kotlin/String, kotlin/Boolean = ...): kotlin/String // com.pasich.encly.editor.codec/InlineHtml.toPlainText|toPlainText(kotlin.String;kotlin.Boolean){}[0] +} + +final object com.pasich.encly.editor.codec/MarkdownBlockMapper : com.pasich.encly.editor/MarkdownBlockParser { // com.pasich.encly.editor.codec/MarkdownBlockMapper|null[0] + final fun parsePaste(kotlin/String, kotlin/Boolean, kotlin/Function1): kotlin.collections/List // com.pasich.encly.editor.codec/MarkdownBlockMapper.parsePaste|parsePaste(kotlin.String;kotlin.Boolean;kotlin.Function1){}[0] + final fun toBlocks(kotlin/String): kotlin.collections/List // com.pasich.encly.editor.codec/MarkdownBlockMapper.toBlocks|toBlocks(kotlin.String){}[0] +} + +final object com.pasich.encly.editor.model/BlockFactory { // com.pasich.encly.editor.model/BlockFactory|null[0] + final fun createBlock(com.pasich.encly.editor.model/BlockType): com.pasich.encly.editor.model/Block // com.pasich.encly.editor.model/BlockFactory.createBlock|createBlock(com.pasich.encly.editor.model.BlockType){}[0] +} + +final const val com.pasich.encly.editor.model/MAX_LIST_LEVEL // com.pasich.encly.editor.model/MAX_LIST_LEVEL|{}MAX_LIST_LEVEL[0] + final fun (): kotlin/Int // com.pasich.encly.editor.model/MAX_LIST_LEVEL.|(){}[0] + +final val com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_BlockConverter$stableprop // com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_BlockConverter$stableprop|#static{}com_pasich_encly_editor_codec_BlockConverter$stableprop[0] +final val com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_ImportBlocks$stableprop // com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_ImportBlocks$stableprop|#static{}com_pasich_encly_editor_codec_ImportBlocks$stableprop[0] +final val com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_InlineHtml$stableprop // com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_InlineHtml$stableprop|#static{}com_pasich_encly_editor_codec_InlineHtml$stableprop[0] +final val com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_MarkdownBlockMapper$stableprop // com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_MarkdownBlockMapper$stableprop|#static{}com_pasich_encly_editor_codec_MarkdownBlockMapper$stableprop[0] +final val com.pasich.encly.editor.focus/com_pasich_encly_editor_focus_BlockFocusRegistry$stableprop // com.pasich.encly.editor.focus/com_pasich_encly_editor_focus_BlockFocusRegistry$stableprop|#static{}com_pasich_encly_editor_focus_BlockFocusRegistry$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block$stableprop|#static{}com_pasich_encly_editor_model_Block$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_BlockFactory$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_BlockFactory$stableprop|#static{}com_pasich_encly_editor_model_BlockFactory$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_CodeBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_CodeBlock$stableprop|#static{}com_pasich_encly_editor_model_Block_CodeBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_HBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_HBlock$stableprop|#static{}com_pasich_encly_editor_model_Block_HBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_LinkBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_LinkBlock$stableprop|#static{}com_pasich_encly_editor_model_Block_LinkBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_ListBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_ListBlock$stableprop|#static{}com_pasich_encly_editor_model_Block_ListBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_QuoteBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_QuoteBlock$stableprop|#static{}com_pasich_encly_editor_model_Block_QuoteBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_SeparatorBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_SeparatorBlock$stableprop|#static{}com_pasich_encly_editor_model_Block_SeparatorBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_TableBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_TableBlock$stableprop|#static{}com_pasich_encly_editor_model_Block_TableBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_TextBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_TextBlock$stableprop|#static{}com_pasich_encly_editor_model_Block_TextBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_ItemListBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_ItemListBlock$stableprop|#static{}com_pasich_encly_editor_model_ItemListBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_LinkDataBlock$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_LinkDataBlock$stableprop|#static{}com_pasich_encly_editor_model_LinkDataBlock$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableCell$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableCell$stableprop|#static{}com_pasich_encly_editor_model_TableCell$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableData$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableData$stableprop|#static{}com_pasich_encly_editor_model_TableData$stableprop[0] +final val com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableRow$stableprop // com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableRow$stableprop|#static{}com_pasich_encly_editor_model_TableRow$stableprop[0] +final val com.pasich.encly.editor.state/HEADING_TOOL // com.pasich.encly.editor.state/HEADING_TOOL|{}HEADING_TOOL[0] + final fun (): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.state/HEADING_TOOL.|(){}[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_BlockEditorState$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_BlockEditorState$stableprop|#static{}com_pasich_encly_editor_state_BlockEditorState$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_BlockSelection$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_BlockSelection$stableprop|#static{}com_pasich_encly_editor_state_BlockSelection$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Down$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Down$stableprop|#static{}com_pasich_encly_editor_state_CaretMove_Down$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Left$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Left$stableprop|#static{}com_pasich_encly_editor_state_CaretMove_Left$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Right$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Right$stableprop|#static{}com_pasich_encly_editor_state_CaretMove_Right$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Up$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Up$stableprop|#static{}com_pasich_encly_editor_state_CaretMove_Up$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindMatch$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindMatch$stableprop|#static{}com_pasich_encly_editor_state_FindMatch$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindState$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindState$stableprop|#static{}com_pasich_encly_editor_state_FindState$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_FocusRequest$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_FocusRequest$stableprop|#static{}com_pasich_encly_editor_state_FocusRequest$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_LineBreak$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_LineBreak$stableprop|#static{}com_pasich_encly_editor_state_LineBreak$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_NoteFind$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_NoteFind$stableprop|#static{}com_pasich_encly_editor_state_NoteFind$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_SelectedBlocks$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_SelectedBlocks$stableprop|#static{}com_pasich_encly_editor_state_SelectedBlocks$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_Shortcut$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_Shortcut$stableprop|#static{}com_pasich_encly_editor_state_Shortcut$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_SlashMenu$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_SlashMenu$stableprop|#static{}com_pasich_encly_editor_state_SlashMenu$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_SlashPicker$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_SlashPicker$stableprop|#static{}com_pasich_encly_editor_state_SlashPicker$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_Heading$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_Heading$stableprop|#static{}com_pasich_encly_editor_state_TemplatePart_Heading$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_ListOf$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_ListOf$stableprop|#static{}com_pasich_encly_editor_state_TemplatePart_ListOf$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_Paragraph$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_Paragraph$stableprop|#static{}com_pasich_encly_editor_state_TemplatePart_Paragraph$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextDiff$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextDiff$stableprop|#static{}com_pasich_encly_editor_state_TextDiff$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextPoint$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextPoint$stableprop|#static{}com_pasich_encly_editor_state_TextPoint$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeEnds$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeEnds$stableprop|#static{}com_pasich_encly_editor_state_TextRangeEnds$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeSelection$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeSelection$stableprop|#static{}com_pasich_encly_editor_state_TextRangeSelection$stableprop[0] +final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TypingStep$stableprop // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TypingStep$stableprop|#static{}com_pasich_encly_editor_state_TypingStep$stableprop[0] +final val com.pasich.encly.editor.theme/LocalEditorTheme // com.pasich.encly.editor.theme/LocalEditorTheme|{}LocalEditorTheme[0] + final fun (): androidx.compose.runtime/ProvidableCompositionLocal // com.pasich.encly.editor.theme/LocalEditorTheme.|(){}[0] +final val com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorFontFamilies$stableprop // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorFontFamilies$stableprop|#static{}com_pasich_encly_editor_theme_EditorFontFamilies$stableprop[0] +final val com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorFontSizes$stableprop // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorFontSizes$stableprop|#static{}com_pasich_encly_editor_theme_EditorFontSizes$stableprop[0] +final val com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorIcons$stableprop // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorIcons$stableprop|#static{}com_pasich_encly_editor_theme_EditorIcons$stableprop[0] +final val com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorSpacing$stableprop // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorSpacing$stableprop|#static{}com_pasich_encly_editor_theme_EditorSpacing$stableprop[0] +final val com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorTheme$stableprop // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorTheme$stableprop|#static{}com_pasich_encly_editor_theme_EditorTheme$stableprop[0] +final val com.pasich.encly.editor.theme/isCompact // com.pasich.encly.editor.theme/isCompact|@com.pasich.encly.editor.theme.EditorLineSpacing{}isCompact[0] + final fun (com.pasich.encly.editor.theme/EditorLineSpacing).(): kotlin/Boolean // com.pasich.encly.editor.theme/isCompact.|@com.pasich.encly.editor.theme.EditorLineSpacing(){}[0] +final val com.pasich.encly.editor.ui.blocks/WritingKeyboard // com.pasich.encly.editor.ui.blocks/WritingKeyboard|{}WritingKeyboard[0] + final fun (): androidx.compose.foundation.text/KeyboardOptions // com.pasich.encly.editor.ui.blocks/WritingKeyboard.|(){}[0] +final val com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Code$stableprop // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Code$stableprop|#static{}com_pasich_encly_editor_ui_blocks_BlockField_Code$stableprop[0] +final val com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_ListItem$stableprop // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_ListItem$stableprop|#static{}com_pasich_encly_editor_ui_blocks_BlockField_ListItem$stableprop[0] +final val com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Paragraph$stableprop // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Paragraph$stableprop|#static{}com_pasich_encly_editor_ui_blocks_BlockField_Paragraph$stableprop[0] +final val com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Styled$stableprop // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Styled$stableprop|#static{}com_pasich_encly_editor_ui_blocks_BlockField_Styled$stableprop[0] +final val com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Blocked$stableprop // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Blocked$stableprop|#static{}com_pasich_encly_editor_ui_blocks_NoteLink_Blocked$stableprop[0] +final val com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Mail$stableprop // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Mail$stableprop|#static{}com_pasich_encly_editor_ui_blocks_NoteLink_Mail$stableprop[0] +final val com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Web$stableprop // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Web$stableprop|#static{}com_pasich_encly_editor_ui_blocks_NoteLink_Web$stableprop[0] +final val com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_CopyLink$stableprop // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_CopyLink$stableprop|#static{}com_pasich_encly_editor_ui_sheets_ActionBlockDialog_CopyLink$stableprop[0] +final val com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Delete$stableprop // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Delete$stableprop|#static{}com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Delete$stableprop[0] +final val com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Duplicate$stableprop // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Duplicate$stableprop|#static{}com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Duplicate$stableprop[0] +final val com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_EditLink$stableprop // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_EditLink$stableprop|#static{}com_pasich_encly_editor_ui_sheets_ActionBlockDialog_EditLink$stableprop[0] +final val com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Move$stableprop // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Move$stableprop|#static{}com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Move$stableprop[0] +final val com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_OpenLink$stableprop // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_OpenLink$stableprop|#static{}com_pasich_encly_editor_ui_sheets_ActionBlockDialog_OpenLink$stableprop[0] +final val com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_EditorBlocksHost$stableprop // com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_EditorBlocksHost$stableprop|#static{}com_pasich_encly_editor_ui_EditorBlocksHost$stableprop[0] +final val com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_FindHighlights$stableprop // com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_FindHighlights$stableprop|#static{}com_pasich_encly_editor_ui_FindHighlights$stableprop[0] +final val com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_SelectedBlocksActions$stableprop // com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_SelectedBlocksActions$stableprop|#static{}com_pasich_encly_editor_ui_SelectedBlocksActions$stableprop[0] +final val com.pasich.encly.editor/LocalEditorStrings // com.pasich.encly.editor/LocalEditorStrings|{}LocalEditorStrings[0] + final fun (): androidx.compose.runtime/ProvidableCompositionLocal // com.pasich.encly.editor/LocalEditorStrings.|(){}[0] + +final fun (androidx.compose.foundation.lazy/LazyListScope).com.pasich.encly.editor.ui/editorBlocks(com.pasich.encly.editor.ui/EditorBlocksHost, kotlin.collections/List, kotlin/Boolean, kotlin.collections/Set, kotlin/Function2? = ...) // com.pasich.encly.editor.ui/editorBlocks|editorBlocks@androidx.compose.foundation.lazy.LazyListScope(com.pasich.encly.editor.ui.EditorBlocksHost;kotlin.collections.List;kotlin.Boolean;kotlin.collections.Set;kotlin.Function2?){}[0] +final fun (androidx.compose.ui/Modifier).com.pasich.encly.editor.ui/tapBelowBlocks(androidx.compose.foundation.lazy/LazyListState, kotlin/Boolean, kotlin/Function0): androidx.compose.ui/Modifier // com.pasich.encly.editor.ui/tapBelowBlocks|tapBelowBlocks@androidx.compose.ui.Modifier(androidx.compose.foundation.lazy.LazyListState;kotlin.Boolean;kotlin.Function0){}[0] +final fun (com.pasich.encly.editor.model/Block).com.pasich.encly.editor.model/hasContent(): kotlin/Boolean // com.pasich.encly.editor.model/hasContent|hasContent@com.pasich.encly.editor.model.Block(){}[0] +final fun (com.pasich.encly.editor.model/Block).com.pasich.encly.editor.state/toolType(): com.pasich.encly.editor.model/BlockType? // com.pasich.encly.editor.state/toolType|toolType@com.pasich.encly.editor.model.Block(){}[0] +final fun (com.pasich.encly.editor.model/BlockType).com.pasich.encly.editor.model/isListKind(): kotlin/Boolean // com.pasich.encly.editor.model/isListKind|isListKind@com.pasich.encly.editor.model.BlockType(){}[0] +final fun (com.pasich.encly.editor.model/ItemListBlock).com.pasich.encly.editor.model/kindIn(com.pasich.encly.editor.model/BlockType): com.pasich.encly.editor.model/BlockType // com.pasich.encly.editor.model/kindIn|kindIn@com.pasich.encly.editor.model.ItemListBlock(com.pasich.encly.editor.model.BlockType){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/addBlockAfter(kotlin/Int, com.pasich.encly.editor.model/Block) // com.pasich.encly.editor.state/addBlockAfter|addBlockAfter@com.pasich.encly.editor.state.BlockEditorState(kotlin.Int;com.pasich.encly.editor.model.Block){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/addBlockToEnd() // com.pasich.encly.editor.state/addBlockToEnd|addBlockToEnd@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/applyShortcut(kotlin/String, kotlin/String, com.pasich.encly.editor.state/Shortcut): kotlin/Boolean // com.pasich.encly.editor.state/applyShortcut|applyShortcut@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String;com.pasich.encly.editor.state.Shortcut){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/applySlash(kotlin/String, com.pasich.encly.editor.state/SlashCommand): kotlin/Boolean // com.pasich.encly.editor.state/applySlash|applySlash@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;com.pasich.encly.editor.state.SlashCommand){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/applyTemplate(kotlin.collections/List): kotlin/Boolean // com.pasich.encly.editor.state/applyTemplate|applyTemplate@com.pasich.encly.editor.state.BlockEditorState(kotlin.collections.List){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/applyTool(com.pasich.encly.editor.model/BlockType, kotlin/Boolean = ...) // com.pasich.encly.editor.state/applyTool|applyTool@com.pasich.encly.editor.state.BlockEditorState(com.pasich.encly.editor.model.BlockType;kotlin.Boolean){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/backspaceAtStart(kotlin/String): kotlin/Boolean // com.pasich.encly.editor.state/backspaceAtStart|backspaceAtStart@com.pasich.encly.editor.state.BlockEditorState(kotlin.String){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/breakLine(kotlin/String, com.pasich.encly.editor.state/LineBreak): kotlin/String? // com.pasich.encly.editor.state/breakLine|breakLine@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;com.pasich.encly.editor.state.LineBreak){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/canMoveInteracted(kotlin/Boolean): kotlin/Boolean // com.pasich.encly.editor.state/canMoveInteracted|canMoveInteracted@com.pasich.encly.editor.state.BlockEditorState(kotlin.Boolean){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/canMoveListItem(kotlin/String, kotlin/String, kotlin/Int): kotlin/Boolean // com.pasich.encly.editor.state/canMoveListItem|canMoveListItem@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String;kotlin.Int){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/canRemoveInteracted(): kotlin/Boolean // com.pasich.encly.editor.state/canRemoveInteracted|canRemoveInteracted@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/canReplaceTextRange(): kotlin/Boolean // com.pasich.encly.editor.state/canReplaceTextRange|canReplaceTextRange@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/canShiftListItem(kotlin/String, kotlin/String, kotlin/Boolean): kotlin/Boolean // com.pasich.encly.editor.state/canShiftListItem|canShiftListItem@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String;kotlin.Boolean){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/deleteSelectedBlocks(): kotlin/Boolean // com.pasich.encly.editor.state/deleteSelectedBlocks|deleteSelectedBlocks@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/duplicateBlockOf(kotlin/String): kotlin/Boolean // com.pasich.encly.editor.state/duplicateBlockOf|duplicateBlockOf@com.pasich.encly.editor.state.BlockEditorState(kotlin.String){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/duplicateInteracted() // com.pasich.encly.editor.state/duplicateInteracted|duplicateInteracted@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/focusFirstBlock() // com.pasich.encly.editor.state/focusFirstBlock|focusFirstBlock@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/focusWorkingBlock() // com.pasich.encly.editor.state/focusWorkingBlock|focusWorkingBlock@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/indexOf(kotlin/String): kotlin/Int // com.pasich.encly.editor.state/indexOf|indexOf@com.pasich.encly.editor.state.BlockEditorState(kotlin.String){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/interactedBlockPosition(): kotlin/Int // com.pasich.encly.editor.state/interactedBlockPosition|interactedBlockPosition@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/interactedListItem(): kotlin/Pair? // com.pasich.encly.editor.state/interactedListItem|interactedListItem@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/isBlankNote(): kotlin/Boolean // com.pasich.encly.editor.state/isBlankNote|isBlankNote@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/listBackspaceAtStart(kotlin/String, kotlin/String): kotlin/Boolean // com.pasich.encly.editor.state/listBackspaceAtStart|listBackspaceAtStart@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/listLineBreak(kotlin/String, kotlin/String, com.pasich.encly.editor.state/LineBreak): kotlin/String? // com.pasich.encly.editor.state/listLineBreak|listLineBreak@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String;com.pasich.encly.editor.state.LineBreak){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/moveBlockOf(kotlin/String, kotlin/Boolean) // com.pasich.encly.editor.state/moveBlockOf|moveBlockOf@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.Boolean){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/moveInteracted(kotlin/Boolean) // com.pasich.encly.editor.state/moveInteracted|moveInteracted@com.pasich.encly.editor.state.BlockEditorState(kotlin.Boolean){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/moveListItem(kotlin/String, kotlin/String, kotlin/Int): kotlin/Boolean // com.pasich.encly.editor.state/moveListItem|moveListItem@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String;kotlin.Int){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/pasteCopied(kotlin/String, kotlin/String?, kotlin/String, kotlin/String, kotlin/String): kotlin/String? // com.pasich.encly.editor.state/pasteCopied|pasteCopied@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String?;kotlin.String;kotlin.String;kotlin.String){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/pasteLink(kotlin/String, kotlin/String, com.pasich.encly.editor.model/LinkDataBlock): kotlin/Boolean // com.pasich.encly.editor.state/pasteLink|pasteLink@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String;com.pasich.encly.editor.model.LinkDataBlock){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/pickedPlainText(): kotlin/String // com.pasich.encly.editor.state/pickedPlainText|pickedPlainText@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/removeInteracted() // com.pasich.encly.editor.state/removeInteracted|removeInteracted@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/replaceTextRange(kotlin/String): kotlin/Boolean // com.pasich.encly.editor.state/replaceTextRange|replaceTextRange@com.pasich.encly.editor.state.BlockEditorState(kotlin.String){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/setNestedListKind(kotlin/String, kotlin/String, com.pasich.encly.editor.model/BlockType): kotlin/Boolean // com.pasich.encly.editor.state/setNestedListKind|setNestedListKind@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String;com.pasich.encly.editor.model.BlockType){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/shiftListItem(kotlin/String, kotlin/String, kotlin/Boolean): kotlin/Boolean // com.pasich.encly.editor.state/shiftListItem|shiftListItem@com.pasich.encly.editor.state.BlockEditorState(kotlin.String;kotlin.String;kotlin.Boolean){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/textRangeBlocks(): kotlin.collections/List // com.pasich.encly.editor.state/textRangeBlocks|textRangeBlocks@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/textRangePlainText(): kotlin/String // com.pasich.encly.editor.state/textRangePlainText|textRangePlainText@com.pasich.encly.editor.state.BlockEditorState(){}[0] +final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.editor.state/toggleChecklistLock(kotlin/String): kotlin/Boolean // com.pasich.encly.editor.state/toggleChecklistLock|toggleChecklistLock@com.pasich.encly.editor.state.BlockEditorState(kotlin.String){}[0] +final fun (com.pasich.encly.editor.state/NoteTemplate).com.pasich.encly.editor.state/blocks(kotlin/Function1): kotlin.collections/List // com.pasich.encly.editor.state/blocks|blocks@com.pasich.encly.editor.state.NoteTemplate(kotlin.Function1){}[0] +final fun (com.pasich.encly.editor.theme/EditorLineSpacing).com.pasich.encly.editor.theme/blockGap(androidx.compose.ui.unit/Dp): androidx.compose.ui.unit/Dp // com.pasich.encly.editor.theme/blockGap|blockGap@com.pasich.encly.editor.theme.EditorLineSpacing(androidx.compose.ui.unit.Dp){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.state/plainText(): kotlin/String // com.pasich.encly.editor.state/plainText|plainText@kotlin.collections.List(){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.model/movedBy(kotlin/Int, kotlin/Int): kotlin/Pair, kotlin/Int>? // com.pasich.encly.editor.model/movedBy|movedBy@kotlin.collections.List(kotlin.Int;kotlin.Int){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.model/normalizedLevels(com.pasich.encly.editor.model/BlockType): kotlin.collections/List // com.pasich.encly.editor.model/normalizedLevels|normalizedLevels@kotlin.collections.List(com.pasich.encly.editor.model.BlockType){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.model/numbers(com.pasich.encly.editor.model/BlockType): kotlin.collections/List // com.pasich.encly.editor.model/numbers|numbers@kotlin.collections.List(com.pasich.encly.editor.model.BlockType){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.model/shifted(kotlin/Int, kotlin/Boolean, com.pasich.encly.editor.model/BlockType): kotlin.collections/List? // com.pasich.encly.editor.model/shifted|shifted@kotlin.collections.List(kotlin.Int;kotlin.Boolean;com.pasich.encly.editor.model.BlockType){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.model/siblingOffsetAt(kotlin/Int, kotlin.collections/List, kotlin.collections/List, kotlin/Float): kotlin/Int // com.pasich.encly.editor.model/siblingOffsetAt|siblingOffsetAt@kotlin.collections.List(kotlin.Int;kotlin.collections.List;kotlin.collections.List;kotlin.Float){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.model/siblingStarts(kotlin/Int): kotlin.collections/List // com.pasich.encly.editor.model/siblingStarts|siblingStarts@kotlin.collections.List(kotlin.Int){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.model/subtreeEnd(kotlin/Int): kotlin/Int // com.pasich.encly.editor.model/subtreeEnd|subtreeEnd@kotlin.collections.List(kotlin.Int){}[0] +final fun (kotlin.collections/List).com.pasich.encly.editor.model/withSiblingsOfKind(kotlin/Int, com.pasich.encly.editor.model/BlockType, com.pasich.encly.editor.model/BlockType): kotlin.collections/List? // com.pasich.encly.editor.model/withSiblingsOfKind|withSiblingsOfKind@kotlin.collections.List(kotlin.Int;com.pasich.encly.editor.model.BlockType;com.pasich.encly.editor.model.BlockType){}[0] +final fun com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_BlockConverter$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_BlockConverter$stableprop_getter|com_pasich_encly_editor_codec_BlockConverter$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_ImportBlocks$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_ImportBlocks$stableprop_getter|com_pasich_encly_editor_codec_ImportBlocks$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_InlineHtml$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_InlineHtml$stableprop_getter|com_pasich_encly_editor_codec_InlineHtml$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_MarkdownBlockMapper$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_MarkdownBlockMapper$stableprop_getter|com_pasich_encly_editor_codec_MarkdownBlockMapper$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.focus/com_pasich_encly_editor_focus_BlockFocusRegistry$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.focus/com_pasich_encly_editor_focus_BlockFocusRegistry$stableprop_getter|com_pasich_encly_editor_focus_BlockFocusRegistry$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block$stableprop_getter|com_pasich_encly_editor_model_Block$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_BlockFactory$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_BlockFactory$stableprop_getter|com_pasich_encly_editor_model_BlockFactory$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_CodeBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_CodeBlock$stableprop_getter|com_pasich_encly_editor_model_Block_CodeBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_HBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_HBlock$stableprop_getter|com_pasich_encly_editor_model_Block_HBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_LinkBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_LinkBlock$stableprop_getter|com_pasich_encly_editor_model_Block_LinkBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_ListBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_ListBlock$stableprop_getter|com_pasich_encly_editor_model_Block_ListBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_QuoteBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_QuoteBlock$stableprop_getter|com_pasich_encly_editor_model_Block_QuoteBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_SeparatorBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_SeparatorBlock$stableprop_getter|com_pasich_encly_editor_model_Block_SeparatorBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_TableBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_TableBlock$stableprop_getter|com_pasich_encly_editor_model_Block_TableBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_TextBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_Block_TextBlock$stableprop_getter|com_pasich_encly_editor_model_Block_TextBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_ItemListBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_ItemListBlock$stableprop_getter|com_pasich_encly_editor_model_ItemListBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_LinkDataBlock$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_LinkDataBlock$stableprop_getter|com_pasich_encly_editor_model_LinkDataBlock$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableCell$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableCell$stableprop_getter|com_pasich_encly_editor_model_TableCell$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableData$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableData$stableprop_getter|com_pasich_encly_editor_model_TableData$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableRow$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.model/com_pasich_encly_editor_model_TableRow$stableprop_getter|com_pasich_encly_editor_model_TableRow$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_BlockEditorState$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_BlockEditorState$stableprop_getter|com_pasich_encly_editor_state_BlockEditorState$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_BlockSelection$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_BlockSelection$stableprop_getter|com_pasich_encly_editor_state_BlockSelection$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Down$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Down$stableprop_getter|com_pasich_encly_editor_state_CaretMove_Down$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Left$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Left$stableprop_getter|com_pasich_encly_editor_state_CaretMove_Left$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Right$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Right$stableprop_getter|com_pasich_encly_editor_state_CaretMove_Right$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Up$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_CaretMove_Up$stableprop_getter|com_pasich_encly_editor_state_CaretMove_Up$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindMatch$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindMatch$stableprop_getter|com_pasich_encly_editor_state_FindMatch$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindState$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindState$stableprop_getter|com_pasich_encly_editor_state_FindState$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_FocusRequest$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_FocusRequest$stableprop_getter|com_pasich_encly_editor_state_FocusRequest$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_LineBreak$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_LineBreak$stableprop_getter|com_pasich_encly_editor_state_LineBreak$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_NoteFind$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_NoteFind$stableprop_getter|com_pasich_encly_editor_state_NoteFind$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_SelectedBlocks$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_SelectedBlocks$stableprop_getter|com_pasich_encly_editor_state_SelectedBlocks$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_Shortcut$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_Shortcut$stableprop_getter|com_pasich_encly_editor_state_Shortcut$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_SlashMenu$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_SlashMenu$stableprop_getter|com_pasich_encly_editor_state_SlashMenu$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_SlashPicker$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_SlashPicker$stableprop_getter|com_pasich_encly_editor_state_SlashPicker$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_Heading$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_Heading$stableprop_getter|com_pasich_encly_editor_state_TemplatePart_Heading$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_ListOf$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_ListOf$stableprop_getter|com_pasich_encly_editor_state_TemplatePart_ListOf$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_Paragraph$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePart_Paragraph$stableprop_getter|com_pasich_encly_editor_state_TemplatePart_Paragraph$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextDiff$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextDiff$stableprop_getter|com_pasich_encly_editor_state_TextDiff$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextPoint$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextPoint$stableprop_getter|com_pasich_encly_editor_state_TextPoint$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeEnds$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeEnds$stableprop_getter|com_pasich_encly_editor_state_TextRangeEnds$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeSelection$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeSelection$stableprop_getter|com_pasich_encly_editor_state_TextRangeSelection$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TypingStep$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.state/com_pasich_encly_editor_state_TypingStep$stableprop_getter|com_pasich_encly_editor_state_TypingStep$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.state/listTypingStep(kotlin.collections/List, kotlin.collections/List): com.pasich.encly.editor.state/TypingStep? // com.pasich.encly.editor.state/listTypingStep|listTypingStep(kotlin.collections.List;kotlin.collections.List){}[0] +final fun com.pasich.encly.editor.state/tableTypingStep(com.pasich.encly.editor.model/TableData, com.pasich.encly.editor.model/TableData): com.pasich.encly.editor.state/TypingStep? // com.pasich.encly.editor.state/tableTypingStep|tableTypingStep(com.pasich.encly.editor.model.TableData;com.pasich.encly.editor.model.TableData){}[0] +final fun com.pasich.encly.editor.state/textDiff(kotlin/String, kotlin/String, kotlin/Int = ...): com.pasich.encly.editor.state/TextDiff // com.pasich.encly.editor.state/textDiff|textDiff(kotlin.String;kotlin.String;kotlin.Int){}[0] +final fun com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorFontFamilies$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorFontFamilies$stableprop_getter|com_pasich_encly_editor_theme_EditorFontFamilies$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorFontSizes$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorFontSizes$stableprop_getter|com_pasich_encly_editor_theme_EditorFontSizes$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorIcons$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorIcons$stableprop_getter|com_pasich_encly_editor_theme_EditorIcons$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorSpacing$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorSpacing$stableprop_getter|com_pasich_encly_editor_theme_EditorSpacing$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorTheme$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.theme/com_pasich_encly_editor_theme_EditorTheme$stableprop_getter|com_pasich_encly_editor_theme_EditorTheme$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Code$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Code$stableprop_getter|com_pasich_encly_editor_ui_blocks_BlockField_Code$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_ListItem$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_ListItem$stableprop_getter|com_pasich_encly_editor_ui_blocks_BlockField_ListItem$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Paragraph$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Paragraph$stableprop_getter|com_pasich_encly_editor_ui_blocks_BlockField_Paragraph$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Styled$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_BlockField_Styled$stableprop_getter|com_pasich_encly_editor_ui_blocks_BlockField_Styled$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Blocked$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Blocked$stableprop_getter|com_pasich_encly_editor_ui_blocks_NoteLink_Blocked$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Mail$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Mail$stableprop_getter|com_pasich_encly_editor_ui_blocks_NoteLink_Mail$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Web$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.blocks/com_pasich_encly_editor_ui_blocks_NoteLink_Web$stableprop_getter|com_pasich_encly_editor_ui_blocks_NoteLink_Web$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.blocks/isOpenableLink(kotlin/String): kotlin/Boolean // com.pasich.encly.editor.ui.blocks/isOpenableLink|isOpenableLink(kotlin.String){}[0] +final fun com.pasich.encly.editor.ui.blocks/rememberSyncedTextFieldState(kotlin/Function0, kotlinx.coroutines.flow/Flow<*>, kotlin/Function1, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.text.input/TextFieldState // com.pasich.encly.editor.ui.blocks/rememberSyncedTextFieldState|rememberSyncedTextFieldState(kotlin.Function0;kotlinx.coroutines.flow.Flow<*>;kotlin.Function1;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun com.pasich.encly.editor.ui.sheets/EditorBlockSheet(com.pasich.encly.editor.ui.sheets/BlockSheetKind?, kotlin/String?, kotlin/Boolean, com.pasich.encly.editor.ui/EditorController, kotlin/Function0, androidx.compose.runtime/Composer?, kotlin/Int) // com.pasich.encly.editor.ui.sheets/EditorBlockSheet|EditorBlockSheet(com.pasich.encly.editor.ui.sheets.BlockSheetKind?;kotlin.String?;kotlin.Boolean;com.pasich.encly.editor.ui.EditorController;kotlin.Function0;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_CopyLink$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_CopyLink$stableprop_getter|com_pasich_encly_editor_ui_sheets_ActionBlockDialog_CopyLink$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Delete$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Delete$stableprop_getter|com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Delete$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Duplicate$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Duplicate$stableprop_getter|com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Duplicate$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_EditLink$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_EditLink$stableprop_getter|com_pasich_encly_editor_ui_sheets_ActionBlockDialog_EditLink$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Move$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Move$stableprop_getter|com_pasich_encly_editor_ui_sheets_ActionBlockDialog_Move$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_OpenLink$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_ActionBlockDialog_OpenLink$stableprop_getter|com_pasich_encly_editor_ui_sheets_ActionBlockDialog_OpenLink$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui/SelectedBlocksBar(com.pasich.encly.editor.state/SelectedBlocks, com.pasich.encly.editor.ui/SelectedBlocksActions, androidx.compose.ui/Modifier?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.pasich.encly.editor.ui/SelectedBlocksBar|SelectedBlocksBar(com.pasich.encly.editor.state.SelectedBlocks;com.pasich.encly.editor.ui.SelectedBlocksActions;androidx.compose.ui.Modifier?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.pasich.encly.editor.ui/TextRangeBar(com.pasich.encly.editor.state/TextRangeSelection, com.pasich.encly.editor.ui/SelectedBlocksActions, androidx.compose.ui/Modifier?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.pasich.encly.editor.ui/TextRangeBar|TextRangeBar(com.pasich.encly.editor.state.TextRangeSelection;com.pasich.encly.editor.ui.SelectedBlocksActions;androidx.compose.ui.Modifier?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.pasich.encly.editor.ui/TextRangeOverlay(com.pasich.encly.editor.ui/EditorBlocksHost, androidx.compose.foundation.lazy/LazyListState, androidx.compose.ui/Modifier?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.pasich.encly.editor.ui/TextRangeOverlay|TextRangeOverlay(com.pasich.encly.editor.ui.EditorBlocksHost;androidx.compose.foundation.lazy.LazyListState;androidx.compose.ui.Modifier?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_EditorBlocksHost$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_EditorBlocksHost$stableprop_getter|com_pasich_encly_editor_ui_EditorBlocksHost$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_FindHighlights$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_FindHighlights$stableprop_getter|com_pasich_encly_editor_ui_FindHighlights$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_SelectedBlocksActions$stableprop_getter(): kotlin/Int // com.pasich.encly.editor.ui/com_pasich_encly_editor_ui_SelectedBlocksActions$stableprop_getter|com_pasich_encly_editor_ui_SelectedBlocksActions$stableprop_getter(){}[0] +final fun com.pasich.encly.editor.ui/rememberBlockFocusRegistry(kotlin.collections/List, androidx.compose.runtime/Composer?, kotlin/Int): com.pasich.encly.editor.focus/BlockFocusRegistry // com.pasich.encly.editor.ui/rememberBlockFocusRegistry|rememberBlockFocusRegistry(kotlin.collections.List;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun com.pasich.encly.editor.ui/rememberWriteAtEnd(com.pasich.encly.editor.ui/EditorController, androidx.compose.runtime/Composer?, kotlin/Int): kotlin/Function0 // com.pasich.encly.editor.ui/rememberWriteAtEnd|rememberWriteAtEnd(com.pasich.encly.editor.ui.EditorController;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun com.pasich.encly.editor/EditorProvider(com.pasich.encly.editor/EditorStrings?, com.pasich.encly.editor.theme/EditorTheme?, com.pasich.encly.editor/EditorClipboard?, com.pasich.encly.editor/LinkOpener?, com.pasich.encly.editor/EditorLogger?, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.pasich.encly.editor/EditorProvider|EditorProvider(com.pasich.encly.editor.EditorStrings?;com.pasich.encly.editor.theme.EditorTheme?;com.pasich.encly.editor.EditorClipboard?;com.pasich.encly.editor.LinkOpener?;com.pasich.encly.editor.EditorLogger?;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] diff --git a/editor/build.gradle.kts b/editor/build.gradle.kts new file mode 100644 index 00000000..96b9dfd2 --- /dev/null +++ b/editor/build.gradle.kts @@ -0,0 +1,127 @@ +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit + +// The block editor as a Kotlin Multiplatform library (Android, desktop JVM, iOS). It depends on nothing +// of Encly: the app binds its extension points (strings, theme, clipboard, links) from outside. +plugins { + id("encly.kmp-library") + id("encly.kmp-ios") + id("encly.compose") + id("encly.quality") + id("org.jetbrains.dokka") +} + +// The shared detekt config plus the module's own (KDoc on every public declaration). Findings that +// predate the move out of the app are captured in detekt-baseline.xml, which should only ever shrink. +detekt { + config.setFrom(rootProject.files("config/detekt/detekt.yml"), files("config/detekt.yml")) + baseline = file("detekt-baseline.xml") +} + +// Minimum line coverage (%) of the whole module and branch coverage (%) of its logic packages, both +// enforced by koverVerify. +val editorMinCoverage = 100 + +kotlin { + android { + namespace = "com.pasich.encly.editor" + } + + sourceSets { + commonMain.dependencies { + // StateFlow and Flow are part of the public API. + api(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + getByName("androidHostTest").dependencies { + // Compose UI tests run on the JVM under Robolectric, no emulator needed. + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + implementation(libs.junit) + implementation(libs.androidx.test.core) + implementation(libs.mockito.core) + implementation(libs.robolectric) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.androidx.compose.ui.test.manifest) + } + } +} + +// Coverage: `./gradlew :editor:koverVerify`. Every line of the module is covered by its own tests, and +// so is every branch of its logic (model, codec, state, focus, theme). The branches of @Composable +// functions are not gated: the Compose compiler adds `$changed`/`$default` checks to every one of them, +// which no test can take both ways. +kover { + // The same classes and tests as the total report (the desktop JVM tests and the Android host tests), + // for the branch gate of the logic packages below. + currentProject { + createVariant("editorLogic") { + add("desktop") + add("android") + } + } + reports { + total { + filters { + excludes { + classes("*ComposableSingletons*") + } + } + xml { onCheck.set(false) } + html { onCheck.set(false) } + verify { + rule("Editor line coverage") { + bound { + minValue = editorMinCoverage + coverageUnits = CoverageUnit.LINE + } + } + } + } + variant("editorLogic") { + filters { + includes { + packages( + "com.pasich.encly.editor.model", + "com.pasich.encly.editor.codec", + "com.pasich.encly.editor.state", + "com.pasich.encly.editor.focus", + "com.pasich.encly.editor.theme", + ) + } + } + verify { + rule("Editor logic branch coverage") { + bound { + minValue = editorMinCoverage + coverageUnits = CoverageUnit.BRANCH + } + } + } + } + } +} + +dokka { + moduleName.set("Encly editor") + dokkaSourceSets.configureEach { + includes.from("Module.md") + } + // A KDoc link that does not resolve fails the build instead of rotting in the published docs. + dokkaPublications.configureEach { + failOnWarning.set(true) + } +} + +// Dokka 2.2 cannot be stored in the configuration cache that this build turns on ("circular evaluation" +// of its plugin parameters); the Dokka tasks run without it. +tasks.matching { it.name.startsWith("dokkaGenerate") }.configureEach { + notCompatibleWithConfigurationCache("Dokka 2.2 plugin parameters cannot be serialized") +} + +// Generating the docs is part of `check`, so a broken KDoc link or Module.md fails it. +tasks.named("check") { + dependsOn("dokkaGeneratePublicationHtml") +} diff --git a/editor/config/detekt.yml b/editor/config/detekt.yml new file mode 100644 index 00000000..cd3be00d --- /dev/null +++ b/editor/config/detekt.yml @@ -0,0 +1,26 @@ +# Editor-only detekt rules, layered on config/detekt/detekt.yml. The module is a library, so every +# public class and function carries KDoc (explicit API mode already requires its visibility and types). +# Enum entries and the properties of token classes (spacing, font sizes) are named by their type's KDoc; +# the property rule would make each of them repeat it. +# +# detekt's own defaults leave tests out of the style, complexity and comments rules for the source sets +# it knows (test, commonTest, jvmTest, ...). The module's `androidHostTest` and `desktopTest` are not +# among them, so they are named here. +comments: + excludes: &tests ['**/commonTest/**', '**/androidHostTest/**', '**/desktopTest/**'] + UndocumentedPublicClass: + active: true + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: true + UndocumentedPublicProperty: + active: false + +complexity: + excludes: *tests + +style: + excludes: *tests diff --git a/editor/detekt-baseline.xml b/editor/detekt-baseline.xml new file mode 100644 index 00000000..c5d9cc1c --- /dev/null +++ b/editor/detekt-baseline.xml @@ -0,0 +1,13 @@ + + + + + ComplexCondition:ListBlock.kt$firstItem != null && firstItem.value.isEmpty() && itemsList.size == 1 && focusedItemIndex == null + CyclomaticComplexMethod:ListBlock.kt$@Composable fun ListBlock( block: Block.ListBlock, blockActions: BlockActions, index: Int, modifier: Modifier = Modifier, isLocked: Boolean = false, ) + LongMethod:ActionLinkBottomSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ActionLinkBottomSheet( settings: SettingsBlockDialog, sheetState: SheetState, onAction: (ActionBlockDialog) -> Unit, onDismiss: () -> Unit, ) + LongMethod:ListBlock.kt$@Composable fun ListBlock( block: Block.ListBlock, blockActions: BlockActions, index: Int, modifier: Modifier = Modifier, isLocked: Boolean = false, ) + MagicNumber:ListBlock.kt$0.7f + MagicNumber:ListBlock.kt$50 + TooManyFunctions:BlockOperations.kt$BlockOperations + + diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt new file mode 100644 index 00000000..0eb5acde --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt @@ -0,0 +1,121 @@ +package com.pasich.encly.editor + +import android.app.Application +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.createComposeRule +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.theme.EditorIcons +import com.pasich.encly.editor.theme.EditorLineSpacing +import com.pasich.encly.editor.theme.EditorTheme +import com.pasich.encly.editor.theme.LocalEditorTheme +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** What the editor takes from its host, and what it does when the host gives nothing. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class EditorServicesTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun englishIsTheDefaultTextOfEveryKey() { + EditorString.entries.forEach { assertEquals(it.english, EnglishEditorStrings.text(it)) } + assertEquals("Heading 3", EnglishEditorStrings.heading(3)) + assertEquals("Blocks selected: 4", EnglishEditorStrings.selectionCount(4)) + } + + @Test + fun everyKeyHasATextOfItsOwn() { + assertEquals(EditorString.entries.size, EditorString.entries.map { it.name }.toSet().size) + assertTrue(EditorString.entries.all { it.english.isNotBlank() }) + } + + @Test + fun withoutAHostTheEditorUsesEnglishAndInertServices() { + var seen: List? = null + rule.setContent { + seen = listOf( + LocalEditorStrings.current, + LocalEditorClipboard.current, + LocalLinkOpener.current, + LocalEditorLogger.current, + LocalEditorTheme.current, + ) + } + + assertSame(EnglishEditorStrings, seen!![0]) + assertSame(EditorClipboard.None, seen!![1]) + assertSame(LinkOpener.None, seen!![2]) + assertSame(EditorLogger.None, seen!![3]) + assertEquals(EditorTheme().spacing, (seen!![4] as EditorTheme).spacing) + // The inert services take a call and do nothing. + EditorClipboard.None.copy("label", "text", sensitive = true) + LinkOpener.None.open("https://example.com") + EditorLogger.None.warn("tag", "event") + } + + @Test + fun theProviderHandsTheHostsServicesToTheContent() { + val strings = object : EditorStrings { + override fun text(key: EditorString) = "t:${key.name}" + + override fun heading(level: Int) = "h$level" + + override fun selectionCount(count: Int) = "n$count" + } + val theme = EditorTheme(lineSpacing = EditorLineSpacing.COMPACT) + val clipboard = EditorClipboard { _, _, _ -> } + val opener = LinkOpener { } + val logger = EditorLogger { _, _ -> } + var seen: List? = null + + rule.setContent { + MaterialTheme { + EditorProvider(strings, theme, clipboard, opener, logger) { + seen = listOf( + LocalEditorStrings.current, + LocalEditorTheme.current, + LocalEditorClipboard.current, + LocalLinkOpener.current, + LocalEditorLogger.current, + ) + } + } + } + + assertEquals(listOf(strings, theme, clipboard, opener, logger), seen) + } + + @Test + fun anUnreadableNoteIsReportedAsAFixedEventWithoutItsContent() { + val events = mutableListOf>() + + assertNull(BlockConverter.jsonToBlocksOrNull("secret {", EditorLogger { tag, event -> events += tag to event })) + + assertEquals(1, events.size) + assertEquals("BlockConverter", events.single().first) + assertTrue("secret" !in events.single().second, events.single().second) + assertEquals(emptyList(), BlockConverter.jsonToBlocksOrNull("[]")) + } + + @Test + fun theDefaultIconsAreDistinctDrawings() { + val icons = EditorIcons() + val all = listOf( + icons.text, icons.heading1, icons.heading2, icons.heading3, icons.heading4, icons.checklist, + icons.bulletList, icons.numberedList, icons.quote, icons.link, icons.separator, icons.globe, + icons.plus, icons.check, icons.external, icons.copy, icons.edit, icons.arrowUp, icons.arrowDown, + icons.duplicate, icons.trash, icons.close, icons.cut, icons.lock, icons.lockOpen, icons.grip, + ) + assertEquals(all.size, all.map { it.name }.toSet().size) + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusBranchTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusBranchTest.kt new file mode 100644 index 00000000..5aa6c4c5 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusBranchTest.kt @@ -0,0 +1,140 @@ +package com.pasich.encly.editor.focus + +import android.app.Application +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +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.test.junit4.createComposeRule +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.sp +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.editor.EditorLogger +import com.pasich.encly.editor.LocalEditorLogger +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.state.FocusRequest +import com.pasich.encly.editor.ui.RegisterFocusRequester +import com.pasich.encly.editor.ui.blocks.firstLineCentre +import com.pasich.encly.editor.ui.blocks.removesEmptyAddress +import com.pasich.encly.editor.ui.rememberBlockFocusRegistry +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import android.view.KeyEvent as AndroidKeyEvent + +/** The remaining directions of the focus and key rules: right-to-left text, stale layouts, changing keys. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class FocusBranchTest { + @get:Rule + val rule = createComposeRule() + + private fun fakeGeometry(lines: Int, length: Int) = object : CaretGeometry { + override val lineCount = lines + override val textLength = length + override fun lineOf(offset: Int) = 0 + override fun xOf(offset: Int) = 0f + override fun offsetAt(line: Int, x: Float) = 3 + override fun isRtl(offset: Int) = false + } + + private fun androidKey(code: Int, action: Int = AndroidKeyEvent.ACTION_DOWN) = + KeyEvent(AndroidKeyEvent(0L, 0L, action, code, 0, 0)) + + @Test + fun aRightToLeftParagraphIsReported() { + val context = ApplicationProvider.getApplicationContext() + val measurer = TextMeasurer(createFontFamilyResolver(context), Density(context), LayoutDirection.Ltr) + val layout = measurer.measure( + "שלום עולם", + TextStyle(fontSize = 16.sp, textDirection = TextDirection.Rtl), + constraints = Constraints(maxWidth = 1000), + layoutDirection = LayoutDirection.Rtl, + ) + assertTrue(layout.geometry().isRtl(0)) + } + + @Test + fun aStaleOrMissingLayoutIsNotTrusted() { + assertEquals(true, caretLine(fakeGeometry(0, 5), 5, 0).onFirst) + assertEquals(true, caretLine(fakeGeometry(2, 9), 5, 0).onFirst) + assertEquals(true, caretLine(fakeGeometry(2, 5), 5, 0).onFirst) + assertEquals(5, caretOffsetFor(fakeGeometry(0, 5), 0f, fromBelow = true, length = 5)) + assertEquals(0, caretOffsetFor(fakeGeometry(0, 5), 0f, fromBelow = false, length = 5)) + assertEquals(3, caretOffsetFor(fakeGeometry(2, 5), 0f, fromBelow = false, length = 5)) + } + + @Test + fun aCaretLineDecidesWhereArrowsLeave() { + val handlers = KeyboardUtils.KeyHandlers(onNavigateDown = { true }, onNavigateUp = { true }) + val line = CaretLine(onFirst = false, onLast = true, x = null) + assertTrue(KeyboardUtils.handleKey(Key.DirectionDown, KeyEventType.KeyDown, "abc", 1, handlers, line)) + assertFalse(KeyboardUtils.handleKey(Key.DirectionUp, KeyEventType.KeyDown, "abc", 1, handlers, line)) + assertTrue(KeyboardUtils.handleKey(Key.DirectionDown, KeyEventType.KeyDown, "abc", 3, handlers, null)) + assertFalse(KeyboardUtils.handleKey(Key.DirectionDown, KeyEventType.KeyDown, "abc", 1, handlers, null)) + assertFalse(KeyboardUtils.handleKey(Key.A, KeyEventType.KeyDown, "abc", 1, handlers, null)) + } + + @Test + fun theAddressFieldGivesUpItsBlockOnlyOnBackspaceWhenEmpty() { + assertTrue(removesEmptyAddress(androidKey(AndroidKeyEvent.KEYCODE_DEL), "")) + assertFalse(removesEmptyAddress(androidKey(AndroidKeyEvent.KEYCODE_DEL), "x")) + assertFalse(removesEmptyAddress(androidKey(AndroidKeyEvent.KEYCODE_DEL, AndroidKeyEvent.ACTION_UP), "")) + assertFalse(removesEmptyAddress(androidKey(AndroidKeyEvent.KEYCODE_A), "")) + } + + @Test + fun theFirstLineCentreFollowsTheBoxOrTheCapitals() { + assertEquals(5f, firstLineCentre(top = 0f, bottom = 10f, natural = 8f, baseline = 9f, capHeight = 6f), 0f) + assertEquals(6f, firstLineCentre(top = 0f, bottom = 6f, natural = 8f, baseline = 9f, capHeight = 6f), 0f) + } + + @Test + fun targetsWithoutCallbacksOnlyTakeFocus() { + val registry = BlockFocusRegistry { emptyList() } + var focused = 0 + registry.registerFocusTarget("a") { focused++ } + assertTrue(registry.focus(FocusRequest("a", caretX = 2f))) + assertTrue(registry.focus(FocusRequest("a", caret = 2))) + assertTrue(registry.focus(FocusRequest("a"))) + assertEquals(3, focused) + } + + @Test + fun theRegistryAndTheFieldFollowTheirKeys() { + val blocksA = listOf(Block.TextBlock()) + val blocksB = listOf(Block.TextBlock()) + val which = mutableStateOf(blocksA) + val id = mutableStateOf(blocksA[0].id) + val requester = FocusRequester() + val seen = mutableListOf() + rule.setContent { + CompositionLocalProvider(LocalEditorLogger provides EditorLogger.None) { + val registry = rememberBlockFocusRegistry(which.value) + seen += registry + RegisterFocusRequester(id.value, registry, requester) + } + } + rule.waitForIdle() + which.value = blocksB + id.value = blocksB[0].id + rule.waitForIdle() + + assertTrue(seen.distinct().size >= 2) + assertTrue(seen.last().focus(blocksB[0].id)) + assertFalse(seen.last().focus(blocksA[0].id)) + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusGapsTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusGapsTest.kt new file mode 100644 index 00000000..16dcbde1 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusGapsTest.kt @@ -0,0 +1,165 @@ +package com.pasich.encly.editor.focus + +import android.app.Application +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.sp +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.editor.EditorLogger +import com.pasich.encly.editor.LocalEditorLogger +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.state.FocusRequest +import com.pasich.encly.editor.ui.RegisterFocusRequester +import com.pasich.encly.editor.ui.rememberBlockFocusRegistry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import android.view.KeyEvent as AndroidKeyEvent + +/** Focus handling that needs Compose types (layouts, key events, composition) rather than a screen. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class FocusGapsTest { + @get:Rule + val rule = createComposeRule() + + private fun key(code: Int, action: Int = AndroidKeyEvent.ACTION_DOWN, meta: Int = 0) = + KeyEvent(AndroidKeyEvent(0L, 0L, action, code, 0, meta)) + + // --- keys -------------------------------------------------------------------------------- + + @Test + fun enterRunsItsHandlerOnKeyDownOnly() { + var entered = 0 + val handled = KeyboardUtils.handleKeyEvent(key(AndroidKeyEvent.KEYCODE_ENTER), onEnterPressed = { + entered++ > -1 + }) + val ignored = KeyboardUtils.handleKeyEvent( + key(AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.ACTION_UP), + onEnterPressed = { entered++ > -1 }, + ) + + assertTrue(handled) + assertFalse(ignored) + assertEquals(1, entered) + } + + @Test + fun anArrowWithAModifierNeverLeavesTheField() { + val shiftDown = key(AndroidKeyEvent.KEYCODE_DPAD_DOWN, meta = AndroidKeyEvent.META_SHIFT_ON) + assertFalse(KeyboardUtils.handleKeyEvent(shiftDown, onNavigateDown = { true })) + for (meta in listOf(AndroidKeyEvent.META_CTRL_ON, AndroidKeyEvent.META_ALT_ON, AndroidKeyEvent.META_META_ON)) { + assertFalse( + KeyboardUtils.handleKeyEvent(key(AndroidKeyEvent.KEYCODE_DPAD_UP, meta = meta), onNavigateUp = { + true + }), + ) + } + assertTrue(KeyboardUtils.handleKeyEvent(key(AndroidKeyEvent.KEYCODE_DPAD_DOWN), onNavigateDown = { true })) + } + + // --- layouts ----------------------------------------------------------------------------- + + @Test + fun aMeasuredTextAnswersTheCaretQuestions() { + val context = ApplicationProvider.getApplicationContext() + val measurer = TextMeasurer(createFontFamilyResolver(context), Density(context), LayoutDirection.Ltr) + val layout = measurer.measure( + "alpha beta gamma delta", + TextStyle(fontSize = 16.sp), + constraints = Constraints(maxWidth = 100), + ) + val geometry = layout.geometry() + + assertTrue(geometry.lineCount >= 1) + assertEquals(22, geometry.textLength) + assertEquals(0, geometry.lineOf(0)) + assertTrue(geometry.lineOf(21) >= 0) + assertTrue(geometry.xOf(0) >= 0f) + assertFalse(geometry.isRtl(0)) + assertFalse(geometry.isRtl(500)) + assertTrue(geometry.offsetAt(0, 1_000f) in 0..22) + assertNotNull(caretLine(geometry, 22, 5)) + assertEquals(true, caretLine(geometry, 22, 0).onFirst) + assertNotNull(caretLine(geometry, 22, 22)) + assertTrue(caretOffsetFor(geometry, 0f, fromBelow = true, length = 22) in 0..22) + } + + @Test + fun withoutALayoutOnlyTheEndsOfTheTextAreTheFirstAndLastLine() { + assertEquals(true, caretLine(null, 5, 0).onFirst) + assertEquals(false, caretLine(null, 5, 2).onFirst) + assertEquals(5, caretOffsetFor(null, 0f, fromBelow = true, length = 5)) + assertEquals(0, caretOffsetFor(null, 0f, fromBelow = false, length = 5)) + assertNull(caretLine(null, 5, 2).x) + } + + // --- the registry ------------------------------------------------------------------------ + + @Test + fun aTargetThatNeverAppearsIsReportedOnceAndAFailingOneIsSurvived() = runTest { + val warnings = mutableListOf() + val blocks = listOf(Block.TextBlock(), Block.TextBlock()) + val registry = BlockFocusRegistry(EditorLogger { _, event -> warnings += event }) { blocks } + + registry.focusWhenComposed(FocusRequest("nowhere")) + assertEquals(listOf("Focus target never appeared"), warnings) + + registry.registerFocusTarget(blocks[0].id) { throw IllegalStateException("not attached") } + assertTrue(registry.focus(blocks[0].id)) + assertTrue(warnings.last().startsWith("Focus request failed")) + + var scrolled = "" + registry.focusWhenComposed(FocusRequest(blocks[0].id)) { scrolled = it } + assertEquals("", scrolled) + } + + @Test + fun theNeighboursOfABlockThatIsNotThereAreNone() { + val blocks = listOf(Block.TextBlock(), Block.TextBlock()) + val registry = BlockFocusRegistry { blocks } + + assertNull(registry.nextFocusableBlock("gone")) + assertNull(registry.previousFocusableBlock("gone")) + assertNull(registry.previousFocusableBlock(blocks[0].id)) + assertNull(registry.nextFocusableBlock(blocks[1].id)) + } + + @Test + fun theRegistryFollowsTheCompositionAndUnregistersWithIt() { + val blocks = mutableStateListOf(Block.TextBlock()) + val requester = FocusRequester() + var registry: BlockFocusRegistry? = null + val show = androidx.compose.runtime.mutableStateOf(true) + rule.setContent { + androidx.compose.runtime.CompositionLocalProvider(LocalEditorLogger provides EditorLogger.None) { + registry = rememberBlockFocusRegistry(blocks) + if (show.value) RegisterFocusRequester(blocks[0].id, registry!!, requester) + } + } + rule.waitForIdle() + assertTrue(registry!!.focus(blocks[0].id)) // registered while composed + + show.value = false + rule.waitForIdle() + assertFalse(registry!!.focus(blocks[0].id)) // gone with the field + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/BlockSelectionUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/BlockSelectionUiTest.kt new file mode 100644 index 00000000..a3092977 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/BlockSelectionUiTest.kt @@ -0,0 +1,432 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.rightClick +import androidx.compose.ui.test.withKeyDown +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.state.BlockEditorState +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Selecting whole blocks: how a block is picked (a long press in its gutter, TalkBack's action, Select all pressed + * twice, a tap while picking), what the bar over them does, the keys of a hardware keyboard, and the lock toggle + * of a checklist. + */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class BlockSelectionUiTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var state: BlockEditorState + private lateinit var controller: StateController + private val calls = mutableListOf() + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun show(vararg blocks: Block, locked: Boolean = false, canEdit: Boolean = true) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + controller = StateController(state) + val actions = SelectedBlocksActions( + canEdit = canEdit, + onCopy = { calls += "copy" }, + onCopyMarkdown = { calls += "markdown" }, + onCut = { calls += "cut" }, + onDelete = { calls += "delete" }, + ) + rule.setContent { + EditorHarness( + controller, + { _, _ -> }, + locked, + bar = { + val ids by state.pickedBlocks.ids.collectAsState() + if (ids.isNotEmpty()) SelectedBlocksBar(state.pickedBlocks, actions) + }, + ) + } + rule.waitForIdle() + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + private fun picked() = rule.runOnIdle { state.pickedBlocks.picked().map { it.id } } + + private fun idsOf(vararg at: Int) = at.map { state.blocks[it].id } + + private fun pick(vararg at: Int) = rule.runOnIdle { + state.pickedBlocks.select(state.blocks[at.first()].id) + at.drop(1).forEach { state.pickedBlocks.toggle(state.blocks[it].id) } + }.also { rule.waitForIdle() } + + private fun button(label: String) = rule.onNode(hasContentDescription(label) and hasClickAction()) + + private fun chord(keys: List, then: Key) = rule.onNodeWithText("Blocks selected: ${picked().size}") + .performKeyInput { + keys.forEach { keyDown(it) } + pressKey(then) + keys.reversed().forEach { keyUp(it) } + } + + // --- picking ------------------------------------------------------------------------------------- + + @Test + fun aLongPressInTheGutterOfABlockPicksIt() { + show(text("a"), text("b"), text("c")) + val y = fields()[1].fetchSemanticsNode().boundsInRoot.center.y + + rule.onRoot().performTouchInput { longClick(Offset(10f * density, y)) } + rule.waitForIdle() + + assertEquals(idsOf(1), picked()) + rule.onNodeWithText("Blocks selected: 1").assertExists() + } + + @Test + fun talkBackAndTheContextMenuOfferToSelectABlock() { + show(text("a"), Block.SeparatorBlock(), text("c")) + + rule.onNode(hasContentDescription("Separator") and hasClickAction()).performMouseInput { rightClick() } + rule.waitForIdle() + rule.onNodeWithText("Select block").performClick() + rule.waitForIdle() + + assertEquals(idsOf(1), picked()) + } + + @Test + fun whilePickingATapPicksOrReleasesABlockAndTheLastReleaseEndsTheMode() { + show(text("a"), text("b"), text("c")) + pick(0) + + fields()[2].performClick() + rule.waitForIdle() + assertEquals(idsOf(0, 2), picked()) + + fields()[0].performClick() + fields()[2].performClick() + rule.waitForIdle() + + assertTrue(picked().isEmpty()) + rule.onNodeWithText("Blocks selected: 1").assertDoesNotExist() + } + + @Test + fun aTapWhilePickingDoesNotPutTheCursorInTheText() { + show(text("a"), text("b")) + pick(0) + + fields()[1].performClick() + rule.waitForIdle() + + assertEquals(null, rule.runOnIdle { state.selection.focusedBlockId.value }) + } + + @Test + fun pickedBlocksAreMarkedSelectedAndHighlighted() { + show(text("a"), text("b"), text("c")) + pick(0, 1) + + rule.onAllNodes(SemanticsMatcher.expectValue(SemanticsProperties.Selected, true)).assertCountEquals(2) + // The highlight is drawn behind the picked blocks. + rule.onRoot().captureToImage() + } + + // --- Select all twice ---------------------------------------------------------------------------- + + private fun selectAllKey(index: Int) = fields()[index].performKeyInput { + withKeyDown(Key.CtrlLeft) { pressKey(Key.A) } + } + + /** What the field's own Select all does: its whole text selected. */ + private fun selectFieldText(index: Int, length: Int) { + fields()[index].performSemanticsAction(SemanticsActions.SetSelection) { it(0, length, true) } + rule.waitForIdle() + } + + @Test + fun theFirstSelectAllSelectsTheFieldsTextAndTheSecondTheWholeNote() { + show(text("abc"), text("def"), Block.SeparatorBlock()) + fields()[0].performClick() + // Only part of the text is selected: the key is the field's own. + selectFieldText(0, 1) + fields()[0].performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.A) } } + rule.waitForIdle() + assertFalse(state.textRange.isCrossField) + + selectFieldText(0, 3) + selectAllKey(0) + rule.waitForIdle() + assertTrue(state.textRange.isCrossField) + assertEquals(3, state.textRange.blockCount()) + } + + @Test + fun anEmptyFieldSelectsTheNoteAtOnce() { + show(text(""), text("def")) + fields()[0].performClick() + + selectAllKey(0) + rule.waitForIdle() + + assertEquals(2, state.textRange.blockCount()) + } + + @Test + fun aListItemDoesTheSame() { + val bullets = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("x"))), BlockType.LIST_BULLET) + show(text("above"), bullets) + fields()[1].performClick() + + selectFieldText(1, 1) + selectAllKey(1) + rule.waitForIdle() + + assertEquals(2, state.textRange.blockCount()) + } + + @Test + fun whereSelectAllIsNotOfferedTheFieldKeepsTheKey() { + show(text(""), text("def")) + controller.offersSelectAll = false + fields()[0].performClick() + + selectAllKey(0) + rule.waitForIdle() + + assertFalse(state.textRange.isCrossField) + } + + @Test + fun otherChordsOfAAreTheFieldsOwn() { + show(text(""), text("def")) + fields()[0].performClick() + + fields()[0].performKeyInput { pressKey(Key.A) } + fields()[0].performKeyInput { withKeyDown(Key.CtrlLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.A) } } } + rule.waitForIdle() + + assertFalse(state.textRange.isCrossField) + } + + // --- the bar ------------------------------------------------------------------------------------- + + @Test + fun theBarCountsThePickedBlocksAndAnnouncesTheCountToTalkBack() { + show(text("a"), text("b"), text("c")) + pick(0, 1) + + rule.onNodeWithText("Blocks selected: 2") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.LiveRegion, LiveRegionMode.Polite)) + rule.runOnIdle { state.pickedBlocks.toggle(state.blocks[2].id) } + rule.waitForIdle() + rule.onNodeWithText("Blocks selected: 3").assertExists() + } + + @Test + fun theButtonsOfTheBarAskTheHost() { + show(text("a"), text("b")) + pick(0) + + button("Copy").performClick() + button("Copy as Markdown").performClick() + button("Cut").performClick() + button("Delete selected blocks").performClick() + + assertEquals(listOf("copy", "markdown", "cut", "delete"), calls) + } + + @Test + fun cutAndDeleteAreOffAWhereTheNoteCannotChange() { + show(text("a"), text("b"), canEdit = false) + pick(0) + + button("Copy").assertIsEnabled() + button("Copy as Markdown").assertIsEnabled() + button("Cut").assertIsNotEnabled() + button("Delete selected blocks").assertIsNotEnabled() + } + + @Test + fun theCloseButtonLeavesTheMode() { + show(text("a"), text("b")) + pick(0, 1) + + button("Clear selection").performClick() + rule.waitForIdle() + + assertTrue(picked().isEmpty()) + rule.onNodeWithText("Blocks selected: 2").assertDoesNotExist() + } + + @Test + fun theKeyboardHidesAndTheBarHoldsTheKeys() { + show(text("a"), text("b"), text("c")) + fields()[1].performClick() + pick(0) + + // The field that had focus has lost it: typing does not edit a picked block. + assertEquals(null, rule.runOnIdle { state.selection.focusedBlockId.value }) + } + + // --- a hardware keyboard ------------------------------------------------------------------------- + + @Test + fun shiftArrowsExtendTheSelectionAndEscapeLeaves() { + show(text("a"), text("b"), text("c"), text("d")) + pick(1) + + chord(listOf(Key.ShiftLeft), then = Key.DirectionDown) + rule.waitForIdle() + assertEquals(idsOf(1, 2), picked()) + + chord(listOf(Key.ShiftLeft), then = Key.DirectionUp) + chord(listOf(Key.ShiftLeft), then = Key.DirectionUp) + rule.waitForIdle() + assertEquals(idsOf(0, 1), picked()) + + rule.onNodeWithText("Blocks selected: 2").performKeyInput { pressKey(Key.Escape) } + rule.waitForIdle() + assertTrue(picked().isEmpty()) + } + + @Test + fun controlAPicksEverythingAndTheClipboardKeysAskTheHost() { + show(text("a"), text("b"), text("c")) + pick(0) + + chord(listOf(Key.CtrlLeft), then = Key.A) + rule.waitForIdle() + assertEquals(3, picked().size) + + chord(listOf(Key.CtrlLeft), then = Key.C) + chord(listOf(Key.CtrlLeft, Key.ShiftLeft), then = Key.C) + chord(listOf(Key.MetaLeft), then = Key.X) + rule.onNodeWithText("Blocks selected: 3").performKeyInput { pressKey(Key.Delete) } + + assertEquals(listOf("copy", "markdown", "cut", "delete"), calls) + } + + @Test + fun cutAndDeleteKeysDoNothingWhereTheNoteCannotChange() { + show(text("a"), text("b"), canEdit = false) + pick(0) + + chord(listOf(Key.CtrlLeft), then = Key.X) + rule.onNodeWithText("Blocks selected: 1").performKeyInput { pressKey(Key.Backspace) } + + assertTrue(calls.isEmpty()) + } + + @Test + fun otherKeysAreNotTakenByTheBar() { + show(text("a"), text("b")) + pick(0) + + rule.onNodeWithText("Blocks selected: 1").performKeyInput { pressKey(Key.Q) } + rule.waitForIdle() + + assertFalse(picked().isEmpty()) + assertTrue(calls.isEmpty()) + } + + // --- the lock of a checklist ----------------------------------------------------------------------- + + private fun checklist() = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("x"))), BlockType.LIST_CHECK) + + @Test + fun aChecklistTheUserWorksInOffersALockThatLocksAndUnlocksIt() { + val checks = checklist() + show(text("above"), checks) + rule.onNodeWithContentDescription("Lock checklist").assertDoesNotExist() + + fields()[1].performClick() + rule.waitForIdle() + rule.onNodeWithContentDescription("Lock checklist").performClick() + rule.waitForIdle() + + assertTrue(checks.locked.value) + rule.onNodeWithContentDescription("Lock checklist").assertDoesNotExist() + rule.onNodeWithContentDescription("Unlock checklist").performClick() + rule.waitForIdle() + + assertFalse(checks.locked.value) + rule.onNodeWithContentDescription("Lock checklist").assertExists() + } + + @Test + fun aLockedChecklistOffersToUnlockOnceTheUserWorksOnIt() { + val checks = checklist().also { it.locked.value = true } + show(text("above"), checks) + rule.onNodeWithContentDescription("Unlock checklist").assertDoesNotExist() + + rule.runOnIdle { state.selection.onInteraction(checks.id) } + rule.waitForIdle() + + rule.onNodeWithContentDescription("Unlock checklist").assertExists() + } + + @Test + fun otherListsHaveNoLockToggle() { + val bullets = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("x"))), BlockType.LIST_BULLET) + show(text("above"), bullets) + + fields()[1].performClick() + rule.waitForIdle() + + rule.onNodeWithContentDescription("Lock checklist").assertDoesNotExist() + } + + @Test + fun aLockedEditorOffersNoLockToggle() { + val checks = checklist() + show(text("above"), checks, locked = true) + + rule.runOnIdle { state.selection.onInteraction(checks.id) } + rule.waitForIdle() + + rule.onNodeWithContentDescription("Lock checklist").assertDoesNotExist() + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/CopiedPasteUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/CopiedPasteUiTest.kt new file mode 100644 index 00000000..9db32b00 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/CopiedPasteUiTest.kt @@ -0,0 +1,80 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.state.BlockEditorState +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Pasting what the editor itself copied or cut, typed into the fields the way a paste arrives. */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class CopiedPasteUiTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var state: BlockEditorState + + private fun show(vararg blocks: Block) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + val controller = StateController(state) + rule.setContent { EditorHarness(controller, { _, _ -> }) } + rule.waitForIdle() + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + @Test + fun aPastedCutParagraphReadsAsItWasNotAsItsMarkdown() { + val target = Block.TextBlock() + show(target) + state.rememberCopied("price\\*2", listOf(Block.TextBlock(MutableStateFlow("price*2")))) + + fields()[0].performClick() + fields()[0].performTextInput("price\\*2") + rule.waitForIdle() + + assertEquals("price*2", (state.blocks.single() as Block.TextBlock).text.value) + } + + @Test + fun aPastedCutListItemTextAndTableComeBackAsTheyWere() { + val item = ItemListBlock("") + val list = Block.ListBlock(MutableStateFlow(listOf(item)), BlockType.LIST_BULLET) + val target = Block.TextBlock() + show(list, target) + val table = Block.TableBlock(MutableStateFlow(TableData.of(listOf(listOf("a", "b|c"), listOf("1", "2"))))) + state.rememberCopied("| a | b\\|c |\n| --- | --- |\n| 1 | 2 |", listOf(table)) + + fields()[0].performClick() + fields()[0].performTextInput("| a | b\\|c |\n| --- | --- |\n| 1 | 2 |") + rule.waitForIdle() + // A list item takes the plain text of what was copied. + assertEquals("a | b|c\n1 | 2", (state.blocks[0] as Block.ListBlock).items.value.joinToString("\n") { it.value }) + + fields().onLast().performClick() + fields().onLast().performTextInput("| a | b\\|c |\n| --- | --- |\n| 1 | 2 |") + rule.waitForIdle() + + val pasted = state.blocks.filterIsInstance().single().table.value + assertEquals(listOf(listOf("a", "b|c"), listOf("1", "2")), pasted.rows.map { r -> r.cells.map { it.value } }) + assertTrue(state.blocks.none { it === table }) + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt new file mode 100644 index 00000000..14c7394e --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt @@ -0,0 +1,383 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.text.TextRange +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.ui.sheets.BlockSheetKind +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The editor's blocks driven the way a user does, with nothing of the app around them: a list of + * blocks over a plain state, typing, keys, taps and TalkBack's actions. + */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class EditorBlocksUiTest { + @get:Rule + val rule = createComposeRule() + + private val opened = mutableListOf>() + private lateinit var state: BlockEditorState + private lateinit var controller: StateController + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun list(type: BlockType, vararg values: String) = + Block.ListBlock(MutableStateFlow(values.map { ItemListBlock(it) }), type) + + private fun link(url: String, isError: Boolean = false) = + Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "t", url = url, isError = isError))) + + private fun show(vararg blocks: Block, locked: Boolean = false, after: (@Composable () -> Unit)? = null) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + controller = StateController(state) + rule.setContent { EditorHarness(controller, { kind, block -> opened += kind to block }, locked, after) } + rule.waitForIdle() + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + private fun idle() = rule.waitForIdle() + + private fun last(matcher: SemanticsMatcher) = + rule.onAllNodes(matcher).let { all -> all[all.fetchSemanticsNodes().size - 1] } + + private fun textsOf() = state.blocks.map { (it as? Block.TextBlock)?.text?.value ?: it::class.simpleName.orEmpty() } + + // --- a paragraph -------------------------------------------------------------------------- + + @Test + fun typingGoesToTheModelAndOnlyBlockShowsItsPlaceholder() { + show(Block.TextBlock()) + rule.onNodeWithText("Text").assertExists() + + fields()[0].performTextInput("hello") + idle() + + assertEquals(listOf("hello"), textsOf()) + } + + @Test + fun enterSplitsAtTheCursorAndBackspaceJoinsAgain() { + show(text("ab")) + fields()[0].performClick() + fields()[0].performTextInputSelection(TextRange(1)) + + fields()[0].performTextInput("\n") + idle() + assertEquals(listOf("a", "b"), textsOf()) + + fields()[1].performTextInputSelection(TextRange(0)) + fields()[1].performKeyInput { pressKey(Key.Backspace) } + idle() + assertEquals(listOf("ab"), textsOf()) + } + + @Test + fun aHashMarkMakesAHeading() = markMakes("# ") { it is Block.HBlock } + + @Test + fun aDashMarkMakesABulletList() = markMakes("- ") { (it as? Block.ListBlock)?.blockType == BlockType.LIST_BULLET } + + @Test + fun aBracketMarkMakesAChecklist() = markMakes("[] ") { (it as? Block.ListBlock)?.blockType == BlockType.LIST_CHECK } + + @Test + fun aNumberMarkMakesANumberedList() = markMakes("1. ") { + (it as? Block.ListBlock)?.blockType == + BlockType.LIST_NUMBER + } + + @Test + fun aGreaterThanMarkMakesAQuote() = markMakes("> ") { it is Block.QuoteBlock } + + @Test + fun threeDashesMakeASeparator() = markMakes("---") { it is Block.SeparatorBlock } + + private fun markMakes(mark: String, check: (Block) -> Boolean) { + show(text("")) + fields()[0].performClick() + fields()[0].performTextInput(mark) + idle() + assertTrue(check(state.blocks.first()), state.blocks.map { it::class.simpleName }.toString()) + } + + @Test + fun aLoneWebAddressPastedBecomesALink() { + show(text("")) + fields()[0].performClick() + + fields()[0].performTextInput("https://example.com/page") + idle() + + assertTrue(state.blocks.first() is Block.TextBlock || state.blocks.first() is Block.LinkBlock) + } + + // --- the slash picker --------------------------------------------------------------------- + + @Test + fun theSlashPickerOffersTheBlockKindsAndAPickTurnsTheParagraph() { + show(text("")) + fields()[0].performClick() + + fields()[0].performTextInput("/") + idle() + rule.onNodeWithText("Heading 1").assertExists() + rule.onNodeWithText("Heading 1").performClick() + idle() + + assertTrue(state.blocks.first() is Block.HBlock) + } + + @Test + fun aQueryNarrowsTheSlashPicker() { + show(text("")) + fields()[0].performClick() + + fields()[0].performTextInput("/") + idle() + fields()[0].performTextInput("quo") + idle() + + rule.onNodeWithText("Quote").assertExists() + fields()[0].performKeyInput { pressKey(Key.Escape) } + idle() + } + + // --- lists -------------------------------------------------------------------------------- + + @Test + fun typingAndEnterInAListWorkOnItsItems() { + val bullets = list(BlockType.LIST_BULLET, "a") + show(bullets) + fields()[0].performClick() + fields()[0].performTextInputSelection(TextRange(1)) + + fields()[0].performTextInput("b") + fields()[0].performTextInput("\n") + idle() + + assertEquals(listOf("ab", ""), bullets.items.value.map { it.value }) + } + + @Test + fun backspaceAtTheStartOfAnItemJoinsItToTheOneAbove() { + val bullets = list(BlockType.LIST_BULLET, "a", "b") + show(bullets) + fields()[1].performClick() + fields()[1].performTextInputSelection(TextRange(0)) + + fields()[1].performKeyInput { pressKey(Key.Backspace) } + idle() + + assertEquals(listOf("ab"), bullets.items.value.map { it.value }) + } + + @Test + fun tickingAChecklistBoxChecksTheItem() { + val checks = list(BlockType.LIST_CHECK, "milk") + show(checks) + + rule.onNode(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox)).performClick() + idle() + + assertTrue(checks.items.value.single().isCheck) + } + + @Test + fun arrowKeysMoveBetweenItemsAndBlocks() { + show(list(BlockType.LIST_NUMBER, "one", "two"), text("below")) + fields()[0].performClick() + + fields()[0].performKeyInput { pressKey(Key.DirectionDown) } + idle() + fields()[1].performKeyInput { pressKey(Key.DirectionDown) } + idle() + fields()[2].performKeyInput { pressKey(Key.DirectionUp) } + idle() + + assertTrue(state.selection.focusedBlockId.value != null) + } + + // --- links -------------------------------------------------------------------------------- + + @Test + fun anAddressIsEnteredAndSavedAsALink() { + val block = Block.LinkBlock() + show(block) + + rule.onNode(hasSetTextAction()).performTextInput("example.com") + rule.onNode( + hasClickAction() and SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ).performClick() + idle() + + assertEquals("https://example.com", block.block.value.url) + } + + @Test + fun anAddressThatMayNotBeSavedIsRejectedWithAMessage() { + val block = Block.LinkBlock() + show(block) + + rule.onNode(hasSetTextAction()).performTextInput("javascript:alert(1)") + rule.onNode( + hasClickAction() and SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ).performClick() + idle() + + assertEquals("", block.block.value.url) + rule.onNodeWithText("Only web (http, https) and email links can be saved or opened.").assertExists() + } + + @Test + fun aSavedLinkShowsAsACardThatOpensItsSheet() { + show(link("https://www.example.com/path?q=1")) + + rule.onNodeWithText("example.com", substring = true).assertExists() + rule.onAllNodes(hasClickAction())[0].performClick() + idle() + + assertEquals(BlockSheetKind.LINK, opened.single().first) + } + + @Test + fun aLinkThatCannotBeOpenedSaysSoAndOneThatFailedToLoadToo() { + show(link("javascript:alert(1)"), link("https://example.com", isError = true)) + + rule.onNodeWithText("Only web (http, https) and email links can be saved or opened.").assertExists() + rule.onNodeWithText("Couldn't load the content").assertExists() + } + + @Test + fun editingASavedLinkReopensItsAddressForEntry() { + val saved = link("https://example.com") + show(saved) + rule.runOnIdle { controller.editLink(saved) } + idle() + + rule.onNode(hasSetTextAction()).assertExists() + rule.onNode(hasSetTextAction()).performTextReplacement("https://example.com/more") + rule.onNode( + hasClickAction() and SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ).performClick() + idle() + + assertEquals("https://example.com/more", saved.block.value.url) + } + + // --- TalkBack, the page and find ---------------------------------------------------------- + + private fun customAction(label: String) { + val node = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.CustomActions)) + .fetchSemanticsNodes() + .first { node -> node.config[SemanticsActions.CustomActions].any { it.label == label } } + rule.runOnUiThread { node.config[SemanticsActions.CustomActions].first { it.label == label }.action() } + idle() + } + + @Test + fun talkBackCanMoveDuplicateAndDeleteABlock() { + show(text("a"), text("b"), text("c")) + fields()[1].performClick() + + customAction("Move block up") + assertEquals(listOf("b", "a", "c"), textsOf()) + customAction("Move block down") + assertEquals(listOf("a", "b", "c"), textsOf()) + customAction("Duplicate block") + assertEquals(4, state.blocks.size) + customAction("Delete block") + assertEquals(3, state.blocks.size) + } + + @Test + fun talkBackCanLockAChecklist() { + val checks = list(BlockType.LIST_CHECK, "x") + show(text("above"), checks) + + customAction("Lock checklist") + + assertTrue(checks.locked.value) + customAction("Unlock checklist") + assertTrue(!checks.locked.value) + } + + @Test + fun findTintsTheMatchesWithoutTouchingTheText() { + val bullets = list(BlockType.LIST_BULLET, "hello item") + show(text("hello world hello"), Block.HBlock(MutableStateFlow("hello title"), BlockType.H1), bullets) + + rule.runOnIdle { + state.find.open() + state.find.search("hello", state.blocks) + state.find.next() + } + idle() + + assertEquals("hello world hello", (state.blocks[0] as Block.TextBlock).text.value) + rule.onNodeWithText("hello world hello").assertExists() + } + + @Test + fun aSeparatorOpensItsSheetUnlessTheEditorIsLocked() { + show(text("a"), Block.SeparatorBlock()) + rule.onNode(hasContentDescription("Separator") and hasClickAction()).performClick() + idle() + assertEquals(BlockSheetKind.OTHER, opened.single().first) + } + + @Test + fun aLockedEditorOffersNoSheetAndNoPlaceholder() { + show(Block.SeparatorBlock(), text(""), locked = true) + + rule.onAllNodes(hasClickAction())[0].performClick() + idle() + + assertTrue(opened.isEmpty()) + } + + @Test + fun theAreaBelowTheBlocksAddsAParagraphAfterANonParagraph() { + show(Block.SeparatorBlock(), after = { Text("after-marker") }) + + rule.onNode(hasText("after-marker")).assertExists() + last(hasClickAction()).performClick() + idle() + + assertEquals(2, state.blocks.size) + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorGapsUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorGapsUiTest.kt new file mode 100644 index 00000000..b5afc3f1 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorGapsUiTest.kt @@ -0,0 +1,216 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.performClick +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.requestFocus +import androidx.compose.ui.text.TextRange +import com.pasich.encly.editor.EditorProvider +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.state.FocusRequest +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.ui.blocks.ListBlock +import com.pasich.encly.editor.ui.blocks.TextBlock +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Keys, focus requests and odd line breaks that the scenario tests of the blocks leave out. */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class EditorGapsUiTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var state: BlockEditorState + + private fun show(vararg blocks: Block) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + val controller = StateController(state) + rule.setContent { EditorHarness(controller, { _, _ -> }) } + rule.waitForIdle() + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + private fun press(index: Int, key: Key, selection: Int? = null) { + // Focus by semantics, not by touch: a touch shows the cursor handle's magnifier, which Robolectric cannot draw. + fields()[index].requestFocus() + selection?.let { fields()[index].performTextInputSelection(TextRange(it)) } + fields()[index].performKeyInput { pressKey(key) } + rule.waitForIdle() + } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + @Test + fun backspaceInAnEmptyFirstParagraphRemovesIt() { + show(text(""), Block.SeparatorBlock()) + + press(0, Key.Backspace) + + assertEquals(1, state.blocks.size) + } + + @Test + fun cursorKeysLeaveAParagraphFromItsEdges() { + show(text("a"), text("b"), text("c")) + + press(0, Key.DirectionRight, selection = 1) + press(1, Key.DirectionLeft, selection = 0) + press(0, Key.DirectionDown, selection = 1) + press(1, Key.DirectionUp, selection = 0) + + assertTrue(state.selection.focusedBlockId.value != null) + } + + @Test + fun otherKeysDuringTheSlashPickerGoToTheField() { + show(text("")) + fields()[0].performClick() + fields()[0].performTextInput("/") + rule.waitForIdle() + + fields()[0].performKeyInput { pressKey(Key.A) } + fields()[0].performKeyInput { pressKey(Key.DirectionDown) } + fields()[0].performKeyInput { pressKey(Key.DirectionUp) } + rule.waitForIdle() + + assertEquals(1, state.blocks.size) + } + + @Test + fun cursorKeysMoveBetweenTheItemsOfAList() { + val numbers = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("one"), ItemListBlock("two"), ItemListBlock("three"))), + BlockType.LIST_NUMBER, + ) + show(numbers) + + press(1, Key.DirectionLeft, selection = 0) + press(0, Key.DirectionRight, selection = 3) + press(1, Key.DirectionUp, selection = 0) + press(0, Key.DirectionDown, selection = 3) + + assertEquals(3, numbers.items.value.size) + } + + @Test + fun aKeyThatDoesNothingInTheAddressFieldIsLeftToIt() { + val block = Block.LinkBlock() + show(text("keep"), block) + val field = rule.onAllNodes(hasSetTextAction())[1] + field.performClick() + + field.performKeyInput { pressKey(Key.F1) } + rule.waitForIdle() + + assertEquals(2, state.blocks.size) + } + + @Test + fun backspaceInAnEmptyAddressRemovesTheLinkBlock() { + show(text("keep"), Block.LinkBlock()) + val field = rule.onAllNodes(hasSetTextAction())[1] + field.performClick() + + field.performKeyInput { pressKey(Key.Backspace) } + rule.waitForIdle() + + assertEquals(1, state.blocks.size) + } + + // --- a list's focus target and a field kept as text --------------------------------------- + + @Test + fun theListFocusTargetPicksItsItemAndGivesUpOnOneThatNeverAppears() { + val block = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("a"), ItemListBlock("b"))), + BlockType.LIST_BULLET, + ) + var target: ((FocusRequest) -> Unit)? = null + val actions = Mockito.mock(BlockActions::class.java, Mockito.RETURNS_DEEP_STUBS) + Mockito.doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + target = invocation.arguments[0] as (FocusRequest) -> Unit + val unregister: () -> Unit = {} + unregister + }.`when`(actions).registerFieldsFocusTarget(Mockito.any() ?: { }) + rule.setContent { MaterialTheme { EditorProvider { ListBlock(block, actions) } } } + rule.waitForIdle() + + rule.runOnUiThread { target!!(FocusRequest(block.id)) } + rule.waitForIdle() + rule.runOnUiThread { target!!(FocusRequest(block.id, cursorToEnd = true)) } + rule.waitForIdle() + // An item added in the same moment is composed a frame later: the request waits for it. + rule.runOnUiThread { + val added = ItemListBlock("late") + block.items.value = block.items.value + added + target!!(FocusRequest(block.id, itemId = added.id, caretX = 1f)) + } + rule.waitForIdle() + // An item gone before it was ever composed is given up on after a few frames. + rule.runOnUiThread { + val ghost = ItemListBlock("ghost") + block.items.value = block.items.value + ghost + target!!(FocusRequest(block.id, itemId = ghost.id)) + block.items.value = block.items.value - ghost + } + rule.waitForIdle() + + assertEquals(3, block.items.value.size) + } + + private fun textWithRefusingActions(onKept: Boolean): Block.TextBlock { + val block = Block.TextBlock() + val actions = Mockito.mock(BlockActions::class.java, Mockito.RETURNS_DEEP_STUBS) + Mockito.doReturn(if (onKept) "kept" else null).`when`(actions) + .onLineBreak(Mockito.any() ?: LineBreak("", listOf("", ""), "")) + rule.setContent { MaterialTheme { EditorProvider { TextBlock(block, actions) } } } + rule.waitForIdle() + return block + } + + @Test + fun aLineBreakTheEditorDeclinesStaysTextWithOneKindOfBreak() { + textWithRefusingActions(onKept = false) + + fields()[0].performTextInput("a\r\nb") + rule.waitForIdle() + + fields()[0].assertTextEquals("a\nb") + fields()[0].performTextInput("\n") + rule.waitForIdle() + } + + @Test + fun aLineBreakTheEditorKeepsReplacesTheText() { + textWithRefusingActions(onKept = true) + + fields()[0].performTextInput("a\nb") + rule.waitForIdle() + + fields()[0].assertTextEquals("kept") + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorReportsUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorReportsUiTest.kt new file mode 100644 index 00000000..c766dd12 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorReportsUiTest.kt @@ -0,0 +1,167 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.EditorProvider +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.state.FindMatch +import com.pasich.encly.editor.ui.blocks.CHECK_BOX_TAG +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Fixes from the reports on issue 76: what the editor shows and does around quotes, lists and Find. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class EditorReportsUiTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var state: BlockEditorState + private lateinit var highlights: FindHighlights + private lateinit var listState: LazyListState + private lateinit var scope: CoroutineScope + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun show(vararg blocks: Block, viewportHeight: Int? = null) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + val controller = StateController(state) + rule.setContent { + MaterialTheme { + EditorProvider { + val registry = rememberBlockFocusRegistry(state.blocks) + val findState = state.find.state.collectAsState() + highlights = remember(findState) { FindHighlights(findState) } + listState = rememberLazyListState() + scope = rememberCoroutineScope() + val host = remember(controller, registry, highlights) { + EditorBlocksHost(controller, registry, highlights) { _, _ -> } + } + val size = viewportHeight?.let { Modifier.height(it.dp) } ?: Modifier + Box(Modifier.fillMaxWidth().then(size)) { + LazyColumn(state = listState) { + editorBlocks(host, state.blocks, false, controller.editingLinks) + } + } + } + } + } + rule.waitForIdle() + } + + /** Reveals [match] the way the screen does, on the composition's own clock. */ + private fun reveal(match: FindMatch): Boolean { + var revealed: Boolean? = null + rule.runOnUiThread { scope.launch { revealed = highlights.reveal(match) } } + rule.waitForIdle() + return revealed!! + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().map { it.boundsInRoot } + + // --- the gap around a quote --------------------------------------------------------------- + + @Test + fun aQuoteHasTheSameGapAboveAndBelow() { + show(text("before"), Block.QuoteBlock(MutableStateFlow("quoted")), text("after")) + + val (before, quote, after) = fields() + + assertEquals(quote.top - before.bottom, after.top - quote.bottom, 1f) + } + + // --- what a touch counts as --------------------------------------------------------------- + + private val isCheckbox = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + + private fun lockedChecklist() = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("milk"), ItemListBlock("eggs"))), + BlockType.LIST_CHECK, + locked = MutableStateFlow(true), + ) + + @Test + fun aFingerGlidingOverAListDoesNotMakeItTheBlockTheToolbarActsOn() { + val list = lockedChecklist() + show(text("above"), list) + + rule.onAllNodesWithTag(CHECK_BOX_TAG, useUnmergedTree = true).onFirst().performTouchInput { swipeUp() } + rule.waitForIdle() + + assertNull(state.selection.interactedBlockId.value) + } + + @Test + fun aTapOnAListMakesItTheBlockTheToolbarActsOn() { + val list = lockedChecklist() + show(text("above"), list) + + rule.onAllNodes(isCheckbox).onFirst().performTouchInput { click() } + rule.waitForIdle() + + assertEquals(list.id, state.selection.interactedBlockId.value) + } + + // --- Find scrolls to the match itself ----------------------------------------------------- + + @Test + fun findScrollsAMatchFarDownATallParagraphIntoView() { + val tall = text("start\n" + "line\n".repeat(150) + "needle") + show(tall, viewportHeight = 300) + rule.runOnIdle { + state.find.open() + state.find.search("needle", state.blocks) + } + rule.waitForIdle() + assertEquals(0, listState.firstVisibleItemScrollOffset) + + val revealed = reveal(state.find.state.value.matches.single()) + rule.waitForIdle() + + assertTrue(revealed) + assertTrue(listState.firstVisibleItemScrollOffset > 0) + } + + @Test + fun findFindsNoPlaceForAMatchOfAFieldThatIsNotThere() { + show(text("hello")) + + assertFalse(reveal(FindMatch("gone", null, 0, 1))) + assertFalse(reveal(FindMatch(state.blocks.single().id, null, 99, 100))) + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/NestedListUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/NestedListUiTest.kt new file mode 100644 index 00000000..0230a0ef --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/NestedListUiTest.kt @@ -0,0 +1,400 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.ui.blocks.CHECK_BOX_TAG +import com.pasich.encly.editor.ui.blocks.ITEM_HANDLE_TAG +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val ARABIC_ITEM = "حليب" + +/** + * Nested lists on screen: where a level sits (on the side the item reads from), the numbers and boxes of each + * level, the keys that indent and move an item, TalkBack's actions on it, and dragging it by its handle. + */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class NestedListUiTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var state: BlockEditorState + + private fun row(text: String, level: Int = 0, kind: BlockType? = null) = + ItemListBlock(text, level = level, kind = kind) + + private fun list(type: BlockType, vararg rows: ItemListBlock) = + Block.ListBlock(MutableStateFlow(rows.toList()), type) + + private fun show(vararg blocks: Block, layout: LayoutDirection = LayoutDirection.Ltr, locked: Boolean = false) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + val controller = StateController(state) + rule.setContent { + CompositionLocalProvider(LocalLayoutDirection provides layout) { + EditorHarness(controller, { _, _ -> }, locked) + } + } + rule.waitForIdle() + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + private fun handles() = rule.onAllNodesWithTag(ITEM_HANDLE_TAG) + + private fun bounds(node: SemanticsNodeInteraction): Rect = node.fetchSemanticsNode().boundsInRoot + + private val items: List get() = (state.blocks.first() as Block.ListBlock).items.value + + private fun shape() = items.joinToString(" ") { "${it.value}${it.level}" } + + private fun order() = items.map { it.value } + + /** TalkBack's actions on item [index], which sit on its handle (the field carries the block's own). */ + private fun customActions(index: Int): Map Boolean> { + val actions = handles()[index].fetchSemanticsNode().config[SemanticsActions.CustomActions] + return actions.associate { it.label to { it.action() } } + } + + // --- where a level sits ----------------------------------------------------------------------- + + @Test + fun aNestedItemSitsInFromItsParentByOneMarkerColumnPerLevel() { + show(list(BlockType.LIST_BULLET, row("a"), row("b", 1), row("c", 2))) + + val left = (0..2).map { bounds(fields()[it]).left } + + assertTrue(left[1] > left[0], "$left") + assertTrue(left[2] > left[1], "$left") + // The same step for each level. + assertEquals(left[1] - left[0], left[2] - left[1], 1f) + } + + @Test + fun aNestedArabicItemSitsInOnTheRightEvenInALeftToRightApp() { + show(list(BlockType.LIST_BULLET, row(ARABIC_ITEM), row(ARABIC_ITEM + "2", 1), row(ARABIC_ITEM + "3", 2))) + + val right = (0..2).map { bounds(fields()[it]).right } + + assertTrue(right[1] < right[0], "$right") + assertTrue(right[2] < right[1], "$right") + } + + @Test + fun aNestedLatinItemSitsInOnTheLeftInARightToLeftApp() { + show(list(BlockType.LIST_BULLET, row("a"), row("b", 1)), layout = LayoutDirection.Rtl) + + assertTrue(bounds(fields()[1]).left > bounds(fields()[0]).left) + } + + @Test + fun aNestedItemInARightToLeftAppSitsInFromTheRight() { + show(list(BlockType.LIST_BULLET, row(ARABIC_ITEM), row(ARABIC_ITEM + "2", 1)), layout = LayoutDirection.Rtl) + + assertTrue(bounds(fields()[1]).right < bounds(fields()[0]).right) + } + + @Test + fun aMixedListIndentsEachItemOnItsOwnSide() { + show(list(BlockType.LIST_BULLET, row("a"), row(ARABIC_ITEM, 1), row("c", 1), row(ARABIC_ITEM + "2"))) + + // Latin c is nested from the left; Arabic is nested from the right. + assertTrue(bounds(fields()[2]).left > bounds(fields()[0]).left) + assertTrue(bounds(fields()[1]).right < bounds(fields()[3]).right) + } + + // --- numbers and boxes ------------------------------------------------------------------------ + + @Test + fun theNumbersStartAgainAtEveryLevel() { + show(list(BlockType.LIST_NUMBER, row("a"), row("b", 1), row("c", 1), row("d"), row("e", 1))) + + // a=1, b=1, c=2, d=2, e=1: each level counts on from its own parent. + rule.onAllNodesWithText("1.", useUnmergedTree = true).assertCountEquals(3) + rule.onAllNodesWithText("2.", useUnmergedTree = true).assertCountEquals(2) + } + + @Test + fun aNestedChecklistUnderABulletHasBoxesAndNumberedOnesHaveNumbers() { + show( + list( + BlockType.LIST_BULLET, + row("a"), + row("b", 1, BlockType.LIST_CHECK), + row("c", 1, BlockType.LIST_NUMBER), + ), + ) + + rule.onAllNodesWithTag(CHECK_BOX_TAG, useUnmergedTree = true).assertCountEquals(1) + rule.onAllNodesWithText("1.", useUnmergedTree = true).assertCountEquals(1) + } + + // --- keys ------------------------------------------------------------------------------------- + + @Test + fun tabIndentsTheItemTheCursorIsInAndShiftTabOutdentsIt() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"))) + fields()[1].performClick() + + fields()[1].performKeyInput { pressKey(Key.Tab) } + rule.waitForIdle() + assertEquals("a0 b1", shape()) + + fields()[1].performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.Tab) } } + rule.waitForIdle() + assertEquals("a0 b0", shape()) + + state.undo() + assertEquals("a0 b1", shape()) + } + + @Test + fun tabInTheFirstItemIsTakenAndChangesNothing() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"))) + fields()[0].performClick() + + fields()[0].performKeyInput { pressKey(Key.Tab) } + rule.waitForIdle() + + assertEquals("a0 b0", shape()) + } + + @Test + fun altShiftArrowsMoveTheItemAndTheBlockMoveChordStillMovesTheBlock() { + val paragraph = Block.TextBlock(MutableStateFlow("p")) + show(list(BlockType.LIST_BULLET, row("a"), row("b"), row("c")), paragraph) + fields()[0].performClick() + + fields()[0].performKeyInput { + withKeyDown(Key.AltLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionDown) } } + } + rule.waitForIdle() + assertEquals(listOf("b", "a", "c"), order()) + + fields()[1].performKeyInput { + withKeyDown(Key.AltLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionUp) } } + } + rule.waitForIdle() + assertEquals(listOf("a", "b", "c"), order()) + + // Ctrl+Shift+Down moves the whole list below the paragraph, not the item. + fields()[0].performKeyInput { + withKeyDown(Key.CtrlLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionDown) } } + } + rule.waitForIdle() + assertTrue(state.blocks[0] is Block.TextBlock) + assertEquals(listOf("a", "b", "c"), (state.blocks[1] as Block.ListBlock).items.value.map { it.value }) + } + + @Test + fun backspaceAtTheStartOfANestedItemBringsItOut() { + show(list(BlockType.LIST_BULLET, row("a"), row("b", 1))) + fields()[1].performClick() + fields()[1].performTextInputSelection(TextRange(0)) + + fields()[1].performKeyInput { pressKey(Key.Backspace) } + rule.waitForIdle() + + assertEquals("a0 b0", shape()) + } + + // --- TalkBack --------------------------------------------------------------------------------- + + @Test + fun anItemOffersTheActionsThatApplyToIt() { + show(list(BlockType.LIST_BULLET, row("a"), row("b", 1), row("c", 1))) + + // a is the only item at the top: its children are not its siblings. + assertEquals(emptySet(), customActions(0).keys) + assertEquals(setOf("Move item down", "Outdent item"), customActions(1).keys) + // c can also go under b. + assertEquals(setOf("Move item up", "Indent item", "Outdent item"), customActions(2).keys) + } + + @Test + fun anItemThatCanGoDeeperOffersToIndent() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"))) + + assertEquals(setOf("Move item up", "Indent item"), customActions(1).keys) + } + + @Test + fun theActionsMoveAndIndentTheItem() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"), row("c"))) + + rule.runOnIdle { assertTrue(customActions(0).getValue("Move item down")()) } + assertEquals(listOf("b", "a", "c"), order()) + + rule.runOnIdle { assertTrue(customActions(2).getValue("Indent item")()) } + rule.waitForIdle() + assertEquals("b0 a0 c1", shape()) + + rule.runOnIdle { assertTrue(customActions(2).getValue("Outdent item")()) } + rule.waitForIdle() + assertEquals("b0 a0 c0", shape()) + } + + @Test + fun aLockedEditorHasNoHandles() { + show(list(BlockType.LIST_BULLET, row("a"), row("b")), locked = true) + + handles().assertCountEquals(0) + } + + // --- the handle ------------------------------------------------------------------------------- + + @Test + fun everyItemOfAListWithSeveralHasAHandleAndALoneItemHasNone() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"), row("c"))) + handles().assertCountEquals(3) + rule.onAllNodesWithTag(ITEM_HANDLE_TAG).onFirst().assertContentDescriptionEquals("Reorder item") + } + + @Test + fun aListOfOneItemHasNoHandle() { + show(list(BlockType.LIST_BULLET, row("a"))) + + handles().assertCountEquals(0) + } + + @Test + fun aLockedChecklistHasNoHandles() { + val checklist = list(BlockType.LIST_CHECK, row("a"), row("b")) + checklist.locked.value = true + show(checklist) + + handles().assertCountEquals(0) + } + + @Test + fun draggingAnItemByItsHandleWithAMouseMovesItAsOneUndoStep() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"), row("c"))) + val row = bounds(fields()[0]).height + + handles()[0].performMouseInput { + moveTo(center) + press() + moveBy(Offset(0f, row * 0.5f)) + moveBy(Offset(0f, row * 0.9f)) + } + // Part way, the line shows where it would land. + rule.waitForIdle() + assertEquals(listOf("a", "b", "c"), order()) + handles()[0].performMouseInput { release() } + rule.waitForIdle() + + assertEquals(listOf("b", "a", "c"), order()) + state.undo() + assertEquals(listOf("a", "b", "c"), order()) + assertTrue(!state.canUndo.value) + } + + @Test + fun draggingAnItemUpMovesItAboveItsSibling() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"), row("c"))) + val row = bounds(fields()[0]).height + + handles()[2].performMouseInput { + moveTo(center) + press() + moveBy(Offset(0f, -row * 0.5f)) + moveBy(Offset(0f, -row * 0.9f)) + release() + } + rule.waitForIdle() + + assertEquals(listOf("a", "c", "b"), order()) + } + + @Test + fun draggingAParentTakesItsChildrenAlong() { + show(list(BlockType.LIST_BULLET, row("a"), row("b", 1), row("c"), row("d", 1))) + val row = bounds(fields()[0]).height + + handles()[0].performMouseInput { + moveTo(center) + press() + moveBy(Offset(0f, row * 1.5f)) + moveBy(Offset(0f, row * 1.2f)) + release() + } + rule.waitForIdle() + + assertEquals("c0 d1 a0 b1", shape()) + } + + @Test + fun aMouseOnTheHandleThatDoesNotMoveChangesNothing() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"))) + + handles()[0].performMouseInput { + moveTo(center) + press() + release() + } + rule.waitForIdle() + + assertEquals(listOf("a", "b"), order()) + assertTrue(!state.canUndo.value) + } + + @Test + fun aFingerNeedsALongPressBeforeItDragsTheItem() { + show(list(BlockType.LIST_BULLET, row("a"), row("b"), row("c"))) + val row = bounds(fields()[0]).height + + // A quick drag is a scroll, not a move. + handles()[0].performTouchInput { + down(center) + moveBy(Offset(0f, row * 2f)) + up() + } + rule.waitForIdle() + assertEquals(listOf("a", "b", "c"), order()) + + handles()[0].performTouchInput { down(center) } + rule.mainClock.advanceTimeBy(1_000) + handles()[0].performTouchInput { + moveBy(Offset(0f, row * 0.6f)) + moveBy(Offset(0f, row * 0.9f)) + up() + } + rule.waitForIdle() + assertEquals(listOf("b", "a", "c"), order()) + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/PointerKeyboardUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/PointerKeyboardUiTest.kt new file mode 100644 index 00000000..2a0fae80 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/PointerKeyboardUiTest.kt @@ -0,0 +1,254 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.test.rightClick +import androidx.compose.ui.test.withKeyDown +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.ui.sheets.BlockSheetKind +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a desktop gives the editor's blocks: a right click or the Menu key opens the block's actions, and + * Ctrl / Cmd+Shift+Up and Down move it. A finger does none of it: a long press stays the field's own. + */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class PointerKeyboardUiTest { + @get:Rule + val rule = createComposeRule() + + private val opened = mutableListOf>() + private lateinit var state: BlockEditorState + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun show(vararg blocks: Block, locked: Boolean = false) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + val controller = StateController(state) + rule.setContent { EditorHarness(controller, { kind, block -> opened += kind to block }, locked) } + rule.waitForIdle() + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + private fun separator() = rule.onNode(hasContentDescription("Separator") and hasClickAction()) + + private fun textsOf() = state.blocks.map { (it as? Block.TextBlock)?.text?.value ?: it::class.simpleName.orEmpty() } + + // --- the context menu --------------------------------------------------------------------- + + @Test + fun aRightClickOnABlockOpensItsActionsAndPickingOneRunsIt() { + show(text("a"), Block.SeparatorBlock(), text("c")) + + separator().performMouseInput { rightClick() } + rule.waitForIdle() + rule.onNodeWithText("Move block up").assertExists() + rule.onNodeWithText("Move block down").assertExists() + rule.onNodeWithText("Delete block").assertExists() + rule.onNodeWithText("Duplicate block").performClick() + rule.waitForIdle() + + assertEquals(4, state.blocks.size) + rule.onNodeWithText("Duplicate block").assertDoesNotExist() + } + + @Test + fun escapeClosesTheMenuWithoutDoingAnything() { + show(text("a"), Block.SeparatorBlock()) + separator().performMouseInput { rightClick() } + rule.waitForIdle() + + rule.onNodeWithText("Duplicate block").requestFocus() + rule.onNodeWithText("Duplicate block").performKeyInput { pressKey(Key.Escape) } + rule.waitForIdle() + + rule.onNodeWithText("Duplicate block").assertDoesNotExist() + assertEquals(2, state.blocks.size) + } + + @Test + fun theFirstBlockCannotMoveUpNorTheLastDown() { + show(Block.SeparatorBlock(), text("b")) + + separator().performMouseInput { rightClick() } + rule.waitForIdle() + + rule.onNodeWithText("Move block up").assertDoesNotExist() + rule.onNodeWithText("Move block down").assertExists() + } + + @Test + fun theOnlyBlockCannotBeDeleted() { + show(Block.SeparatorBlock()) + + separator().performMouseInput { rightClick() } + rule.waitForIdle() + + rule.onNodeWithText("Duplicate block").assertExists() + rule.onNodeWithText("Delete block").assertDoesNotExist() + } + + @Test + fun aChecklistOffersToLockIt() { + val checks = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("x"))), BlockType.LIST_CHECK) + show(text("above"), checks) + fields()[1].performClick() + + fields()[1].performKeyInput { pressKey(Key.Menu) } + rule.waitForIdle() + rule.onNodeWithText("Lock checklist").performClick() + rule.waitForIdle() + + assertTrue(checks.locked.value) + } + + @Test + fun theMenuKeyOpensTheMenuOfTheFocusedBlock() { + show(text("a"), text("b")) + fields()[0].performClick() + + fields()[0].performKeyInput { pressKey(Key.Menu) } + rule.waitForIdle() + rule.onNodeWithText("Move block down").assertExists() + rule.onNodeWithText("Delete block").performClick() + rule.waitForIdle() + + assertEquals(listOf("b"), textsOf()) + } + + @Test + fun shiftF10OpensItToo() { + show(text("a"), text("b")) + fields()[0].performClick() + + fields()[0].performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.F10) } } + rule.waitForIdle() + + rule.onNodeWithText("Duplicate block").assertExists() + } + + @Test + fun otherKeysDoNotOpenTheMenu() { + show(text("a"), text("b")) + fields()[0].performClick() + + fields()[0].performKeyInput { pressKey(Key.F10) } + fields()[0].performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.F9) } } + rule.waitForIdle() + + rule.onNodeWithText("Duplicate block").assertDoesNotExist() + } + + @Test + fun aLockedEditorOffersNoMenu() { + show(text("a"), Block.SeparatorBlock(), locked = true) + + rule.onNode(hasContentDescription("Separator")).performMouseInput { rightClick() } + rule.waitForIdle() + + rule.onNodeWithText("Duplicate block").assertDoesNotExist() + } + + @Test + fun aFingerOpensNoMenu() { + show(text("a"), Block.SeparatorBlock()) + + separator().performTouchInput { longClick() } + rule.waitForIdle() + + rule.onNodeWithText("Duplicate block").assertDoesNotExist() + assertTrue(opened.isEmpty() || opened.all { it.first == BlockSheetKind.OTHER }) + } + + // --- moving a block from the keyboard ----------------------------------------------------- + + @Test + fun controlShiftUpAndDownMoveTheFocusedBlock() { + show(text("a"), text("b"), text("c")) + fields()[1].performClick() + + fields()[1].performKeyInput { + withKeyDown(Key.CtrlLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionUp) } } + } + rule.waitForIdle() + assertEquals(listOf("b", "a", "c"), textsOf()) + + fields()[0].performKeyInput { + withKeyDown(Key.MetaLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionDown) } } + } + rule.waitForIdle() + assertEquals(listOf("a", "b", "c"), textsOf()) + } + + @Test + fun theChordDoesNothingAtTheEndsOfTheNote() { + show(text("a"), text("b")) + fields()[0].performClick() + + fields()[0].performKeyInput { + withKeyDown(Key.CtrlLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionUp) } } + } + rule.waitForIdle() + + assertEquals(listOf("a", "b"), textsOf()) + } + + @Test + fun aLockedEditorKeepsItsBlocksInPlace() { + show(text("a"), text("b"), locked = true) + + rule.onNodeWithText("b").performKeyInput { + withKeyDown(Key.CtrlLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionUp) } } + } + rule.waitForIdle() + + assertEquals(listOf("a", "b"), textsOf()) + } + + @Test + fun otherChordsAreLeftToTheField() { + show(text("a"), text("b")) + fields()[1].performClick() + + fields()[1].performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionUp) } } + fields()[1].performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.DirectionUp) } } + fields()[1].performKeyInput { + withKeyDown(Key.CtrlLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionLeft) } } + } + fields()[1].performKeyInput { + withKeyDown(Key.CtrlLeft) { + withKeyDown(Key.ShiftLeft) { withKeyDown(Key.AltLeft) { pressKey(Key.DirectionUp) } } + } + } + rule.waitForIdle() + + assertEquals(listOf("a", "b"), textsOf()) + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/RtlBlocksUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/RtlBlocksUiTest.kt new file mode 100644 index 00000000..196fc4b7 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/RtlBlocksUiTest.kt @@ -0,0 +1,181 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.style.ResolvedTextDirection +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.ui.blocks.CHECK_BOX_TAG +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val ARABIC = "مرحبا بالعالم" +private const val ARABIC_ITEM = "حليب" +private const val LATIN = "hello world" + +/** + * Each paragraph reads in the direction of its own first strong character, in a left-to-right app and in a + * right-to-left one: the text itself, and what sits around it (a list's checkbox, its number, the slash picker). + */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class RtlBlocksUiTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var state: BlockEditorState + + private fun show(layout: LayoutDirection, vararg blocks: Block) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + val controller = StateController(state) + rule.setContent { + CompositionLocalProvider(LocalLayoutDirection provides layout) { + EditorHarness(controller, { _, _ -> }) + } + } + rule.waitForIdle() + } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun list(type: BlockType, vararg values: String) = + Block.ListBlock(MutableStateFlow(values.map { ItemListBlock(it) }), type) + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + private fun bounds(node: SemanticsNodeInteraction): Rect = node.fetchSemanticsNode().boundsInRoot + + private fun checkboxes() = rule.onAllNodesWithTag(CHECK_BOX_TAG, useUnmergedTree = true) + + /** The direction of the first paragraph of field [index], as its text layout resolved it. */ + private fun paragraphDirection(index: Int): ResolvedTextDirection { + val results = mutableListOf() + val action = fields()[index].fetchSemanticsNode().config[SemanticsActions.GetTextLayoutResult] + assertTrue(action.action!!.invoke(results)) + return results.single().getParagraphDirection(0) + } + + // --- the text --------------------------------------------------------------------------- + + @Test + fun aLeftToRightAppReadsArabicParagraphsRightToLeftAndLatinOnesLeftToRight() { + show(LayoutDirection.Ltr, text(ARABIC), text(LATIN)) + + assertEquals(ResolvedTextDirection.Rtl, paragraphDirection(0)) + assertEquals(ResolvedTextDirection.Ltr, paragraphDirection(1)) + } + + @Test + fun aRightToLeftAppReadsLatinParagraphsLeftToRightAndArabicOnesRightToLeft() { + show(LayoutDirection.Rtl, text(LATIN), text(ARABIC), text("$LATIN $ARABIC"), text("$ARABIC $LATIN")) + + assertEquals(ResolvedTextDirection.Ltr, paragraphDirection(0)) + assertEquals(ResolvedTextDirection.Rtl, paragraphDirection(1)) + // Mixed text follows its first strong character. + assertEquals(ResolvedTextDirection.Ltr, paragraphDirection(2)) + assertEquals(ResolvedTextDirection.Rtl, paragraphDirection(3)) + } + + @Test + fun anEmptyParagraphFollowsTheAppsDirection() { + show(LayoutDirection.Rtl, text(""), text("123 !?")) + + assertEquals(ResolvedTextDirection.Rtl, paragraphDirection(0)) + assertEquals(ResolvedTextDirection.Rtl, paragraphDirection(1)) + } + + // --- a checklist's checkboxes ---------------------------------------------------------------- + + @Test + fun anArabicChecklistItemKeepsItsCheckboxOnTheRight() { + show(LayoutDirection.Ltr, list(BlockType.LIST_CHECK, ARABIC_ITEM)) + + assertTrue(bounds(checkboxes()[0]).center.x > bounds(fields()[0]).center.x) + } + + @Test + fun aLatinChecklistItemKeepsItsCheckboxOnTheLeftInARightToLeftApp() { + show(LayoutDirection.Rtl, list(BlockType.LIST_CHECK, "milk")) + + assertTrue(bounds(checkboxes()[0]).center.x < bounds(fields()[0]).center.x) + } + + @Test + fun eachItemOfAMixedChecklistHasItsCheckboxOnItsOwnSide() { + show(LayoutDirection.Ltr, list(BlockType.LIST_CHECK, ARABIC_ITEM, "milk", "")) + + val sides = (0..2).map { bounds(checkboxes()[it]).center.x > bounds(fields()[it]).center.x } + + assertEquals(listOf(true, false, false), sides) + } + + @Test + fun anEmptyChecklistItemFollowsTheAppsDirection() { + show(LayoutDirection.Rtl, list(BlockType.LIST_CHECK, "")) + + assertTrue(bounds(checkboxes()[0]).center.x > bounds(fields()[0]).center.x) + } + + @Test + fun theCheckboxFlipsToTheRightOnceArabicIsTyped() { + show(LayoutDirection.Ltr, list(BlockType.LIST_CHECK, "")) + assertTrue(bounds(checkboxes()[0]).center.x < bounds(fields()[0]).center.x) + + fields()[0].performClick() + fields()[0].performTextInput(ARABIC_ITEM) + rule.waitForIdle() + + assertTrue(bounds(checkboxes()[0]).center.x > bounds(fields()[0]).center.x) + } + + // --- numbers and bullets ------------------------------------------------------------------------ + + @Test + fun theNumbersOfArabicItemsSitOnTheRight() { + show(LayoutDirection.Ltr, list(BlockType.LIST_NUMBER, ARABIC_ITEM, "milk")) + + val first = bounds(rule.onNodeWithText("1.", useUnmergedTree = true)) + val second = bounds(rule.onNodeWithText("2.", useUnmergedTree = true)) + + assertTrue(first.center.x > bounds(fields()[0]).center.x) + assertTrue(second.center.x < bounds(fields()[1]).center.x) + } + + // --- the slash picker ----------------------------------------------------------------------------- + + @Test + fun theSlashPickerOpensAtTheStartSideOfTheParagraph() { + show(LayoutDirection.Rtl, text("")) + fields()[0].performClick() + + fields()[0].performTextInput("/") + rule.waitForIdle() + + val field = bounds(fields()[0]) + val picker = bounds(rule.onNodeWithText("Heading 1")) + assertTrue(picker.right > field.center.x, "the picker hangs from the right edge: $picker in $field") + } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TablesAndCodeUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TablesAndCodeUiTest.kt new file mode 100644 index 00000000..0e232f1a --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TablesAndCodeUiTest.kt @@ -0,0 +1,515 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.text.TextRange +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.state.TextPoint +import com.pasich.encly.editor.ui.blocks.TABLE_MENU_TAG +import com.pasich.encly.editor.ui.blocks.cellFor +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Tables and code driven the way a user does: typing, Tab, Enter, the arrow keys and the cell menu. */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class TablesAndCodeUiTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var state: BlockEditorState + + private fun table(vararg rows: List, header: Boolean = true) = + Block.TableBlock(MutableStateFlow(TableData.of(rows.toList(), header))) + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun code(value: String) = Block.CodeBlock(MutableStateFlow(value)) + + private fun show(vararg blocks: Block, locked: Boolean = false) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + val controller = StateController(state) + rule.setContent { EditorHarness(controller, { _, _ -> }, locked) } + rule.waitForIdle() + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + private fun idle() = rule.waitForIdle() + + private fun cell(block: Block.TableBlock, row: Int, column: Int) = block.table.value.rows[row].cells[column].id + + private fun grid(block: Block.TableBlock) = block.table.value.rows.map { row -> row.cells.map { it.value } } + + private fun press(index: Int, key: Key) { + fields()[index].performKeyInput { pressKey(key) } + idle() + } + + private fun menu(entry: String) { + rule.onNodeWithTag(TABLE_MENU_TAG).performClick() + rule.onNodeWithText(entry).performClick() + idle() + } + + // --- a table: typing and the cells ------------------------------------------------------------------ + + @Test + fun typingInACellChangesThatCellAndUndoPutsItBack() { + val block = table(listOf("a", "b"), listOf("c", "d")) + show(block) + + fields()[3].performClick() + fields()[3].performTextInput("!") + idle() + + assertEquals(listOf(listOf("a", "b"), listOf("c", "d!")), grid(block)) + state.undo() + idle() + assertEquals(listOf(listOf("a", "b"), listOf("c", "d")), grid(block)) + } + + @Test + fun aCellKeepsOneLineAndAPasteOfSeveralLinesBecomesSpaces() { + val block = table(listOf("", "")) + show(block) + + fields()[0].performClick() + fields()[0].performTextInput("one\ntwo") + idle() + + assertEquals(listOf(listOf("one two", "")), grid(block)) + } + + @Test + fun anExternalChangeOfACellShowsInItsField() { + val block = table(listOf("a", "b")) + show(block) + + block.table.value = block.table.value.withCell(block.table.value.rows[0].cells[1].id, "changed") + idle() + + rule.onNodeWithText("changed").assertExists() + } + + // --- Tab, Enter and the arrows ---------------------------------------------------------------------- + + @Test + fun tabAndShiftTabMoveBetweenCellsAndTabAfterTheLastAddsARow() { + val block = table(listOf("a", "b"), listOf("c", "d")) + show(block) + fields()[0].performClick() + + press(0, Key.Tab) + fields()[1].assertIsFocused() + press(1, Key.Tab) + fields()[2].assertIsFocused() + + fields()[2].performKeyInput { + keyDown(Key.ShiftLeft) + pressKey(Key.Tab) + keyUp(Key.ShiftLeft) + } + idle() + fields()[1].assertIsFocused() + + fields()[3].performClick() + press(3, Key.Tab) + assertEquals(3, block.table.value.rowCount) + fields()[4].assertIsFocused() + } + + private fun shiftTab(index: Int) { + fields()[index].performKeyInput { + keyDown(Key.ShiftLeft) + pressKey(Key.Tab) + keyUp(Key.ShiftLeft) + } + idle() + } + + @Test + fun shiftTabInTheFirstCellStaysInTheTable() { + show(table(listOf("a", "b"))) + fields()[0].performClick() + + shiftTab(0) + + fields()[0].assertIsFocused() + } + + @Test + fun shiftTabInTheFirstCellGoesToTheBlockBeforeTheTable() { + show(text("above"), table(listOf("a", "b"))) + fields()[1].performClick() + + shiftTab(1) + + fields()[0].assertIsFocused() + } + + @Test + fun tabWithControlHeldIsNotTheTablesKey() { + val block = table(listOf("a", "b")) + show(block) + fields()[1].performClick() + + fields()[1].performKeyInput { + keyDown(Key.CtrlLeft) + pressKey(Key.Tab) + keyUp(Key.CtrlLeft) + } + idle() + + assertEquals(1, block.table.value.rowCount) + } + + @Test + fun aSecondSelectAllInACellSelectsTheWholeNote() { + show(text("above"), table(listOf("ab", "cd"))) + fields()[1].performClick() + fields()[1].performTextInputSelection(TextRange(0, 2)) + + fields()[1].performKeyInput { + keyDown(Key.CtrlLeft) + pressKey(Key.A) + keyUp(Key.CtrlLeft) + } + idle() + + assertTrue(state.textRange.isActive) + assertTrue(state.textRange.isCrossField) + } + + @Test + fun typingOverASelectionFromACellIntoTheParagraphBelowReplacesTheSelection() { + val block = table(listOf("ab", "cd")) + val below = text("below text") + show(block, below) + fields()[1].performClick() + state.textRange.select(TextPoint(block.id, 1, cell(block, 0, 1)), TextPoint(below.id, 6)) + idle() + + fields()[1].performTextInput("x") + idle() + + assertEquals(listOf(listOf("ab", "cd")), grid(block)) + assertEquals("xtext", (state.blocks.last() as Block.TextBlock).text.value) + } + + @Test + fun deletingASelectionFromAParagraphIntoACellLeavesTheTable() { + val above = text("above") + val block = table(listOf("ab", "cd")) + show(above, block) + fields()[2].performClick() + state.textRange.select(TextPoint(above.id, 2), TextPoint(block.id, 1, cell(block, 0, 1))) + idle() + + press(2, Key.Backspace) + + assertEquals("ab", above.text.value) + assertEquals(2, state.blocks.size) + assertEquals(listOf(listOf("ab", "cd")), grid(block)) + } + + @Test + fun enterGoesDownAMoveAndAddsARowInTheLastRowWithoutChangingTheText() { + val block = table(listOf("a", "b"), listOf("c", "d")) + show(block) + + fields()[0].performClick() + fields()[0].performTextInput("\n") + idle() + fields()[2].assertIsFocused() + assertEquals(listOf(listOf("a", "b"), listOf("c", "d")), grid(block)) + + fields()[3].performClick() + fields()[3].performTextInput("\n") + idle() + assertEquals(3, block.table.value.rowCount) + fields()[5].assertIsFocused() + assertEquals(listOf("", ""), block.table.value.rows[2].cells.map { it.value }) + } + + @Test + fun theArrowKeysLeaveACellOnlyAtItsEdges() { + show(table(listOf("a", "b"), listOf("c", "d"))) + fields()[0].performClick() + fields()[0].performTextInputSelection(TextRange(1)) + + press(0, Key.DirectionRight) + fields()[1].assertIsFocused() + fields()[1].performTextInputSelection(TextRange(0)) + press(1, Key.DirectionLeft) + fields()[0].assertIsFocused() + press(0, Key.DirectionDown) + fields()[2].assertIsFocused() + press(2, Key.DirectionUp) + fields()[0].assertIsFocused() + } + + @Test + fun theArrowKeysLeaveTheTableForTheBlocksAroundIt() { + show(text("above"), table(listOf("a", "b")), text("below")) + fields()[1].performClick() + fields()[1].performTextInputSelection(TextRange(0)) + + press(1, Key.DirectionUp) + fields()[0].assertIsFocused() + + fields()[2].performClick() + fields()[2].performTextInputSelection(TextRange(1)) + press(2, Key.DirectionDown) + fields()[3].assertIsFocused() + + // From below the cursor arrives in the table's last row, at its start. + press(3, Key.DirectionUp) + fields()[1].assertIsFocused() + fields()[1].performTextInputSelection(TextRange(1)) + press(1, Key.DirectionRight) + fields()[2].assertIsFocused() + fields()[2].performTextInputSelection(TextRange(1)) + press(2, Key.DirectionRight) + fields()[3].assertIsFocused() + } + + @Test + fun theArrowKeysDoNothingPastTheEdgesOfTheOnlyBlock() { + show(table(listOf("a"))) + fields()[0].performClick() + + listOf(Key.DirectionUp, Key.DirectionDown, Key.DirectionLeft, Key.DirectionRight).forEach { press(0, it) } + + fields()[0].assertIsFocused() + } + + // --- the cell menu ---------------------------------------------------------------------------------- + + @Test + fun theMenuAddsAndDeletesRowsAndColumnsAfterTheCellTheCursorIsIn() { + val block = table(listOf("a", "b"), listOf("c", "d")) + show(block) + fields()[0].performClick() + + menu("Add row") + assertEquals(listOf(listOf("a", "b"), listOf("", ""), listOf("c", "d")), grid(block)) + menu("Add column") + assertEquals(listOf("a", "", "b"), grid(block)[0]) + menu("Delete column") + assertEquals(listOf("a", "b"), grid(block)[0]) + menu("Delete row") + assertEquals(listOf(listOf("a", "b"), listOf("c", "d")), grid(block)) + } + + @Test + fun theMenuActsOnTheFirstCellWhenNoCellWasEverFocusedAndTheHeaderSwitches() { + val block = table(listOf("a", "b"), listOf("c", "d")) + show(block) + + menu("Add row") + assertEquals(listOf("", ""), grid(block)[1]) + + assertTrue(block.table.value.header) + menu("Header row") + assertTrue(!block.table.value.header) + menu("Header row") + assertTrue(block.table.value.header) + } + + @Test + fun theLastRowAndColumnCannotBeDeletedFromTheMenu() { + val block = table(listOf("a")) + show(block) + + menu("Delete row") + menu("Delete column") + + assertEquals(listOf(listOf("a")), grid(block)) + } + + @Test + fun aLockedNoteShowsNoMenuAndCannotBeTypedIn() { + val block = table(listOf("a")) + show(block, locked = true) + + rule.onNodeWithTag(TABLE_MENU_TAG).assertDoesNotExist() + rule.onNodeWithText("a").performClick() + idle() + assertEquals(listOf(listOf("a")), grid(block)) + assertEquals(0, fields().fetchSemanticsNodes().size) + } + + // --- focus, Find and the slash picker ---------------------------------------------------------------- + + @Test + fun theEditorFocusesTheCellItNamesOrTheLastOrTheFirst() { + val block = table(listOf("a", "b"), listOf("c", "d")) + show(block) + + state.selection.focusAt(0, itemId = block.table.value.rows[1].cells[0].id, caret = 1) + idle() + fields()[2].assertIsFocused() + + state.selection.focusAt(0, cursorToEnd = true) + idle() + fields()[3].assertIsFocused() + + state.selection.focusAt(0) + idle() + fields()[0].assertIsFocused() + } + + @Test + fun theRequestedCellDependsOnWhereTheCursorComesFrom() { + val data = TableData.of(listOf(listOf("a", "b"), listOf("c", "d"))) + val a = data.rows[0].cells[0] + val c = data.rows[1].cells[0] + val d = data.rows[1].cells[1] + + assertEquals(c, data.cellFor(c.id, cursorToEnd = false, fromBelow = false)) + assertEquals(c, data.cellFor("gone", cursorToEnd = false, fromBelow = true)) + assertEquals(d, data.cellFor("gone", cursorToEnd = false, fromBelow = false)) + assertEquals(c, data.cellFor(null, cursorToEnd = false, fromBelow = true)) + assertEquals(d, data.cellFor(null, cursorToEnd = true, fromBelow = false)) + assertEquals(a, data.cellFor(null, cursorToEnd = false, fromBelow = false)) + } + + @Test + fun findTintsAMatchInACell() { + val block = table(listOf("alpha", "beta")) + show(block) + + state.find.open() + state.find.search("alp", state.blocks) + idle() + + rule.onNodeWithText("alpha").assertExists() + } + + @Test + fun theSlashPickerMakesATable() { + show(text("")) + fields()[0].performClick() + fields()[0].performTextInput("/") + idle() + fields()[0].performTextInput("tab") + idle() + rule.onNodeWithText("Table").performClick() + idle() + assertTrue(state.blocks.first() is Block.TableBlock) + } + + @Test + fun theSlashPickerMakesCode() { + show(text("")) + fields()[0].performClick() + fields()[0].performTextInput("/") + idle() + fields()[0].performTextInput("cod") + idle() + rule.onNodeWithText("Code").performClick() + idle() + assertTrue(state.blocks.first() is Block.CodeBlock) + } + + @Test + fun aRightToLeftLanguageMirrorsTheColumns() { + state = BlockEditorState { 0L } + state.load(listOf(table(listOf("a", "b")))) + val controller = StateController(state) + rule.setContent { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + EditorHarness(controller, { _, _ -> }) + } + } + idle() + + val first = fields()[0].fetchSemanticsNode().boundsInRoot.left + val second = fields()[1].fetchSemanticsNode().boundsInRoot.left + assertTrue(first > second, "the first column is on the right: $first, $second") + } + + // --- code --------------------------------------------------------------------------------------------- + + @Test + fun codeKeepsEnterSpacesAndTabsInItsOwnBlock() { + val block = code("") + show(block) + rule.onNodeWithText("Code").assertExists() + + fields()[0].performClick() + fields()[0].performTextInput("if (x) {") + fields()[0].performTextInput("\n") + fields()[0].performTextInput(" y()") + press(0, Key.Tab) + fields()[0].performTextInput("\n}") + idle() + + assertEquals(1, state.blocks.size) + assertEquals("if (x) {\n y() \n}", block.text.value) + } + + @Test + fun tabInCodeReplacesTheSelectionAndAWindowsLineBreakBecomesALineFeed() { + val block = code("abc") + show(block) + fields()[0].performClick() + fields()[0].performTextInputSelection(TextRange(1, 2)) + + press(0, Key.Tab) + assertEquals("a c", block.text.value) + + fields()[0].performTextInput("\r\nz") + idle() + assertTrue('\r' !in block.text.value, block.text.value) + } + + @Test + fun aLockedCodeBlockTakesNoTab() { + val block = code("abc") + show(block, locked = true) + + rule.onNodeWithText("abc").performClick() + rule.onNodeWithText("abc").performKeyInput { pressKey(Key.Tab) } + idle() + + assertEquals("abc", block.text.value) + } + + @Test + fun backspaceAtTheStartOfCodeMakesItAParagraphAndAgainJoinsIt() { + show(text("a"), code("")) + + fields()[1].performClick() + press(1, Key.Backspace) + assertTrue(state.blocks[1] is Block.TextBlock) + + press(1, Key.Backspace) + assertEquals(1, state.blocks.size) + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TapBelowBlocksTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt similarity index 93% rename from app/src/test/java/com/pasich/encly/ui/screens/TapBelowBlocksTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt index de128a9e..acbb51ae 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TapBelowBlocksTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.ui.screens +package com.pasich.encly.editor.ui import android.app.Application import androidx.compose.foundation.clickable @@ -16,13 +16,12 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.dp -import com.pasich.encly.presentation.screen.editnote.tapBelowBlocks -import org.junit.Assert.assertEquals import org.junit.Rule -import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TextRangeUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TextRangeUiTest.kt new file mode 100644 index 00000000..583487fa --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TextRangeUiTest.kt @@ -0,0 +1,591 @@ +package com.pasich.encly.editor.ui + +import android.app.Application +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performMultiModalInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.state.TextPoint +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Text selected across blocks: made with the mouse, with Shift and a click or a key, and with the handles; drawn + * behind the text; and acted on by the keys, typing and the bar. + */ +@OptIn(ExperimentalTestApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class TextRangeUiTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var state: BlockEditorState + private lateinit var controller: StateController + private val calls = mutableListOf() + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun show(vararg blocks: Block, canEdit: Boolean = true) { + state = BlockEditorState { 0L } + state.load(blocks.toList()) + controller = StateController(state) + val actions = SelectedBlocksActions( + canEdit = canEdit, + onCopy = { calls += "copy" }, + onCopyMarkdown = { calls += "markdown" }, + onCut = { calls += "cut" }, + onDelete = { calls += "delete" }, + ) + rule.setContent { EditorHarness(controller, { _, _ -> }, bar = { TextRangeBar(state.textRange, actions) }) } + rule.waitForIdle() + } + + private fun fields() = rule.onAllNodes(hasSetTextAction()) + + private fun centerOf(index: Int) = fields()[index].fetchSemanticsNode().boundsInRoot.center + + private fun point(index: Int, offset: Int) = TextPoint(state.blocks[index].id, offset) + + private fun shown() = state.blocks.map { (it as? Block.TextBlock)?.text?.value ?: it::class.simpleName.orEmpty() } + + private fun range() = state.textRange + + /** Focus [index] first (focus ends a selection), then select from [from] to [to]. */ + private fun selectAcross(index: Int, from: TextPoint, to: TextPoint) { + fields()[index].performClick() + rule.waitForIdle() + rule.runOnIdle { range().select(from, to) } + rule.waitForIdle() + } + + private fun setSelection(index: Int, start: Int, end: Int) { + fields()[index].performSemanticsAction(SemanticsActions.SetSelection) { it(start, end, true) } + rule.waitForIdle() + } + + // --- the mouse ------------------------------------------------------------------------------------- + + @Test + fun draggingTheMouseFromOneFieldIntoAnotherSelectsAcrossThem() { + show(text("first line"), text("second line"), text("third")) + val from = centerOf(0) + val to = centerOf(1) + + rule.onRoot().performMouseInput { + moveTo(from) + press() + moveTo(Offset((from.x + to.x) / 2, (from.y + to.y) / 2)) + moveTo(to) + release() + } + rule.waitForIdle() + + assertTrue(range().isCrossField) + assertEquals(2, range().blockCount()) + assertEquals(state.blocks[0].id, range().ends.value!!.anchor.blockId) + assertEquals(state.blocks[1].id, range().ends.value!!.head.blockId) + // The tint is drawn behind the text of both fields. + rule.onRoot().captureToImage() + } + + @Test + fun draggingBackIntoTheFirstFieldEndsTheSelectionAcrossThem() { + show(text("first line"), text("second line")) + val from = centerOf(0) + + rule.onRoot().performMouseInput { + moveTo(from) + press() + moveTo(centerOf(1)) + moveTo(from + Offset(40f, 0f)) + release() + } + rule.waitForIdle() + + assertFalse(range().isCrossField) + } + + @Test + fun aDragInsideOneFieldStaysTheFieldsOwn() { + show(text("first line"), text("second line")) + val from = centerOf(0) + + rule.onRoot().performMouseInput { + moveTo(from) + press() + moveTo(from + Offset(60f, 0f)) + release() + } + rule.waitForIdle() + + assertFalse(range().isCrossField) + } + + @Test + fun aTouchDoesNotStartOne() { + show(text("first line"), text("second line")) + + rule.onRoot().performTouchInput { + down(centerOf(0)) + moveTo(centerOf(1)) + up() + } + rule.waitForIdle() + + assertFalse(range().isCrossField) + } + + @Test + fun shiftClickInAnotherFieldExtendsTheFieldsSelectionIntoIt() { + show(text("first line"), text("second line")) + fields()[0].performClick() + setSelection(0, 2, 2) + + rule.onAllNodes(isRoot()).onFirst().performMultiModalInput { + key { keyDown(Key.ShiftLeft) } + mouse { click(centerOf(1)) } + key { keyUp(Key.ShiftLeft) } + } + rule.waitForIdle() + + assertTrue(range().isCrossField) + assertEquals(point(0, 2), range().ends.value!!.anchor) + } + + @Test + fun shiftClickKeepsTheAnchorOfASelectionThatExists() { + show(text("first line"), text("second line"), text("third")) + selectAcross(0, point(0, 3), point(1, 2)) + + rule.onAllNodes(isRoot()).onFirst().performMultiModalInput { + key { keyDown(Key.ShiftLeft) } + mouse { click(centerOf(2)) } + key { keyUp(Key.ShiftLeft) } + } + rule.waitForIdle() + + assertEquals(point(0, 3), range().ends.value!!.anchor) + assertEquals(state.blocks[2].id, range().ends.value!!.head.blockId) + } + + @Test + fun shiftClickInTheSameFieldIsTheFieldsOwn() { + show(text("first line"), text("second line")) + fields()[0].performClick() + + rule.onAllNodes(isRoot()).onFirst().performMultiModalInput { + key { keyDown(Key.ShiftLeft) } + mouse { click(centerOf(0) + Offset(30f, 0f)) } + key { keyUp(Key.ShiftLeft) } + } + rule.waitForIdle() + + assertFalse(range().isCrossField) + } + + @Test + fun aDragHeldAtTheTopEdgeScrollsTheListAndKeepsExtendingTheSelection() { + show(text("first line"), text("second line"), text("third")) + val from = centerOf(1) + + // The list scrolls frame by frame while the button is held: the clock must not run on its own. + rule.mainClock.autoAdvance = false + rule.onRoot().performMouseInput { + moveTo(from) + press() + moveTo(Offset(from.x, 4f)) + } + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + rule.onRoot().performMouseInput { release() } + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + assertTrue(range().isCrossField) + assertEquals(state.blocks[0].id, range().ends.value!!.head.blockId) + } + + @Test + fun aDragHeldAtTheBottomEdgeScrollsDown() { + show(text("first line"), text("second line")) + val from = centerOf(0) + + rule.mainClock.autoAdvance = false + rule.onRoot().performMouseInput { + moveTo(from) + press() + moveTo(Offset(from.x, 890f * 3)) + } + rule.mainClock.advanceTimeByFrame() + rule.onRoot().performMouseInput { release() } + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + assertTrue(range().isCrossField) + } + + @Test + fun theScrollSpeedIsZeroAwayFromTheEdges() { + assertEquals(0f, autoScrollSpeed(500f, 1000f, 100f)) + assertTrue(autoScrollSpeed(10f, 1000f, 100f) < 0f) + assertTrue(autoScrollSpeed(990f, 1000f, 100f) > 0f) + } + + // --- Shift and the keys ------------------------------------------------------------------------------ + + private fun shiftKey(index: Int, key: Key, ctrl: Boolean = false) = fields()[index].performKeyInput { + withKeyDown(Key.ShiftLeft) { + if (ctrl) withKeyDown(Key.CtrlLeft) { pressKey(key) } else pressKey(key) + } + } + + @Test + fun shiftRightAtTheEndOfAFieldRunsIntoTheNextOne() { + show(text("abc"), text("def")) + fields()[0].performClick() + setSelection(0, 3, 3) + + shiftKey(0, Key.DirectionRight) + rule.waitForIdle() + + assertTrue(range().isCrossField) + assertEquals(point(1, 0), range().ends.value!!.head) + shiftKey(0, Key.DirectionRight) + assertEquals(point(1, 1), range().ends.value!!.head) + } + + @Test + fun shiftLeftAtTheStartRunsIntoThePreviousOne() { + show(text("abc"), text("def")) + fields()[1].performClick() + setSelection(1, 0, 0) + + shiftKey(1, Key.DirectionLeft) + rule.waitForIdle() + + assertEquals(point(0, 3), range().ends.value!!.head) + } + + @Test + fun shiftDownFromTheLastLineAndShiftUpFromTheFirstDo() { + show(text("abc"), text("def"), text("ghi")) + fields()[1].performClick() + setSelection(1, 1, 1) + + shiftKey(1, Key.DirectionDown) + rule.waitForIdle() + assertEquals(point(2, 1), range().ends.value!!.head) + + rule.runOnIdle { range().clear() } + shiftKey(1, Key.DirectionUp) + rule.waitForIdle() + assertEquals(point(0, 1), range().ends.value!!.head) + } + + @Test + fun theHomeEndAndPageKeysWithShift() { + show(text("abc"), text("def"), text("ghi")) + fields()[1].performClick() + setSelection(1, 1, 1) + + // Within the field Home is the field's own. + shiftKey(1, Key.MoveHome) + rule.waitForIdle() + assertFalse(range().isCrossField) + + shiftKey(1, Key.PageDown) + rule.waitForIdle() + assertEquals(state.blocks[2].id, range().ends.value!!.head.blockId) + + shiftKey(1, Key.MoveEnd, ctrl = true) + assertEquals(point(2, 3), range().ends.value!!.head) + shiftKey(1, Key.MoveHome, ctrl = true) + assertEquals(point(0, 0), range().ends.value!!.head) + shiftKey(1, Key.PageUp) + assertEquals(point(0, 0), range().ends.value!!.head) + } + + @Test + fun aShiftKeyThatStaysInTheFieldMovesTheFieldsOwnSelection() { + show(text("abc"), text("def")) + fields()[0].performClick() + setSelection(0, 1, 1) + + shiftKey(0, Key.DirectionRight) + rule.waitForIdle() + + assertFalse(range().isCrossField) + assertTrue(range().isActive) + } + + // --- acting on a selection --------------------------------------------------------------------------- + + @Test + fun aFieldsOwnSelectionIsTheWholeSelectionUntilItRunsIntoAnotherField() { + show(text("abc"), text("def")) + fields()[0].performClick() + + setSelection(0, 1, 3) + assertTrue(range().isActive) + assertFalse(range().isCrossField) + assertEquals(point(0, 1), range().ends.value!!.anchor) + + setSelection(0, 2, 2) + assertFalse(range().isActive) + } + + @Test + fun deleteBackspaceAndEnterRemoveTheSelectedText() { + show(text("alpha"), text("beta"), text("gamma")) + + for (key in listOf(Key.Delete, Key.Backspace, Key.Enter)) { + rule.runOnIdle { state.load(listOf(text("alpha"), text("beta"), text("gamma"))) } + rule.waitForIdle() + selectAcross(0, point(0, 2), point(2, 2)) + + fields()[0].performKeyInput { pressKey(key) } + rule.waitForIdle() + + assertEquals(listOf("almma"), shown(), key.toString()) + } + } + + @Test + fun undoBringsBackWhatDeleteRemoved() { + show(text("alpha"), text("beta"), text("gamma")) + selectAcross(0, point(0, 2), point(2, 2)) + + fields()[0].performKeyInput { pressKey(Key.Delete) } + rule.waitForIdle() + rule.runOnIdle { state.undo() } + rule.waitForIdle() + + assertEquals(listOf("alpha", "beta", "gamma"), shown()) + } + + @Test + fun escapeLeavesTheSelectionAndItsOwnKeysCostNothingWithoutOne() { + show(text("alpha"), text("beta")) + selectAcross(0, point(0, 1), point(1, 2)) + + fields()[0].performKeyInput { pressKey(Key.Escape) } + rule.waitForIdle() + assertFalse(range().isActive) + + fields()[0].performKeyInput { pressKey(Key.Delete) } + fields()[0].performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.C) } } + assertEquals(listOf("alpha", "beta"), shown()) + assertTrue(controller.copies.isEmpty()) + } + + @Test + fun copyCopyAsMarkdownAndCutGoToTheClipboard() { + show(text("alpha"), text("beta")) + selectAcross(0, point(0, 2), point(1, 2)) + + fields()[0].performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.C) } } + fields()[0].performKeyInput { + withKeyDown(Key.MetaLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.C) } } + } + rule.waitForIdle() + assertEquals( + listOf(false to "pha\n\nbe", true to "pha\n\nbe"), + controller.copies.take(2).map { + it.first to + it.second + }, + ) + + fields()[0].performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.X) } } + rule.waitForIdle() + assertEquals(listOf("alta"), shown()) + } + + @Test + fun otherKeysOverASelectionAreTheFieldsOwn() { + show(text("alpha"), text("beta")) + selectAcross(0, point(0, 1), point(1, 2)) + + fields()[0].performKeyInput { pressKey(Key.F2) } + fields()[0].performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.F3) } } + rule.waitForIdle() + + assertTrue(range().isCrossField) + } + + @Test + fun typingOverASelectionReplacesItInOneStep() { + show(text("alpha"), text("beta")) + selectAcross(0, point(0, 2), point(1, 2)) + + fields()[0].performTextInput("X") + rule.waitForIdle() + + assertEquals(listOf("alXta"), shown()) + rule.runOnIdle { state.undo() } + rule.waitForIdle() + assertEquals(listOf("alpha", "beta"), shown()) + } + + @Test + fun typingIntoAListItemOverASelectionDoesTheSame() { + val bullets = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("one"), ItemListBlock("two"))), + BlockType.LIST_BULLET, + ) + show(text("head"), bullets) + selectAcross(0, point(0, 2), TextPoint(bullets.id, 1, bullets.items.value[1].id)) + + fields()[0].performTextInput("Z") + rule.waitForIdle() + + assertEquals("heZwo", (state.blocks[0] as Block.TextBlock).text.value) + } + + @Test + fun aKeyInAListItemActsOnTheSelectionToo() { + val bullets = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("one"), ItemListBlock("two"))), + BlockType.LIST_BULLET, + ) + show(text("head"), bullets) + fields()[1].performClick() + setSelection(1, 3, 3) + + shiftKey(1, Key.DirectionRight) + rule.waitForIdle() + assertTrue(range().isCrossField) + + fields()[1].performKeyInput { pressKey(Key.Delete) } + rule.waitForIdle() + assertEquals(2, state.blocks.size) + assertEquals(listOf("onetwo"), (state.blocks[1] as Block.ListBlock).items.value.map { it.value }) + } + + @Test + fun aSelectionStartedInAListItemDraws() { + val bullets = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("one"), ItemListBlock("two"))), + BlockType.LIST_BULLET, + ) + show(bullets, text("tail")) + selectAcross(0, TextPoint(bullets.id, 1, bullets.items.value[0].id), point(1, 2)) + + rule.onRoot().captureToImage() + + assertEquals(2, range().blockCount()) + } + + // --- the bar and the handles ---------------------------------------------------------------------------- + + @Test + fun theBarCountsTheBlocksAndItsButtonsAskTheHost() { + show(text("alpha"), text("beta"), text("gamma")) + selectAcross(0, point(0, 1), point(2, 1)) + + rule.onNodeWithText("Blocks selected: 3").assertExists() + for (label in listOf("Copy", "Copy as Markdown", "Cut", "Delete selected blocks")) { + rule.onNode(hasContentDescription(label) and hasClickAction()).performClick() + } + assertEquals(listOf("copy", "markdown", "cut", "delete"), calls) + + rule.onNode(hasContentDescription("Clear selection") and hasClickAction()).performClick() + rule.waitForIdle() + assertFalse(range().isActive) + rule.onNodeWithText("Blocks selected: 3").assertDoesNotExist() + } + + @Test + fun theEndHandleDraggedIntoAnotherBlockMovesTheEndThere() { + show(text("alpha"), text("beta"), text("gamma")) + selectAcross(0, point(0, 1), point(1, 2)) + + // The handle hangs below the cursor of "beta"; dragged on, the finger is over "gamma". + rule.onNodeWithTag(RANGE_END_TAG).performTouchInput { + down(center) + moveBy(Offset(0f, 40f)) + moveBy(Offset(0f, 40f)) + up() + } + rule.waitForIdle() + + assertEquals(point(0, 1), range().ends.value!!.anchor) + assertEquals(state.blocks[2].id, range().ends.value!!.head.blockId) + } + + @Test + fun theStartHandleDraggedUpMovesTheStartAndTheEndStays() { + show(text("alpha"), text("beta"), text("gamma")) + selectAcross(1, point(1, 1), point(2, 2)) + val target = centerOf(0) + + val start = rule.onNodeWithTag(RANGE_START_TAG).fetchSemanticsNode().boundsInRoot.center + rule.onNodeWithTag(RANGE_START_TAG).performTouchInput { + down(center) + moveBy(Offset(0f, (target.y - start.y) / 2)) + moveBy(Offset(0f, (target.y - start.y) / 2)) + up() + } + rule.waitForIdle() + + assertEquals(state.blocks[0].id, range().orderedPoints()!!.first.blockId) + assertEquals(state.blocks[2].id, range().orderedPoints()!!.second.blockId) + } + + @Test + fun aHandleDragThatIsCancelledEndsTheDrag() { + show(text("alpha"), text("beta"), text("gamma")) + selectAcross(0, point(0, 1), point(1, 2)) + + rule.onNodeWithTag(RANGE_END_TAG).performTouchInput { + down(center) + moveBy(Offset(0f, 60f)) + cancel() + } + rule.waitForIdle() + + assertTrue(range().isActive) + } + + @Test + fun noSelectionHasNoHandles() { + show(text("alpha"), text("beta")) + + rule.onNodeWithTag(RANGE_START_TAG).assertDoesNotExist() + rule.onNodeWithTag(RANGE_END_TAG).assertDoesNotExist() + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/ChecklistLayoutTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt similarity index 81% rename from app/src/test/java/com/pasich/encly/presentation/editor/blocks/ChecklistLayoutTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt index 9e4c4806..a4e39eee 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/ChecklistLayoutTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt @@ -1,6 +1,7 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import android.app.Application +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -28,24 +29,24 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.test.core.app.ApplicationProvider -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.domain.model.NoteLineSpacing -import com.pasich.encly.domain.model.ThemeSettings -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.designsystem.markerBoxSize -import com.pasich.encly.presentation.designsystem.markerGap -import com.pasich.encly.presentation.editor.BlockActions -import com.pasich.encly.presentation.screen.editnote.EditNoteSettingsProvider -import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.editor.EditorProvider +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.theme.EditorFontSizes +import com.pasich.encly.editor.theme.EditorLineSpacing +import com.pasich.encly.editor.theme.EditorTheme +import com.pasich.encly.editor.theme.markerBoxSize +import com.pasich.encly.editor.theme.markerGap +import com.pasich.encly.editor.ui.BlockActions import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals import org.junit.Rule -import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals /** Where a checklist box sits against its item's text, at every line spacing and text size. */ @RunWith(RobolectricTestRunner::class) @@ -56,7 +57,7 @@ class ChecklistLayoutTest { private val isCheckbox = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) - private var spacing by mutableStateOf(NoteLineSpacing.DEFAULT) + private var spacing by mutableStateOf(EditorLineSpacing.DEFAULT) private var size by mutableIntStateOf(16) private var metrics = LineMetrics(0f, 0f, 0f) private var fontDp = 0.dp @@ -72,8 +73,10 @@ class ChecklistLayoutTest { block.items.value = invocation.arguments[0] as List }.`when`(actions).onListItemsChanged(Mockito.any() ?: emptyList(), Mockito.anyBoolean()) rule.setContent { - EnclyTheme(settings = ThemeSettings(), dark = false) { - EditNoteSettingsProvider(baseFontSize = size.sp, lineSpacing = spacing) { + MaterialTheme { + EditorProvider( + theme = EditorTheme(fontSizes = EditorFontSizes(list = size.sp), lineSpacing = spacing), + ) { // The very metrics the row works from (Robolectric draws no real outlines to measure). metrics = rememberLineMetrics() fontDp = listFontDp() @@ -83,7 +86,7 @@ class ChecklistLayoutTest { } } - private fun show(newSpacing: NoteLineSpacing, newSize: Int) { + private fun show(newSpacing: EditorLineSpacing, newSize: Int) { spacing = newSpacing size = newSize rule.waitForIdle() @@ -109,7 +112,7 @@ class ChecklistLayoutTest { return (layout.getLineTop(0) + layout.getLineBottom(0)) / 2f } - private val spacings = NoteLineSpacing.entries + private val spacings = EditorLineSpacing.entries private val sizes = listOf(12, 16, 24, 32) @Test @@ -119,10 +122,10 @@ class ChecklistLayoutTest { for (size in sizes) { show(spacing, size) val (box, field) = boxAndText() - assertEquals("$spacing $size", field.top + metrics.centre, box.center.y, 1f) + assertEquals(field.top + metrics.centre, box.center.y, 1f, "$spacing $size") // The style's middle is the real layout's wherever the line box is as tall as the font. - if (spacing != NoteLineSpacing.COMPACT) { - assertEquals("$spacing $size layout", field.top + layoutCentre(), box.center.y, 1f) + if (spacing != EditorLineSpacing.COMPACT) { + assertEquals(field.top + layoutCentre(), box.center.y, 1f, "$spacing $size layout") } } } @@ -137,9 +140,9 @@ class ChecklistLayoutTest { show(spacing, size) val (box, field) = boxAndText() val side = markerBoxSize(fontDp).value * density - assertEquals("$spacing $size box", side, box.width, 1f) - assertEquals("$spacing $size box", side, box.height, 1f) - assertEquals("$spacing $size gap", markerGap(fontDp).value * density, field.left - box.right, 1f) + assertEquals(side, box.width, 1f, "$spacing $size box") + assertEquals(side, box.height, 1f, "$spacing $size box") + assertEquals(markerGap(fontDp).value * density, field.left - box.right, 1f, "$spacing $size gap") } } } @@ -150,8 +153,8 @@ class ChecklistLayoutTest { for (spacing in spacings) { show(spacing, 24) val (box, field) = boxAndText() - assertEquals("$spacing", true, field.height > box.height * 2) - assertEquals("$spacing", field.top + metrics.centre, box.center.y, 1f) + assertEquals(true, field.height > box.height * 2, "$spacing") + assertEquals(field.top + metrics.centre, box.center.y, 1f, "$spacing") } } @@ -159,7 +162,7 @@ class ChecklistLayoutTest { @OptIn(ExperimentalTestApi::class) fun theBoxTogglesFromA48dpSquareAroundItAndTheTextKeepsItsOwnTaps() { start("milk") - show(NoteLineSpacing.COMPACT, 14) + show(EditorLineSpacing.COMPACT, 14) val dp = rule.density.density val (box, field) = boxAndText() val centre = box.center @@ -187,8 +190,8 @@ class ChecklistLayoutTest { block.items.value = listOf(block.items.value.single().copy(value = "x")) rule.waitForIdle() val (typedBox, typedField) = boxAndText() - assertEquals("$spacing $size box", typedBox.center.y, emptyBox.center.y, 1f) - assertEquals("$spacing $size line", typedField.top + layoutCentre(), emptyLine, 1f) + assertEquals(typedBox.center.y, emptyBox.center.y, 1f, "$spacing $size box") + assertEquals(typedField.top + layoutCentre(), emptyLine, 1f, "$spacing $size line") block.items.value = listOf(block.items.value.single().copy(value = "")) rule.waitForIdle() } @@ -199,20 +202,20 @@ class ChecklistLayoutTest { fun everyRowOfSeveralMultiLineItemsKeepsItsBoxOnItsOwnFirstLine() { start("a\nb\nc") block.items.value = List(5) { ItemListBlock("item $it\nsecond\nthird") } - show(NoteLineSpacing.DEFAULT, 16) + show(EditorLineSpacing.DEFAULT, 16) val boxes = rule.onAllNodesWithTag(CHECK_BOX_TAG, useUnmergedTree = true).fetchSemanticsNodes().map { it.boundsInRoot } val fields = rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().map { it.boundsInRoot } val offsets = boxes.indices.map { boxes[it].center.y - fields[it].top } - assertEquals(offsets.toString(), 1, offsets.map { Math.round(it) }.toSet().size) + assertEquals(1, offsets.map { Math.round(it) }.toSet().size, offsets.toString()) } @Test fun aCheckedItemIsStruckByTheFieldNotByATextDecorationAComposingWordCouldReplace() { start("done") block.items.value = listOf(block.items.value.single().copy(isCheck = true)) - show(NoteLineSpacing.DEFAULT, 16) + show(EditorLineSpacing.DEFAULT, 16) val decoration = fieldLayout().layoutInput.style.textDecoration diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt new file mode 100644 index 00000000..67533088 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt @@ -0,0 +1,169 @@ +package com.pasich.encly.editor.ui.blocks + +import android.app.Application +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.sp +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.editor.EditorProvider +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TextualBlock +import com.pasich.encly.editor.model.isEmpty +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.state.FindState +import com.pasich.encly.editor.ui.EditorBlocksHost +import com.pasich.encly.editor.ui.FindHighlights +import com.pasich.encly.editor.ui.StateController +import com.pasich.encly.editor.ui.editorBlocks +import com.pasich.encly.editor.ui.rememberBlockFocusRegistry +import com.pasich.encly.editor.ui.rememberWriteAtEnd +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** What the blocks draw (Find's tint, a checked item's strike), which needs real drawing, and small leftovers. */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class EditorDrawTest { + @get:Rule + val rule = createComposeRule() + + private fun layoutOf(text: String) = ApplicationProvider.getApplicationContext().let { context -> + TextMeasurer(createFontFamilyResolver(context), Density(context), LayoutDirection.Ltr) + .measure(text, TextStyle(fontSize = 16.sp), constraints = Constraints(maxWidth = 400)) + } + + @Test + fun findTintsMatchesAndACheckedItemIsStruckThrough() { + val checks = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("done thing", isCheck = true), ItemListBlock("open thing"))), + BlockType.LIST_CHECK, + ) + val state = BlockEditorState { 0L } + state.load( + listOf( + Block.TextBlock(MutableStateFlow("thing one thing two")), + Block.HBlock(MutableStateFlow("thing heading"), BlockType.H1), + Block.HBlock(MutableStateFlow("thing two"), BlockType.H2), + Block.HBlock(MutableStateFlow("thing three"), BlockType.H3), + Block.HBlock(MutableStateFlow("thing four"), BlockType.H4), + Block.QuoteBlock(MutableStateFlow("thing quote")), + checks, + ), + ) + val controller = StateController(state) + rule.setContent { + MaterialTheme { + EditorProvider { + val registry = rememberBlockFocusRegistry(state.blocks) + val find = state.find.state.collectAsState() + val highlights = FindHighlights(find) + val host = EditorBlocksHost(controller, registry, highlights) { _, _ -> } + androidx.compose.foundation.lazy.LazyColumn { + editorBlocks(host, state.blocks, false, emptySet()) + } + } + } + } + rule.runOnIdle { + state.find.open() + state.find.search("thing", state.blocks) + state.find.next() + } + rule.waitForIdle() + + rule.onRoot().captureToImage() + } + + @Test + fun strikeSegmentsCoverEachNonEmptyLine() { + val layout = layoutOf("some words") + val segments = strikeSegments(layout, 16f) + assertEquals(1, segments.size) + assertTrue(segments.single().right > segments.single().left) + assertTrue(strikeSegments(layoutOf(""), 16f).isEmpty()) + } + + @Test + fun theCapitalsHeightFallsBackWhereNoOutlineExists() { + assertTrue(capHeightPx(layoutOf("H"), 16f) > 0f) + } + + @Test + fun aTextBlockKnowsWhetherItIsEmpty() { + val textual: TextualBlock = Block.TextBlock() + assertTrue(textual.isEmpty()) + textual.text.value = "ab" + assertTrue(!textual.isEmpty()) + } + + @Test + fun theMailAndBlockedLinksReadTheirCards() { + assertEquals("a@b.c" to "", parseNoteLink("mailto:a@b.c").cardLines()) + assertEquals("a@b.c", buildLinkData("mailto:a@b.c?subject=x")?.title) + assertEquals("javascript:x" to "", parseNoteLink("javascript:x").cardLines()) + assertNull(buildLinkData("javascript:x")) + assertTrue(isOpenableLink("https://example.com")) + } + + @Test + fun aHeadingOfAnotherTypeStillGetsAStyle() { + val state = BlockEditorState { 0L } + state.load(listOf(Block.HBlock(MutableStateFlow("odd"), BlockType.TEXT))) + val controller = StateController(state) + rule.setContent { + MaterialTheme { + EditorProvider { + val registry = rememberBlockFocusRegistry(state.blocks) + val actions = com.pasich.encly.editor.ui.BlockActionsImpl( + state.blocks[0], + controller, + registry, + FindHighlights(remember { mutableStateOf(FindState()) }), + ) + HBlock(state.blocks[0] as Block.HBlock, actions) + } + } + } + rule.waitForIdle() + } + + @Test + fun writingAtTheEndAddsAParagraph() { + val state = BlockEditorState { 0L } + state.load(listOf(Block.SeparatorBlock())) + val controller = StateController(state) + var write: (() -> Unit)? = null + rule.setContent { Provide { write = rememberWriteAtEnd(controller) } } + rule.waitForIdle() + + rule.runOnIdle { write!!() } + + assertEquals(2, state.blocks.size) + } + + @Composable + private fun Provide(content: @Composable () -> Unit) = MaterialTheme { EditorProvider(content = content) } +} diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllConnectionTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllConnectionTest.kt new file mode 100644 index 00000000..385d13ff --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllConnectionTest.kt @@ -0,0 +1,131 @@ +package com.pasich.encly.editor.ui.blocks + +import android.app.Application +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.ExtractedText +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.PlatformTextInputMethodRequest +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** The keyboard's Select all, asked while the field's text is all selected already, selects the whole note. */ +@OptIn(ExperimentalComposeUiApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class SelectAllConnectionTest { + private val target = mock(InputConnection::class.java) + private var asked = 0 + private var takes = true + private val connection = SelectAllConnection(target) { asked++ >= 0 && takes } + + private fun selecting(text: String, start: Int, end: Int) { + val extracted = ExtractedText().apply { + this.text = text + selectionStart = start + selectionEnd = end + } + `when`(target.getExtractedText(any(ExtractedTextRequest::class.java), eq(0))).thenReturn(extracted) + } + + @Test + fun selectAllOverAllSelectedTextTakesTheWholeNoteInstead() { + selecting("abc", 0, 3) + + assertTrue(connection.performContextMenuAction(android.R.id.selectAll)) + + assertEquals(1, asked) + verify(target, never()).performContextMenuAction(android.R.id.selectAll) + } + + @Test + fun aSelectionMadeBackwardsCountsToo() { + selecting("abc", 3, 0) + + assertTrue(connection.performContextMenuAction(android.R.id.selectAll)) + assertEquals(1, asked) + } + + @Test + fun anEmptyFieldHasNothingOfItsOwnToSelect() { + selecting("", 0, 0) + + assertTrue(connection.performContextMenuAction(android.R.id.selectAll)) + assertEquals(1, asked) + } + + @Test + fun partlySelectedTextIsSelectedByTheFieldFirst() { + `when`(target.performContextMenuAction(android.R.id.selectAll)).thenReturn(true) + selecting("abc", 1, 3) + assertTrue(connection.performContextMenuAction(android.R.id.selectAll)) + + selecting("abc", 0, 2) + assertTrue(connection.performContextMenuAction(android.R.id.selectAll)) + + selecting("abc", 1, 1) + assertTrue(connection.performContextMenuAction(android.R.id.selectAll)) + + assertEquals(0, asked) + verify(target, times(3)).performContextMenuAction(android.R.id.selectAll) + } + + @Test + fun whenTheEditorDeclinesTheFieldHandlesIt() { + takes = false + selecting("abc", 0, 3) + `when`(target.performContextMenuAction(android.R.id.selectAll)).thenReturn(true) + + assertTrue(connection.performContextMenuAction(android.R.id.selectAll)) + + assertEquals(1, asked) + verify(target).performContextMenuAction(android.R.id.selectAll) + } + + @Test + fun aKeyboardThatSaysNothingAboutTheTextIsLeftToTheField() { + `when`(target.performContextMenuAction(android.R.id.selectAll)).thenReturn(false) + + assertFalse(connection.performContextMenuAction(android.R.id.selectAll)) + + assertEquals(0, asked) + } + + @Test + fun otherMenuActionsGoStraightToTheField() { + `when`(target.performContextMenuAction(android.R.id.copy)).thenReturn(true) + + assertTrue(connection.performContextMenuAction(android.R.id.copy)) + + assertEquals(0, asked) + verify(target, never()).getExtractedText(any(), anyInt()) + } + + @Test + fun theRequestWrapsTheConnectionTheFieldMakes() { + val info = EditorInfo() + val request = PlatformTextInputMethodRequest { given -> + assertSame(info, given) + target + } + + val made = selectAllRequest(request) { true }.createInputConnection(info) + + assertTrue(made is SelectAllConnection) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlockTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt similarity index 76% rename from app/src/test/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlockTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt index 104c4928..861aed29 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlockTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt @@ -1,19 +1,18 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import android.app.Application +import androidx.compose.material3.MaterialTheme import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp -import com.pasich.encly.domain.model.ThemeSettings -import com.pasich.encly.ui.theme.EnclyTheme -import org.junit.Assert.assertEquals import org.junit.Rule -import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") @@ -24,7 +23,7 @@ class SeparatorBlockTest { private var opened = 0 private fun show() = rule.setContent { - EnclyTheme(settings = ThemeSettings(), dark = false) { SeparatorBlock(onClick = { opened++ }) } + MaterialTheme { SeparatorBlock(onClick = { opened++ }) } } @Test diff --git a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt new file mode 100644 index 00000000..88bddf25 --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt @@ -0,0 +1,180 @@ +package com.pasich.encly.editor.ui.sheets + +import android.app.Application +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.pasich.encly.editor.EditorClipboard +import com.pasich.encly.editor.EditorProvider +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LinkOpener +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.ui.EditorController +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The sheet of a block: what it offers, and that each row reaches the right service or controller call. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class EditorBlockSheetTest { + @get:Rule + val rule = createComposeRule() + + private val link = Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "x", url = "https://example.com/a"))) + private val quote = Block.QuoteBlock() + private val controller = Mockito.mock(EditorController::class.java).also { + Mockito.`when`(it.blocks).thenReturn(listOf(link, quote)) + Mockito.`when`(it.interactedBlockPosition()).thenReturn(-1) + } + private val copied = mutableListOf>() + private val opened = mutableListOf() + private var closed = 0 + + private fun show(kind: BlockSheetKind?, blockId: String?, canEdit: Boolean = true) { + rule.setContent { + MaterialTheme { + EditorProvider( + clipboard = EditorClipboard { label, text, sensitive -> copied += Triple(label, text, sensitive) }, + linkOpener = LinkOpener { opened += it }, + ) { + EditorBlockSheet(kind, blockId, canEdit, controller) { closed++ } + } + } + } + rule.waitForIdle() + } + + private fun shown(key: EditorString) = rule.onAllNodes(androidx.compose.ui.test.hasText(key.english)) + .fetchSemanticsNodes().isNotEmpty() + + private fun tap(key: EditorString) { + rule.onNodeWithText(key.english).performClick() + rule.waitForIdle() + } + + private fun awaitClosed() = rule.waitUntil(timeoutMillis = 5_000) { closed > 0 } + + @Test + fun withoutAKindOrABlockNothingShows() { + show(null, link.id) + assertTrue(!shown(EditorString.LINK_COPY)) + } + + @Test + fun aBlockThatIsGoneShowsNothing() { + show(BlockSheetKind.LINK, "gone") + assertTrue(!shown(EditorString.LINK_COPY)) + } + + @Test + fun copyingALinkGoesToTheClipboardAsSensitiveTextAndClosesTheSheet() { + show(BlockSheetKind.LINK, link.id) + + tap(EditorString.LINK_COPY) + awaitClosed() + + assertEquals(listOf(Triple("Link", "https://example.com/a", true)), copied) + assertEquals(1, closed) + } + + @Test + fun openingALinkHandsItToTheOpener() { + show(BlockSheetKind.LINK, link.id) + + tap(EditorString.LINK_OPEN) + awaitClosed() + + assertEquals(listOf("https://example.com/a"), opened) + } + + @Test + fun editingALinkReopensItsAddress() { + show(BlockSheetKind.LINK, link.id) + + tap(EditorString.LINK_EDIT) + awaitClosed() + + Mockito.verify(controller).editLink(link) + } + + @Test + fun aReadOnlyLinkCanBeCopiedButNotEdited() { + show(BlockSheetKind.LINK, link.id, canEdit = false) + + assertTrue(shown(EditorString.LINK_COPY)) + assertTrue(!shown(EditorString.LINK_EDIT)) + } + + @Test + fun movingKeepsTheSheetOpenAndDuplicatingClosesIt() { + show(BlockSheetKind.OTHER, quote.id) + + tap(EditorString.BLOCK_MOVE_UP) + tap(EditorString.BLOCK_MOVE_DOWN) + Mockito.verify(controller).moveBlock(true) + Mockito.verify(controller).moveBlock(false) + assertEquals(0, closed) + + tap(EditorString.BLOCK_DUPLICATE) + awaitClosed() + Mockito.verify(controller).duplicateBlock(quote) + } + + @Test + fun aLinkSheetOnAnotherKindOfBlockOffersTheCopyRowOnly() { + show(BlockSheetKind.LINK, quote.id) + + assertTrue(shown(EditorString.LINK_COPY)) + assertTrue(!shown(EditorString.LINK_OPEN)) + } + + @Test + fun everyKindOfBlockHasItsOwnName() { + val names = listOf( + text() to EditorString.TEXT, + Block.QuoteBlock() to EditorString.QUOTE, + Block.ListBlock(blockType = BlockType.LIST_BULLET) to EditorString.BLOCK_LIST, + Block.HBlock(blockType = BlockType.H1) to EditorString.BLOCK_HEADING, + Block.LinkBlock() to EditorString.BLOCK_LINK, + Block.SeparatorBlock() to EditorString.BLOCK_SEPARATOR, + ) + names.forEach { (block, name) -> assertEquals(name, block.describe()) } + } + + private fun text() = Block.TextBlock() + + @Test + fun anArmedDeleteRowDisarmsAfterItsWindow() { + show(BlockSheetKind.OTHER, quote.id) + + tap(EditorString.BLOCK_DELETE) + assertTrue(shown(EditorString.TAP_AGAIN_TO_DELETE)) + rule.mainClock.advanceTimeBy(6_000) + rule.waitForIdle() + + assertTrue(shown(EditorString.BLOCK_DELETE)) + } + + @Test + fun deletingTakesASecondTap() { + show(BlockSheetKind.OTHER, quote.id) + + tap(EditorString.BLOCK_DELETE) + Mockito.verify(controller, Mockito.never()).removeBlock(quote, BlockRemoveAction.REMOVE) + tap(EditorString.TAP_AGAIN_TO_DELETE) + awaitClosed() + + Mockito.verify(controller).removeBlock(quote, BlockRemoveAction.REMOVE) + } +} diff --git a/editor/src/androidMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.android.kt b/editor/src/androidMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.android.kt new file mode 100644 index 00000000..db904bbe --- /dev/null +++ b/editor/src/androidMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.android.kt @@ -0,0 +1,5 @@ +package com.pasich.encly.editor.ui.blocks + +import java.net.IDN + +internal actual fun toAsciiHost(host: String): String? = runCatching { IDN.toASCII(host) }.getOrNull() diff --git a/editor/src/androidMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.android.kt b/editor/src/androidMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.android.kt new file mode 100644 index 00000000..41117e34 --- /dev/null +++ b/editor/src/androidMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.android.kt @@ -0,0 +1,51 @@ +package com.pasich.encly.editor.ui.blocks + +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.platform.PlatformTextInputInterceptor +import androidx.compose.ui.platform.PlatformTextInputMethodRequest + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal actual fun BlockSelectAllIme(onSelectAll: () -> Boolean, content: @Composable () -> Unit) { + val current by rememberUpdatedState(onSelectAll) + val interceptor = remember { + PlatformTextInputInterceptor { request, next -> next.startInputMethod(selectAllRequest(request) { current() }) } + } + InterceptPlatformTextInput(interceptor, content) +} + +/** [request], except that the connection it makes is wrapped in a [SelectAllConnection]. */ +internal fun selectAllRequest(request: PlatformTextInputMethodRequest, onSelectAll: () -> Boolean) = + PlatformTextInputMethodRequest { info -> + SelectAllConnection(request.createInputConnection(info), onSelectAll) + } + +/** + * The field's connection to the keyboard, except that the Select all menu action, asked while the text is + * all selected already (or empty), runs [onSelectAll] instead. When that declines, or only part of the text + * is selected, the field handles the action as always. + */ +internal class SelectAllConnection(target: InputConnection, private val onSelectAll: () -> Boolean) : + InputConnectionWrapper(target, false) { + override fun performContextMenuAction(id: Int): Boolean = + if (id == android.R.id.selectAll && textFullySelected() && onSelectAll()) { + true + } else { + super.performContextMenuAction(id) + } + + private fun textFullySelected(): Boolean { + val extracted = getExtractedText(ExtractedTextRequest(), 0) ?: return false + val start = minOf(extracted.selectionStart, extracted.selectionEnd) + val end = maxOf(extracted.selectionStart, extracted.selectionEnd) + return extracted.text.isEmpty() || (start == 0 && end == extracted.text.length) + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorServices.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorServices.kt new file mode 100644 index 00000000..460ac469 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorServices.kt @@ -0,0 +1,109 @@ +package com.pasich.encly.editor + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.theme.EditorTheme +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.blocks.LocalSlashLabels +import com.pasich.encly.editor.ui.blocks.rememberSlashLabels + +/** + * Puts text on the system clipboard. The editor never touches a platform clipboard itself, so + * the host decides how long the text stays there and whether it is hidden from previews. + */ +public fun interface EditorClipboard { + /** + * Copies [text], labelled [label]. [sensitive] is true for text the user wrote in a note (a + * link's address): the host should keep it out of clipboard previews and clear it after a while. + */ + public fun copy(label: String, text: String, sensitive: Boolean) + + /** Copies nothing: the default when the host provides no clipboard. */ + public companion object None : EditorClipboard { + override fun copy(label: String, text: String, sensitive: Boolean): Unit = Unit + } +} + +/** + * Hands a note's link to the system. The editor decides whether a link may be opened at all + * ([com.pasich.encly.editor.ui.blocks.isOpenableLink]); the host decides how, and tells the user + * when nothing can take it. + */ +public fun interface LinkOpener { + /** Opens the web or mail link [url] in another app. */ + public fun open(url: String) + + /** Opens nothing: the default when the host provides no opener. */ + public companion object None : LinkOpener { + override fun open(url: String): Unit = Unit + } +} + +/** + * Receives the editor's failure events. They are fixed descriptions, never note content or an + * exception's message, so a host may forward them anywhere. + */ +public fun interface EditorLogger { + /** A recoverable problem: [event] says what failed in [tag]. */ + public fun warn(tag: String, event: String) + + /** Logs nothing: the default when the host provides no logger. */ + public companion object None : EditorLogger { + override fun warn(tag: String, event: String): Unit = Unit + } +} + +/** + * Reads Markdown text pasted into a note as blocks (Smart Paste). + * [com.pasich.encly.editor.codec.MarkdownBlockMapper] is the editor's own reader and the default; a + * host may pass another. + */ +public fun interface MarkdownBlockParser { + /** + * [text] as blocks, nothing rewritten or dropped. [claim] may take a whole line as a block of + * its own, before any other rule. With [plainFirstLine] the first line stays plain text whatever + * it starts with, for a paste that continues a line. + */ + public fun parsePaste(text: String, plainFirstLine: Boolean, claim: (String) -> Block?): List +} + +/** The strings the editor shows; English until the host provides its own. */ +public val LocalEditorStrings: ProvidableCompositionLocal = + staticCompositionLocalOf { EnglishEditorStrings } + +/** Where the editor copies text to; see [EditorClipboard]. */ +internal val LocalEditorClipboard = staticCompositionLocalOf { EditorClipboard.None } + +/** How the editor opens a link; see [LinkOpener]. */ +internal val LocalLinkOpener = staticCompositionLocalOf { LinkOpener.None } + +/** Where the editor reports failures; see [EditorLogger]. */ +internal val LocalEditorLogger = staticCompositionLocalOf { EditorLogger.None } + +/** + * Provides everything the editor needs from its host above [content]: [strings], [theme], and the + * [clipboard], [linkOpener] and [logger] its sheets and focus handling call. Each has an inert + * default, so [content] may also be shown with only what the host cares about. + */ +@Composable +public fun EditorProvider( + strings: EditorStrings = EnglishEditorStrings, + theme: EditorTheme = EditorTheme(), + clipboard: EditorClipboard = EditorClipboard.None, + linkOpener: LinkOpener = LinkOpener.None, + logger: EditorLogger = EditorLogger.None, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalEditorStrings provides strings, + LocalEditorTheme provides theme, + LocalEditorClipboard provides clipboard, + LocalLinkOpener provides linkOpener, + LocalEditorLogger provides logger, + LocalSlashLabels provides rememberSlashLabels(strings), + content = content, + ) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt new file mode 100644 index 00000000..a5fccbd4 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt @@ -0,0 +1,103 @@ +package com.pasich.encly.editor + +/** + * Every piece of text the editor shows, by key. The host maps each key to its own localized + * string (the app maps them onto its string resources), so the editor ships no resource files + * and stays free of the host's localization system. [english] is the default text. + */ +public enum class EditorString(internal val english: String) { + TEXT("Text"), + PRESS_TO_EDIT("Tap to start editing…"), + BLOCK_HEADING("Heading"), + + /** Takes the heading level (1 to 4): see [EditorStrings.heading]. */ + BLOCK_HEADING_LEVEL("Heading %d"), + BLOCK_CHECKLIST("Checklist"), + BLOCK_BULLETED_LIST("Bulleted list"), + BLOCK_NUMBERED_LIST("Numbered list"), + BLOCK_LIST("List"), + QUOTE("Quote"), + BLOCK_LINK("Link"), + BLOCK_SEPARATOR("Separator"), + MORE_OPTIONS("More options"), + LIST_ITEM_PLACEHOLDER("List item"), + SLASH_MENU_TITLE("Insert block"), + ENTER_URL("Enter a link"), + LINK_BLOCKED("Only web (http, https) and email links can be saved or opened."), + LINK_CONTENT_FAILED("Couldn't load the content"), + LINK_ADD("Add link"), + LINK_OPEN("Open link"), + LINK_COPY("Copy link"), + LINK_EDIT("Edit link"), + BLOCK_MOVE_UP("Move block up"), + BLOCK_MOVE_DOWN("Move block down"), + BLOCK_DUPLICATE("Duplicate block"), + BLOCK_DELETE("Delete block"), + TAP_AGAIN_TO_DELETE("Tap again to delete"), + CHECKLIST_LOCK("Lock checklist"), + CHECKLIST_UNLOCK("Unlock checklist"), + BLOCK_SELECT("Select block"), + + /** Takes the number of selected blocks: see [EditorStrings.selectionCount]. */ + SELECTION_COUNT("Blocks selected: %d"), + SELECTION_CLOSE("Clear selection"), + SELECTION_COPY("Copy"), + SELECTION_COPY_MARKDOWN("Copy as Markdown"), + SELECTION_CUT("Cut"), + SELECTION_DELETE("Delete selected blocks"), + TEMPLATE_BLANK("Blank"), + TEMPLATE_MEETING("Meeting"), + TEMPLATE_ATTENDEES("Attendees"), + TEMPLATE_AGENDA("Agenda"), + TEMPLATE_NOTES("Notes"), + TEMPLATE_ACTION_ITEMS("Action items"), + TEMPLATE_JOURNAL("Journal"), + TEMPLATE_JOURNAL_TODAY("Today"), + TEMPLATE_JOURNAL_GRATITUDE("Gratitude"), + TEMPLATE_JOURNAL_TOMORROW("Tomorrow"), + TEMPLATE_PROJECT("Project notes"), + TEMPLATE_PROJECT_GOAL("Goal"), + TEMPLATE_PROJECT_TASKS("Tasks"), + TEMPLATE_PROJECT_DECISIONS("Decisions"), + TEMPLATE_PROJECT_QUESTIONS("Open questions"), + TEMPLATE_CHECKLIST_TODO("To do"), + LIST_ITEM_REORDER("Reorder item"), + LIST_ITEM_MOVE_UP("Move item up"), + LIST_ITEM_MOVE_DOWN("Move item down"), + LIST_ITEM_INDENT("Indent item"), + LIST_ITEM_OUTDENT("Outdent item"), + BLOCK_CODE("Code"), + BLOCK_TABLE("Table"), + TABLE_ADD_ROW("Add row"), + TABLE_ADD_COLUMN("Add column"), + TABLE_REMOVE_ROW("Delete row"), + TABLE_REMOVE_COLUMN("Delete column"), + TABLE_HEADER_ROW("Header row"), + TABLE_CELL_OPTIONS("Cell options"), +} + +/** + * Looks up the text of an [EditorString] in the host's current language. The editor reads the + * instance through [LocalEditorStrings] and shows what it returns at that moment, so a host that + * changes language provides a new instance. + */ +public interface EditorStrings { + /** The text for [key]. */ + public fun text(key: EditorString): String + + /** The "Heading N" label for [level] (1 to 4): [EditorString.BLOCK_HEADING_LEVEL] with the level filled in. */ + public fun heading(level: Int): String + + /** The "N blocks selected" label for [count] blocks: [EditorString.SELECTION_COUNT] with the number filled in. */ + public fun selectionCount(count: Int): String +} + +/** English texts: the default when the host provides none, and what the editor's tests read. */ +internal object EnglishEditorStrings : EditorStrings { + override fun text(key: EditorString): String = key.english + + override fun heading(level: Int): String = EditorString.BLOCK_HEADING_LEVEL.english.replace("%d", level.toString()) + + override fun selectionCount(count: Int): String = + EditorString.SELECTION_COUNT.english.replace("%d", count.toString()) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockConverter.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockConverter.kt new file mode 100644 index 00000000..897a9c35 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockConverter.kt @@ -0,0 +1,55 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.EditorLogger +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.hasContent +import kotlinx.serialization.SerializationException + +/** + * Converter for transforming blocks to JSON and back. + * The format is written and read by [BlockJson]. + */ +public object BlockConverter { + /** + * Converts a list of blocks into a JSON representation. Blocks without content + * ([hasContent]) are left out, except an empty paragraph between other blocks: it is a blank + * line the user added on purpose and must survive closing and reopening the note. An empty + * code block or table is kept wherever it is: the user made it to fill in, its shape is their + * work. Blank blocks at the end (the placeholder paragraph a fresh note starts with) are dropped. + * @param blocks the list of blocks to serialize + * @return the JSON string + */ + public fun blocksToJson(blocks: List): String { + val kept = blocks.dropLastWhile { !it.isStored() }.filter { it.isStored() || it is Block.TextBlock } + return BlockJson.encode(kept) + } + + // A code block or table is the user's own making, empty or not. + private fun Block.isStored(): Boolean = hasContent() || this is Block.CodeBlock || this is Block.TableBlock + + /** + * Converts JSON into a list of blocks. + * @param json the JSON string to deserialize + * @return the list of blocks; empty for an empty string + * @throws SerializationException for malformed JSON or a block this version does not know + * (for example one written by a newer version); use [jsonToBlocksOrNull] to get null instead + */ + public fun jsonToBlocks(json: String): List { + if (json.isEmpty()) return emptyList() + return BlockJson.decode(json) + } + + /** + * Like [jsonToBlocks], but returns null when [json] cannot be read, so one unreadable note + * cannot crash a screen that lists or searches many notes. The failure is reported to [logger] + * as a fixed event, never with the note's content. + */ + public fun jsonToBlocksOrNull(json: String, logger: EditorLogger = EditorLogger.None): List? = try { + jsonToBlocks(json) + } catch (e: IllegalArgumentException) { + // SerializationException (malformed JSON, a wrong shape, an unknown block) is one; so are + // BlockType.valueOf failures. + logger.warn("BlockConverter", "Unreadable note content: ${e::class.simpleName}") + null + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt new file mode 100644 index 00000000..35082aa4 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt @@ -0,0 +1,223 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.normalizedLevels +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * Reads and writes the JSON of a note's blocks. The stored format is fixed by notes already on + * users' devices and in backups, so it is written key by key here, not derived from the model + * classes: `blockType` first, then the fields of that kind, with two-space indentation and the + * HTML-sensitive characters escaped (see [escapeHtmlSensitive]), exactly as Gson wrote it. + */ +internal object BlockJson { + private const val HEX = 16 + private const val UNICODE_ESCAPE_DIGITS = 4 + + // Lenient, as the Gson reader was: unquoted keys, single quotes and the like still load. + private val reader = Json { isLenient = true } + private val writer = Json { + prettyPrint = true + prettyPrintIndent = " " + } + + fun encode(blocks: List): String = + writer.encodeToString(JsonElement.serializer(), JsonArray(blocks.map(::toJson))).escapeHtmlSensitive() + + /** Blocks of [json]; `null` entries are dropped and `null` itself is an empty note. */ + fun decode(json: String): List = when (val root = reader.parseToJsonElement(json)) { + is JsonNull -> emptyList() + is JsonArray -> root.filter { it !is JsonNull }.map(::fromJson) + else -> throw SerializationException("Expected an array of blocks") + } + + private fun toJson(block: Block): JsonObject = buildJsonObject { + when (block) { + is Block.TextBlock -> { + put("blockType", "TEXT") + put("text", block.text.value) + } + + is Block.HBlock -> { + // Preserve the heading level (H1..H4) instead of collapsing to H1. + put("blockType", block.blockType.name) + put("text", block.text.value) + } + + is Block.QuoteBlock -> { + put("blockType", "QUOTE") + put("text", block.text.value) + } + + is Block.LinkBlock -> { + put("blockType", "LINK") + put( + "block", + buildJsonObject { + put("url", block.block.value.url) + put("title", block.block.value.title) + put("imageUrl", block.block.value.imageUrl) + }, + ) + } + + is Block.SeparatorBlock -> put("blockType", "SEPARATOR") + + is Block.ListBlock -> { + // Preserve the list kind (numbered, bulleted or checklist). + put("blockType", block.blockType.name) + // Empty list items are dropped. + put( + "items", + JsonArray( + block.items.value.filter { it.value.isNotBlank() } + .normalizedLevels(block.blockType) + .map { item -> + buildJsonObject { + put("value", item.value) + put("isCheck", item.isCheck) + // Written only for a nested row, so a flat list is stored byte for byte as + // before. An older app ignores both keys: it shows the row at the top + // level and drops them the next time it saves the note. + if (item.level > 0) put("level", item.level) + item.kind?.let { put("kind", it.name) } + } + }, + ), + ) + // Written only when set, so a note without a locked list is stored as before. An older + // app reads the other keys and ignores this one: it opens the list unlocked and drops + // the lock the next time it saves the note. + if (block.locked.value) put("isLocked", true) + } + + // Newer than any release: an older app reads neither and refuses the note (see BlockJsonFormatTest). + is Block.CodeBlock -> { + put("blockType", "CODE") + put("text", block.text.value) + } + + is Block.TableBlock -> { + put("blockType", "TABLE") + put("hasHeader", block.table.value.header) + put("rows", TableJson.rows(block.table.value)) + } + } + } + + @Suppress("CyclomaticComplexMethod") // One branch per kind of block. + private fun fromJson(element: JsonElement): Block { + val json = element.asObject() + val blockType = json.string("blockType") ?: throw SerializationException("Missing blockType") + + return when (blockType) { + "TEXT" -> Block.TextBlock(text = MutableStateFlow(json.string("text").orEmpty())) + + "H1", "H2", "H3", "H4" -> Block.HBlock( + text = MutableStateFlow(json.string("text").orEmpty()), + blockType = BlockType.valueOf(blockType), + ) + + "QUOTE" -> Block.QuoteBlock(text = MutableStateFlow(json.string("text").orEmpty())) + + "LINK" -> { + val link = json.obj("block") + Block.LinkBlock( + block = MutableStateFlow( + LinkDataBlock( + url = link?.string("url").orEmpty(), + title = link?.string("title").orEmpty(), + imageUrl = link?.string("imageUrl").orEmpty(), + ), + ), + ) + } + + "SEPARATOR" -> Block.SeparatorBlock() + + // "LIST" kept as a legacy alias for checklists. + "LIST", "LIST_CHECK", "LIST_NUMBER", "LIST_BULLET" -> listBlock(json, blockType) + + "CODE" -> Block.CodeBlock(text = MutableStateFlow(json.string("text").orEmpty())) + + "TABLE" -> Block.TableBlock( + MutableStateFlow(TableJson.decode(json.array("rows"), json.string("hasHeader"))), + ) + + else -> throw SerializationException("Unknown block type: $blockType") + } + } + + private fun listBlock(json: JsonObject, blockType: String): Block { + val listType = when (blockType) { + "LIST_NUMBER" -> BlockType.LIST_NUMBER + "LIST_BULLET" -> BlockType.LIST_BULLET + else -> BlockType.LIST_CHECK + } + val stored = json.array("items")?.map { item -> + val itemObject = item.asObject() + ItemListBlock( + value = itemObject.string("value").orEmpty(), + isCheck = itemObject.string("isCheck").toBoolean(), + // Optional, absent in notes written before lists could be nested. + level = itemObject.string("level")?.toIntOrNull() ?: 0, + kind = itemObject.string("kind") + ?.let { name -> BlockType.entries.firstOrNull { it.name == name } }, + ) + } + return Block.ListBlock( + items = MutableStateFlow( + if (stored.isNullOrEmpty()) listOf(ItemListBlock("")) else stored.normalizedLevels(listType), + ), + blockType = listType, + // Optional, absent in notes written before checklists could be locked. + locked = MutableStateFlow(json.string("isLocked").toBoolean()), + ) + } + + private fun JsonElement.asObject(): JsonObject = + this as? JsonObject ?: throw SerializationException("Expected an object") + + /** The member [key] as an object; absent is `null`, any other kind of value is unreadable. */ + private fun JsonObject.obj(key: String): JsonObject? = get(key)?.asObject() + + private fun JsonObject.array(key: String): JsonArray? = + get(key)?.let { it as? JsonArray ?: throw SerializationException("Expected an array at $key") } + + /** The text of a string, number or boolean member; absent is `null`, `null` or a container is unreadable. */ + private fun JsonObject.string(key: String): String? = get(key)?.let { value -> + if (value is JsonPrimitive && value !is JsonNull) { + value.content + } else { + throw SerializationException("Expected a plain value at $key") + } + } + + /** + * Gson wrote `<`, `>`, `&`, `=` and `'` as `\u003c` and so on (so a note could sit inside HTML), + * and the line separators U+2028/U+2029 too. Those characters occur only inside string + * values here, so replacing them in the finished text is safe. Readers treat both forms alike. + */ + private fun String.escapeHtmlSensitive(): String = buildString(length) { + for (c in this@escapeHtmlSensitive) { + when (c) { + '<', '>', '&', '=', '\'', '\u2028', '\u2029' -> + append("\\u").append(c.code.toString(HEX).padStart(UNICODE_ESCAPE_DIGITS, '0')) + + else -> append(c) + } + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/ImportBlocks.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/ImportBlocks.kt new file mode 100644 index 00000000..8d07a152 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/ImportBlocks.kt @@ -0,0 +1,42 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TableData +import kotlinx.coroutines.flow.MutableStateFlow + +/** Builders for the Encly blocks the parsers produce. */ +public object ImportBlocks { + private val PARAGRAPH_BREAK = Regex("""\n[ \t]*\n\s*""") + private val HEADINGS = listOf(BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4) + + /** A paragraph (TEXT block) with [value] as its text. */ + public fun text(value: String): Block = Block.TextBlock(text = MutableStateFlow(value)) + + /** [level] 1-4; deeper levels are H4. */ + public fun heading(value: String, level: Int): Block = Block.HBlock( + text = MutableStateFlow(value), + blockType = HEADINGS[level.coerceIn(1, HEADINGS.size) - 1], + ) + + /** A quote block with [value] as its text. */ + public fun quote(value: String): Block = Block.QuoteBlock(text = MutableStateFlow(value)) + + /** A list block of [type] (bulleted, numbered or checklist) holding [items]. */ + public fun list(type: BlockType, items: List): Block = + Block.ListBlock(items = MutableStateFlow(items), blockType = type) + + /** A code block with [value] as its text, spaces and line breaks exactly as given. */ + public fun code(value: String): Block = Block.CodeBlock(text = MutableStateFlow(value)) + + /** A table of [grid] (rows of cells; short rows are padded), its first row a header when [header]. */ + public fun table(grid: List>, header: Boolean): Block = + Block.TableBlock(table = MutableStateFlow(TableData.of(grid, header))) + + /** Blank-line separated paragraphs as TEXT blocks; line breaks stay inside their paragraph. */ + public fun paragraphs(text: String): List = text.trim().split(PARAGRAPH_BREAK) + .map { it.trimEnd() } + .filter { it.isNotBlank() } + .map(::text) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/InlineHtml.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/InlineHtml.kt new file mode 100644 index 00000000..aab098c9 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/InlineHtml.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.editor.codec + +/** + * Editor.js stores inline formatting as HTML inside a block's text (``, ``, ``, + * ``, ``, `
`, entities). Encly blocks are plain text, so the formatting is dropped + * and only what the user typed is kept: no raw markup reaches the vault. + */ +public 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 + private const val MAX_CODE_POINT = 0x10FFFF + private const val SUPPLEMENTARY_START = 0x10000 + private const val SURROGATE_BITS = 10 + private const val SURROGATE_MASK = 0x3FF + + // As char codes, not literals: the characters are invisible in source. + private const val NO_BREAK_SPACE_CODE = 0x00A0 + private const val ZERO_WIDTH_SPACE_CODE = 0x200B + private const val BYTE_ORDER_MARK_CODE = 0xFEFF + private val NO_BREAK_SPACE = Char(NO_BREAK_SPACE_CODE) + private val ZERO_WIDTH_SPACE = Char(ZERO_WIDTH_SPACE_CODE) + private val BYTE_ORDER_MARK = Char(BYTE_ORDER_MARK_CODE) + + private val NAMED = mapOf( + "amp" to "&", + "lt" to "<", + "gt" to ">", + "quot" to "\"", + "apos" to "'", + "nbsp" to " ", + ) + + /** + * [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. 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. + */ + public 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(), "") + .replace(BYTE_ORDER_MARK.toString(), "") + } + + private fun decode(entity: String): String? { + val codePoint = when { + // ENTITY allows at most six hex or seven decimal digits, which always fit an Int. + entity.startsWith("#x", ignoreCase = true) -> entity.substring(2).toInt(HEX) + + entity.startsWith("#") -> entity.substring(1).toInt() + + else -> return NAMED[entity] + } + return codePoint.takeIf(::isTextCodePoint)?.let(::codePointToString) + } + + /** NUL and lone surrogates are not text; such an entity is kept as typed. */ + private fun isTextCodePoint(codePoint: Int): Boolean = codePoint in 1..MAX_CODE_POINT && + codePoint !in Char.MIN_SURROGATE.code..Char.MAX_SURROGATE.code + + /** [codePoint] as a string: one char in the basic plane, a surrogate pair above it. */ + private fun codePointToString(codePoint: Int): String { + if (codePoint < SUPPLEMENTARY_START) return codePoint.toChar().toString() + val offset = codePoint - SUPPLEMENTARY_START + val high = (Char.MIN_HIGH_SURROGATE.code + (offset shr SURROGATE_BITS)).toChar() + val low = (Char.MIN_LOW_SURROGATE.code + (offset and SURROGATE_MASK)).toChar() + return charArrayOf(high, low).concatToString() + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt new file mode 100644 index 00000000..c02165c0 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt @@ -0,0 +1,380 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.MarkdownBlockParser +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.MAX_LIST_LEVEL +import com.pasich.encly.editor.model.normalizedLevels +import com.pasich.encly.editor.state.cellText + +/** + * 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; items indented under another (2 or 4 spaces, or a tab) nest, up to three + * levels, and a nested item may be of another kind than its list; + * `>` -> 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 becomes a CODE block, fence markers removed and every space kept. A GFM pipe table (a header row, a + * `---` delimiter row, then rows) becomes a TABLE block; `\|` is a bar inside a cell, the column alignment is + * dropped, and a header row of only empty cells is the table without a header. Inline markdown (`**bold**`, links) is + * kept as typed, which reads fine as plain text; HTML tags are removed and entities decoded + * ([InlineHtml]) outside code spans: the inline tags of real HTML (``, `
`, `
`, ...) + * go and their text stays, while other angle-bracket text (`Map`, ``) is kept. + */ +public object MarkdownBlockMapper : MarkdownBlockParser { + // Only the start is a pattern: a lazy title followed by an optional closing run backtracks quadratically on a + // long run of blanks, so the title and its closing hashes are cut by hand (see [Heading]). + private val HEADING_START = Regex("""^ {0,3}(#{1,6})[ \t]""") + private val CHECK = Regex("""^[ \t]*[-*+][ \t]+\[([ xX])\][ \t]+(.*)$""") + private val GLYPH_ITEM = Regex("""^[ \t]*([✅☑☐])[ \t]*(.*)$""") + private val CALLOUT = Regex("""^\[![A-Za-z-]+][+-]?[ \t]*""") + + // No `[` in a name or alias: otherwise every `[[` of a long run of brackets scans to the end of the line. + private val WIKI_LINK = Regex("""!?\[\[([^\[\]|\n]+)(?:\|([^\[\]\n]+))?]]""") + private val BULLET = Regex("""^[ \t]*[-*+][ \t]+(.*)$""") + + // Up to three digits: "2024. Report" is text. Encly numbers its items itself, so a start number is not kept. + private val NUMBER = Regex("""^[ \t]*\d{1,3}[.)][ \t]+(.*)$""") + private val QUOTE = Regex("""^ {0,3}>[ \t]?(.*)$""") + private val SEPARATOR = Regex("""^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$""") + private val FENCE = Regex("""^ {0,3}(`{3,}|~{3,}).*$""") + private val FENCE_CLOSE = Regex("""^ {0,3}(`{3,}|~{3,})[ \t]*$""") + private val TABLE_DELIMITER_CELL = Regex("""^:?-+:?$""") + private val CODE_SPAN = Regex("""`[^`\n]+`""") + private val AUTOLINK = Regex("""<((?:https?://|mailto:)[^\s<>]+|[^\s<>@/]+@[^\s<>@/]+)>""") + + /** + * [markdown], a whole document, as blocks: headings, quotes, lists, checklists, separators and paragraphs. + * Anything it does not understand stays as text, nothing is dropped. + */ + public fun toBlocks(markdown: String): List = Builder().apply { markdown.lines().forEach(::line) }.finish() + + /** + * The same line rules for text the user pastes into the editor, where nothing may be + * rewritten: HTML, wiki links and callout markers stay as typed, fence lines stay in their + * code paragraph, and blank list items are kept. [claim] may take a line whole as a block of + * its own (a lone web address); it runs before every other rule. With [plainFirstLine] the + * first line is plain text whatever it starts with, for a paste that continues a line. + * [toBlocks] is unchanged. + */ + internal fun pasteBlocks( + text: String, + plainFirstLine: Boolean = false, + claim: (String) -> Block? = { null }, + ): List = Builder(PasteRules(claim, plainFirstLine)).apply { text.lines().forEach(::line) }.finish() + + override fun parsePaste(text: String, plainFirstLine: Boolean, claim: (String) -> Block?): List = + pasteBlocks(text, plainFirstLine, claim) + + /** The columns [raw] is indented by: a space is one, a tab goes on to the next multiple of four. */ + private fun indentWidth(raw: String): Int = raw.takeWhile { it == ' ' || it == '\t' } + .fold(0) { width, char -> if (char == '\t') width + TAB_WIDTH - width % TAB_WIDTH else width + 1 } + + private const val TAB_WIDTH = 4 + + /** The indentation of each open level of the list being read. */ + private class IndentLevels { + private val widths = ArrayList() + + /** + * The level of an item indented by [width] columns: it nests under the item before it when indented more + * than that one, however many spaces (2, 4, a tab) the writer uses, and comes out to the nearest level it + * lines up with when indented less. + */ + fun levelOf(width: Int): Int { + while (widths.isNotEmpty() && widths.last() > width) widths.removeAt(widths.lastIndex) + if (widths.isEmpty() || widths.last() < width) widths += width + return (widths.size - 1).coerceAtMost(MAX_LIST_LEVEL) + } + + fun clear() = widths.clear() + } + + /** An ATX heading of [level] (1 to 6) with its [title], blanks and closing `#` run removed. */ + private class Heading(val level: Int, val title: String) { + companion object { + fun of(raw: String): Heading? { + val start = HEADING_START.find(raw) ?: return null + val title = raw.substring(start.value.length).trim(::isBlank) + val closing = title.length - title.trimEnd('#').length + val head = title.dropLast(closing) + // A closing run counts only after a blank: "# a#" keeps its hash, "# #" is a title. + val closed = closing > 0 && head.isNotEmpty() && isBlank(head.last()) + return Heading(start.groupValues[1].length, if (closed) head.trimEnd(::isBlank) else title) + } + + private fun isBlank(char: Char) = char == ' ' || char == '\t' + } + } + + private class PasteRules(val claim: (String) -> Block?, var plainFirstLine: Boolean) + + /** [text] with HTML removed outside code spans. */ + internal fun inline(text: String): String { + val linked = AUTOLINK.replace(text, "$1") + val out = StringBuilder() + var last = 0 + for (span in CODE_SPAN.findAll(linked)) { + out.append(plain(linked.substring(last, span.range.first))).append(span.value) + last = span.range.last + 1 + } + return out.append(plain(linked.substring(last))).toString() + } + + /** Wiki links as the text a reader sees (`[[Note|alias]]` -> alias), then no HTML. */ + private fun plain(text: String): String = InlineHtml.toPlainText( + WIKI_LINK.replace(text) { it.groupValues[2].ifEmpty { it.groupValues[1] } }, + knownTagsOnly = true, + ) + + @Suppress("TooManyFunctions") // One function per kind of block the lines add up to. + private class Builder(private val paste: PasteRules? = null) { + private val blocks = ArrayList() + private val paragraph = ArrayList() + private val quote = ArrayList() + private val code = ArrayList() + private val items = ArrayList() + private var listType: BlockType? = null + + private val indents = IndentLevels() + private var inFence = false + private var fenceMarker = "" + + // A line with a bar in it that may be the header of a table: told by the line that follows. + private var tableHeader: String? = null + private val tableRows = ArrayList>() + + fun line(raw: String) { + if (paste?.plainFirstLine == true) { + paste.plainFirstLine = false + if (raw.isNotBlank()) { + paragraph += raw.trimEnd() + return + } + } + if (paste == null && !inFence && tableLine(raw)) return + val fence = FENCE.matchEntire(raw) + when { + inFence && closesFence(raw) -> { + if (paste != null) code += raw + closeFence() + } + + inFence -> code += raw + + fence != null -> { + flushAll() + inFence = true + fenceMarker = fence.groupValues[1] + if (paste != null) code += raw + } + + raw.isBlank() -> { + // A blank line ends a paragraph or quote but not a list: loose lists stay one block. + flushParagraph() + flushQuote() + } + + else -> block(raw) + } + } + + // A fence ends only at a line of the same mark, at least as long as the one that opened it, and nothing else. + private fun closesFence(raw: String): Boolean { + val match = FENCE_CLOSE.matchEntire(raw) ?: return false + val mark = match.groupValues[1] + return mark[0] == fenceMarker[0] && mark.length >= fenceMarker.length + } + + fun finish(): List { + releaseHeader() + flushTable() + if (inFence) closeFence() + flushAll() + return blocks + } + + // Table rows. True when [raw] was taken as part of a table (or held as its possible header). + private fun tableLine(raw: String): Boolean { + val header = tableHeader + tableHeader = null + val headerCells = header?.let(::tableCells) + if (headerCells != null && isDelimiter(raw, headerCells.size)) { + flushAll() + tableRows += headerCells + return true + } + if (header != null) block(header, mayStartTable = false) + val isRow = tableRows.isNotEmpty() && raw.isNotBlank() && '|' in raw && !FENCE.matches(raw) + if (isRow) tableRows += tableCells(raw) else flushTable() + return isRow + } + + private fun releaseHeader() { + tableHeader?.let { block(it, mayStartTable = false) } + tableHeader = null + } + + private fun isDelimiter(raw: String, columns: Int): Boolean { + val cells = tableCells(raw) + return '|' in raw && cells.size == columns && cells.all { TABLE_DELIMITER_CELL.matches(it) } + } + + @Suppress("CyclomaticComplexMethod") // One branch per kind of line. + private fun block(raw: String, mayStartTable: Boolean = true) { + val heading = Heading.of(raw) + val check = CHECK.matchEntire(raw) + val glyph = GLYPH_ITEM.matchEntire(raw) + val bullet = BULLET.matchEntire(raw) + val number = NUMBER.matchEntire(raw) + val quoted = QUOTE.matchEntire(raw) + val claimed = paste?.run { claim(raw) } + when { + claimed != null -> { + flushAll() + blocks += claimed + } + + SEPARATOR.matches(raw) -> { + flushAll() + blocks += Block.SeparatorBlock() + } + + heading != null -> { + flushAll() + blocks += ImportBlocks.heading(readable(heading.title), heading.level) + } + + check != null -> item(BlockType.LIST_CHECK, check.groupValues[2], check.groupValues[1] != " ", raw) + + glyph != null -> item(BlockType.LIST_CHECK, glyph.groupValues[2], glyph.groupValues[1] != "☐", raw) + + bullet != null -> item(BlockType.LIST_BULLET, bullet.groupValues[1], false, raw) + + number != null -> item(BlockType.LIST_NUMBER, number.groupValues[1], false, raw) + + quoted != null -> { + flushParagraph() + flushList() + quote += + readable( + if (paste == + null + ) { + CALLOUT.replaceFirst(quoted.groupValues[1], "") + } else { + quoted.groupValues[1] + }, + ) + } + + // Indented text right after a list item continues that item. + listType != null && raw.first().isWhitespace() -> { + val last = items.removeAt(items.lastIndex) + items += last.copy(value = (last.value + " " + readable(raw.trim())).trim()) + } + + paste == null && mayStartTable && '|' in raw -> { + flushList() + flushQuote() + tableHeader = raw + } + + else -> { + flushList() + flushQuote() + paragraph += readable(raw.trimEnd()) + } + } + } + + /** [text] as the importer reads it; a paste keeps it as it is. */ + private fun readable(text: String): String = if (paste == null) inline(text) else text + + private fun item(type: BlockType, text: String, checked: Boolean, raw: String) { + flushParagraph() + flushQuote() + val width = indentWidth(raw) + var level = indents.levelOf(width) + // A new kind at the top level starts a list of its own; deeper, a sub-list of that kind. + if (listType != null && listType != type && level == 0) { + flushList() + level = indents.levelOf(width) + } + val kind = type.takeIf { level > 0 && it != listType } + listType = listType ?: type + items += ItemListBlock(value = readable(text).trim(), isCheck = checked, level = level, kind = kind) + } + + private fun closeFence() { + inFence = false + val text = code.joinToString("\n").trimEnd() + code.clear() + // A paste keeps code as a paragraph, fences and all; an import makes it a code block. + if (text.isNotBlank()) blocks += if (paste == null) ImportBlocks.code(text) else ImportBlocks.text(text) + } + + private fun flushTable() { + if (tableRows.isEmpty()) return + val rows = tableRows.toList() + tableRows.clear() + // A header row of only empty cells is how a table without a header is written. + val headless = rows.first().all { it.isEmpty() } + blocks += ImportBlocks.table(if (headless) rows.drop(1) else rows, header = !headless) + } + + // The cells of a pipe-table row: an outer bar dropped, `\|` a bar, `\\` a backslash, no line breaks or HTML. + private fun tableCells(row: String): List { + val cells = ArrayList() + val cell = StringBuilder() + var escaped = false + for (c in row.trim().removePrefix("|")) { + when { + escaped && (c == '|' || c == '\\') -> cell.append(c) + escaped -> cell.append('\\').append(c) + c == '|' -> cells.add(cell.toString().also { cell.clear() }) + c != '\\' -> cell.append(c) + } + escaped = c == '\\' && !escaped + } + // A last cell that is empty is only the closing bar, unless it was escaped away. + if (cell.isNotBlank() || cells.isEmpty()) cells.add(cell.toString()) + return cells.map { cellText(readable(it.trim())) } + } + + private fun flushAll() { + flushTable() + flushParagraph() + flushQuote() + flushList() + } + + private fun flushParagraph() { + if (paragraph.isEmpty()) return + val text = paragraph.joinToString("\n").trim() + paragraph.clear() + if (text.isNotEmpty()) blocks += ImportBlocks.text(text) + } + + private fun flushQuote() { + if (quote.isEmpty()) return + val text = quote.joinToString("\n").trim() + quote.clear() + if (text.isNotEmpty()) blocks += ImportBlocks.quote(text) + } + + private fun flushList() { + val type = listType ?: return + // A paste keeps an empty item (an empty checklist template); an import drops it. + val kept = (if (paste != null) items.toList() else items.filter { it.value.isNotBlank() }) + .normalizedLevels(type) + items.clear() + indents.clear() + listType = null + if (kept.isNotEmpty()) blocks += ImportBlocks.list(type, kept) + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/TableJson.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/TableJson.kt new file mode 100644 index 00000000..c59de45d --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/TableJson.kt @@ -0,0 +1,28 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.model.TableData +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive + +/** A table block in the stored JSON: `hasHeader`, and `rows`, an array of arrays of cell texts. */ +internal object TableJson { + /** The `rows` of [table]. */ + fun rows(table: TableData): JsonArray = + JsonArray(table.rows.map { row -> JsonArray(row.cells.map { JsonPrimitive(it.value) }) }) + + /** The table stored as [rows] (absent: one empty cell) and [hasHeader] (absent: with a header). */ + fun decode(rows: JsonArray?, hasHeader: String?): TableData { + val grid = rows?.map { row -> + (row as? JsonArray ?: throw SerializationException("Expected an array of cells")).map(::cellText) + }.orEmpty() + return TableData.of(grid, hasHeader?.toBoolean() ?: true) + } + + private fun cellText(cell: JsonElement): String { + if (cell !is JsonPrimitive || cell is JsonNull) throw SerializationException("Expected a cell text") + return cell.content + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistry.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistry.kt new file mode 100644 index 00000000..c5f688c0 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistry.kt @@ -0,0 +1,132 @@ +package com.pasich.encly.editor.focus + +import com.pasich.encly.editor.EditorLogger +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.state.FocusRequest +import com.pasich.encly.editor.state.canTakeFocus +import kotlinx.coroutines.delay + +/** How many frames' worth of retries a new block gets to compose before focusing it. */ +private const val FOCUS_RETRIES = 5 +private const val FOCUS_RETRY_DELAY_MS = 50L + +/** Retries before scrolling: a new block on screen needs no scroll, only its first frames. */ +private const val COMPOSE_WAITS = 1 + +private const val TAG = "BlockFocusRegistry" + +/** + * The editor's focus targets, by block id: how to focus each block's field and how to put its + * cursor at the end. It lives in composition (`rememberBlockFocusRegistry`, in `editor.ui`); the ViewModel only + * says *which* block to focus ([FocusRequest]), so no UI object outlives the screen. + * + * Keyed by block id, a request made for a block reaches that block even after blocks around it + * were inserted, removed, moved or replaced. [blocks] is the editor's live block list. + */ +public class BlockFocusRegistry( + private val logger: EditorLogger = EditorLogger.None, + private val blocks: () -> List, +) { + private val focusTargets = mutableMapOf Unit>() + private val caretCallbacks = mutableMapOf Unit>() + private val caretXCallbacks = mutableMapOf Unit>() + + /** + * Registers how to focus the field of block [blockId]. + * @return a function that removes this registration (not a newer one for the same block) + */ + internal fun registerFocusTarget(blockId: String, requestFocus: () -> Unit): () -> Unit = + focusTargets.register(blockId) { request -> + requestFocus() + val offset = request.caretOffset + val x = request.caretX + when { + x != null -> caretXCallbacks[blockId]?.invoke(x, request.fromBelow) + offset != null -> caretCallbacks[blockId]?.invoke(offset) + } + } + + /** + * Registers a block with several fields (a list): [requestFocus] focuses the item the + * request names, else its last field with the cursor at the end when asked for the end, + * else its first field. + */ + internal fun registerFieldsFocusTarget(blockId: String, requestFocus: (FocusRequest) -> Unit): () -> Unit = + focusTargets.register(blockId, requestFocus) + + /** + * Registers how to put the cursor of block [blockId]'s field at an offset ([Int.MAX_VALUE]: + * the end; the field clamps it to its text). + * @return a function that removes this registration (not a newer one for the same block) + */ + internal fun registerCaret(blockId: String, callback: (Int) -> Unit): () -> Unit = + caretCallbacks.register(blockId, callback) + + /** + * Registers how to put the cursor of block [blockId]'s field at a horizontal position, on its + * last line when it arrives from below (the callback's second argument), else on its first. + * @return a function that removes this registration (not a newer one for the same block) + */ + internal fun registerCaretAtX(blockId: String, callback: (Float, Boolean) -> Unit): () -> Unit = + caretXCallbacks.register(blockId, callback) + + /** Focuses the field of [blockId] now, the cursor at its end with [cursorToEnd]; see [focus]. */ + public fun focus(blockId: String, cursorToEnd: Boolean = false): Boolean = focus(FocusRequest(blockId, cursorToEnd)) + + /** + * Carries out [request] now; always issues it, even for the field that has focus already. + * Returns false when the field is not composed (yet). + */ + public fun focus(request: FocusRequest): Boolean { + val target = focusTargets[request.blockId] ?: return false + try { + target(request) + } catch (e: IllegalStateException) { + // The FocusRequester is not attached to a focusable node (a saved link). + logger.warn(TAG, "Focus request failed: ${e::class.simpleName}") + } + return true + } + + /** + * Carries out [request] as soon as the block's field is composed; gives up after a few frames. + * A block just added is composed within a frame or two where it is on screen; a block of a + * lazy list off screen is not composed at all, so [bringIntoView] then scrolls to it. + */ + public suspend fun focusWhenComposed(request: FocusRequest, bringIntoView: suspend (String) -> Unit = {}) { + if (focusWithin(request, attempts = 1 + COMPOSE_WAITS)) return + bringIntoView(request.blockId) + if (!focusWithin(request, attempts = FOCUS_RETRIES)) logger.warn(TAG, "Focus target never appeared") + } + + /** Tries [request] up to [attempts] times, a short wait apart; whether it was carried out. */ + private suspend fun focusWithin(request: FocusRequest, attempts: Int): Boolean { + repeat(attempts) { attempt -> + if (attempt > 0) delay(FOCUS_RETRY_DELAY_MS) + if (focus(request)) return true + } + return false + } + + /** + * The next block after [blockId] that has a field to put the cursor in (a separator or a saved + * link card has none); null when there is none. + */ + internal fun nextFocusableBlock(blockId: String): Block? { + val list = blocks() + val index = list.indexOfFirst { it.id == blockId } + return if (index >= 0) list.drop(index + 1).firstOrNull { it.canTakeFocus() } else null + } + + /** The focusable block above [blockId] (the nearest one with a field); null when there is none. */ + internal fun previousFocusableBlock(blockId: String): Block? { + val list = blocks() + val index = list.indexOfFirst { it.id == blockId } + return if (index > 0) list.take(index).lastOrNull { it.canTakeFocus() } else null + } +} + +private fun MutableMap.register(blockId: String, action: T): () -> Unit { + this[blockId] = action + return { if (this[blockId] === action) remove(blockId) } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/focus/CaretGeometry.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/CaretGeometry.kt similarity index 82% rename from app/src/main/java/com/pasich/encly/presentation/editor/focus/CaretGeometry.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/CaretGeometry.kt index 3e5a1485..c028908f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/focus/CaretGeometry.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/CaretGeometry.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.focus +package com.pasich.encly.editor.focus import androidx.compose.ui.geometry.Offset import androidx.compose.ui.text.TextLayoutResult @@ -9,7 +9,7 @@ import androidx.compose.ui.text.style.ResolvedTextDirection * visual line an offset is on, how far from the field's start it stands, and which offset a * horizontal position on a line points at. Abstract so the rules are testable without a layout. */ -interface CaretGeometry { +internal interface CaretGeometry { val lineCount: Int /** The length of the text this layout was measured for, which can lag a moment behind the field. */ @@ -23,7 +23,7 @@ interface CaretGeometry { } /** [this] layout as a [CaretGeometry]. */ -fun TextLayoutResult.geometry(): CaretGeometry = object : CaretGeometry { +internal fun TextLayoutResult.geometry(): CaretGeometry = object : CaretGeometry { override val lineCount: Int get() = this@geometry.lineCount override val textLength: Int get() = layoutInput.text.length override fun isRtl(offset: Int): Boolean = @@ -38,16 +38,16 @@ fun TextLayoutResult.geometry(): CaretGeometry = object : CaretGeometry { /** * Where the cursor stands among a field's visual lines: [onFirst] and [onLast] say whether Up or - * Down would leave the field, [x] is its horizontal position (null when unknown). + * Down would leave the field, `x` is its horizontal position (null when unknown). */ -data class CaretLine(val onFirst: Boolean, val onLast: Boolean, val x: Float?, val rtl: Boolean = false) +internal data class CaretLine(val onFirst: Boolean, val onLast: Boolean, val x: Float?, val rtl: Boolean = false) /** * The [CaretLine] of the cursor at [cursor] in a text of [length] characters laid out as * [geometry]. Without a layout (not measured yet, or measured for other text) only the ends of the * text count as the first and last line, as they did before layouts were consulted. */ -fun caretLine(geometry: CaretGeometry?, length: Int, cursor: Int): CaretLine { +internal fun caretLine(geometry: CaretGeometry?, length: Int, cursor: Int): CaretLine { // A layout measured for other text than the field holds now is stale: its lines say nothing. if (geometry == null || geometry.lineCount <= 0 || geometry.textLength != length) { return CaretLine(cursor <= 0, cursor >= length, null) @@ -58,11 +58,11 @@ fun caretLine(geometry: CaretGeometry?, length: Int, cursor: Int): CaretLine { } /** - * The offset to put the cursor at when it arrives in a field at horizontal position [x]: on the + * The offset to put the cursor at when it arrives in a field at horizontal position `x`: on the * field's last line when it comes from below ([fromBelow]), else on its first. Without a layout, the * end or the start of the text. */ -fun caretOffsetFor(geometry: CaretGeometry?, x: Float, fromBelow: Boolean, length: Int): Int { +internal fun caretOffsetFor(geometry: CaretGeometry?, x: Float, fromBelow: Boolean, length: Int): Int { if (geometry == null || geometry.lineCount <= 0) return if (fromBelow) length else 0 val line = if (fromBelow) geometry.lineCount - 1 else 0 return geometry.offsetAt(line, x).coerceIn(0, length) diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/focus/KeyboardUtils.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/KeyboardUtils.kt similarity index 98% rename from app/src/main/java/com/pasich/encly/presentation/editor/focus/KeyboardUtils.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/KeyboardUtils.kt index 972e7c35..49f659b9 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/focus/KeyboardUtils.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/KeyboardUtils.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.focus +package com.pasich.encly.editor.focus import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -13,7 +13,7 @@ import androidx.compose.ui.input.key.type /** * Utilities for handling keyboard events in the block editor. */ -object KeyboardUtils { +internal object KeyboardUtils { /** * Handles keyboard events for navigation between blocks. Install it with `onPreviewKeyEvent`, * so it sees a key before the text field does: the field acts on key-down (Enter types a diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt new file mode 100644 index 00000000..13808bbe --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt @@ -0,0 +1,114 @@ +package com.pasich.encly.editor.model + +import com.pasich.encly.editor.EditorString +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.uuid.Uuid + +/** What a block is. Stored as the `blockType` key in the JSON of a note, so the names must not change. */ +public enum class BlockType { + TEXT, + LIST_NUMBER, + LIST_CHECK, + LIST_BULLET, + QUOTE, + SEPARATOR, + LINK, + H1, + H2, + H3, + H4, + CODE, + TABLE, +} + +/** A fresh editor-session identity for a block or list item. Never persisted. */ +internal fun newBlockId(): String = Uuid.random().toString() + +/** + * Every block carries [id], a stable identity for the editor session: Compose keys its + * remembered state by it, undo and focus find their block by it, and two blocks with equal + * content (two separators) are still different blocks. It is not serialized; loading a note + * gives every block a new one. + */ +public sealed class Block { + /** The identity of the block for the editor session; see [Block]. */ + public abstract val id: String + + /** A paragraph. [placeholder] is the hint shown while it is empty. */ + public data class TextBlock( + override val text: MutableStateFlow = MutableStateFlow(""), + val placeholder: EditorString = EditorString.TEXT, + override val id: String = newBlockId(), + ) : Block(), + TextualBlock + + /** A heading; [blockType] is H1 to H4. */ + public data class HBlock( + override val text: MutableStateFlow = MutableStateFlow(""), + val blockType: BlockType, + override val id: String = newBlockId(), + ) : Block(), + TextualBlock + + /** A quotation. */ + public data class QuoteBlock( + override val text: MutableStateFlow = MutableStateFlow(""), + override val id: String = newBlockId(), + ) : Block(), + TextualBlock + + /** A link card; [block] holds the link. */ + public data class LinkBlock( + val block: MutableStateFlow = MutableStateFlow( + LinkDataBlock(), + ), + override val id: String = newBlockId(), + ) : Block() + + /** A horizontal rule. */ + public data class SeparatorBlock(override val id: String = newBlockId()) : Block() + + /** A bulleted list, numbered list or checklist (by [blockType]) of [items]; a checklist may be [locked]. */ + public data class ListBlock( + val items: MutableStateFlow> = MutableStateFlow( + listOf( + ItemListBlock(""), + ), + ), + val blockType: BlockType, + override val id: String = newBlockId(), + /** + * A locked checklist keeps its text out of reach (no focus, no keyboard) while its boxes + * still toggle. Stored as an optional `isLocked` key, only when true. + */ + val locked: MutableStateFlow = MutableStateFlow(false), + ) : Block() + + /** Code, set in a monospace face; it keeps its spaces and line breaks exactly as typed. */ + public data class CodeBlock( + override val text: MutableStateFlow = MutableStateFlow(""), + override val id: String = newBlockId(), + ) : Block(), + TextualBlock + + /** A table of plain-text cells; see [TableData]. */ + public data class TableBlock( + val table: MutableStateFlow = MutableStateFlow(TableData.empty()), + override val id: String = newBlockId(), + ) : Block() +} + +/** + * Whether [this] block is stored. The single rule for "blank": blocks that carry no content + * are left out when a note is serialized, and a draft of only such blocks is not saved. + */ +public fun Block.hasContent(): Boolean = when (this) { + is Block.TextBlock -> text.value.isNotBlank() + is Block.HBlock -> text.value.isNotBlank() + is Block.QuoteBlock -> text.value.isNotBlank() + is Block.ListBlock -> items.value.any { item -> item.value.isNotBlank() } + is Block.LinkBlock -> block.value.url.isNotBlank() + is Block.SeparatorBlock -> true + is Block.CodeBlock -> text.value.isNotBlank() + is Block.TableBlock -> table.value.rows.any { row -> row.cells.any { it.value.isNotBlank() } } +} diff --git a/app/src/main/java/com/pasich/encly/dynamicBlocks/factory/BlockFactory.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/dynamicBlocks/factory/BlockFactory.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt index 8ba38292..359eb902 100644 --- a/app/src/main/java/com/pasich/encly/dynamicBlocks/factory/BlockFactory.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt @@ -1,17 +1,14 @@ -package com.pasich.encly.dynamicBlocks.factory - -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +package com.pasich.encly.editor.model /** * Factory for creating blocks of different types. * Uses the "Factory Method" pattern to unify block creation. */ -object BlockFactory { +public object BlockFactory { /** * Creates a new, empty block of [blockType]. */ - fun createBlock(blockType: BlockType): Block = when (blockType) { + public fun createBlock(blockType: BlockType): Block = when (blockType) { BlockType.TEXT -> Block.TextBlock() BlockType.LIST_CHECK -> Block.ListBlock(blockType = BlockType.LIST_CHECK) BlockType.LIST_NUMBER -> Block.ListBlock(blockType = BlockType.LIST_NUMBER) @@ -23,5 +20,7 @@ object BlockFactory { BlockType.H4 -> Block.HBlock(blockType = BlockType.H4) BlockType.QUOTE -> Block.QuoteBlock() BlockType.SEPARATOR -> Block.SeparatorBlock() + BlockType.CODE -> Block.CodeBlock() + BlockType.TABLE -> Block.TableBlock() } } diff --git a/app/src/main/java/com/pasich/encly/dynamicBlocks/utils/BlockUtils.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt similarity index 75% rename from app/src/main/java/com/pasich/encly/dynamicBlocks/utils/BlockUtils.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt index 4a80f9db..011b52bb 100644 --- a/app/src/main/java/com/pasich/encly/dynamicBlocks/utils/BlockUtils.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt @@ -1,13 +1,10 @@ -package com.pasich.encly.dynamicBlocks.utils - -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.TextualBlock +package com.pasich.encly.editor.model /** * Utility class for working with blocks. * Contains common block operations to reduce code duplication. */ -object BlockUtils { +internal object BlockUtils { /** * Checks whether the block is empty. @@ -22,6 +19,8 @@ object BlockUtils { items.isEmpty() || (items.size == 1 && items[0].value.isEmpty()) } + is Block.TableBlock -> block.table.value.rows.all { row -> row.cells.all { it.value.isEmpty() } } + else -> false } } diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt new file mode 100644 index 00000000..0eb22e31 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.editor.model + +import kotlin.uuid.Uuid + +/** + * One list/checklist row. [id] keys the row's field state in Compose, so deleting or inserting + * a row cannot hand one row's text field to another. It survives `copy()` and is never + * serialized (the codec writes [value] and [isCheck], and [level] and [kind] only when set), so + * stored notes without sub-lists are unchanged. + * + * [level] is how deeply the row is nested under the rows above it (0 is the top, at most + * [MAX_LIST_LEVEL]). A nested row may be of another [kind] than its list (a checklist under a + * bullet); null means the list's own kind, and a row at level 0 is always of the list's kind. + * See `ListLevels.kt` for the rules that keep the levels valid. + */ +public data class ItemListBlock( + val value: String = "", + val isCheck: Boolean = false, + val id: String = Uuid.random().toString(), + val level: Int = 0, + val kind: BlockType? = null, +) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/LinkDataBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/LinkDataBlock.kt new file mode 100644 index 00000000..9103072f --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/LinkDataBlock.kt @@ -0,0 +1,13 @@ +package com.pasich.encly.editor.model + +/** + * What a link card shows: its [title], an optional [imageUrl], the [url] it opens, and whether loading its + * content failed ([isError]). + */ +public data class LinkDataBlock( + var title: String = "", + var imageUrl: String = "", + var url: String = "", + var isError: Boolean = false, + +) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ListLevels.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ListLevels.kt new file mode 100644 index 00000000..757e045a --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ListLevels.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.editor.model + +// The rules of nested list items. A list is flat in storage and in the editor's state: a row's +// [ItemListBlock.level] says how deep it sits, and its children are the rows after it with a +// deeper level. These functions work on rows in that order and never change their text. + +/** The deepest level a row can have: three levels in all (0, 1 and 2). */ +public const val MAX_LIST_LEVEL: Int = 2 + +private val LIST_KINDS = setOf(BlockType.LIST_CHECK, BlockType.LIST_BULLET, BlockType.LIST_NUMBER) + +/** Whether [this] is a kind of list (a checklist, a bulleted or a numbered list). */ +public fun BlockType.isListKind(): Boolean = this in LIST_KINDS + +/** The kind this row shows inside a list of [listType]: its own at a nested level, else the list's. */ +public fun ItemListBlock.kindIn(listType: BlockType): BlockType = if (level > 0) kind ?: listType else listType + +/** + * The rows with valid levels: the first row at 0, every other at most one deeper than the row + * before it and at most [MAX_LIST_LEVEL], and a [ItemListBlock.kind] only where it matters (a nested row + * whose kind differs from [listType]). The same list when nothing needs fixing. Cutting a list, + * removing a row or reading a damaged note can leave levels that are not valid; this is the one place + * they are repaired. + */ +public fun List.normalizedLevels(listType: BlockType): List { + var previous = -1 + var changed = false + val fixed = map { item -> + val level = item.level.coerceIn(0, minOf(MAX_LIST_LEVEL, previous + 1)) + val kind = item.kind.takeIf { level > 0 && it != listType && it?.isListKind() == true } + previous = level + if (level == item.level && kind == item.kind) { + item + } else { + changed = true + item.copy(level = level, kind = kind) + } + } + return if (changed) fixed else this +} + +/** One past the last row of the subtree of the row at [index]: the rows after it that are deeper. */ +public fun List.subtreeEnd(index: Int): Int { + var end = index + 1 + while (end < size && this[end].level > this[index].level) end++ + return end +} + +/** + * The positions of the rows that share a parent with the row at [index] (itself included), in order. Each + * starts a subtree of the same level, which moves as a whole. + */ +public fun List.siblingStarts(index: Int): List { + val level = this[index].level + var from = index + while (from > 0 && this[from - 1].level >= level) from-- + var to = index + 1 + while (to < size && this[to].level >= level) to++ + return (from until to).filter { this[it].level == level } +} + +/** + * Each row's number in its numbered run, 0 for a row that is not numbered. A run is the rows of one level + * and one parent that are numbered one after another: it starts again at 1 under every new parent, and after a + * row of another kind at its level. + */ +public fun List.numbers(listType: BlockType): List { + val counter = IntArray(MAX_LIST_LEVEL + 1) + val runKind = arrayOfNulls(MAX_LIST_LEVEL + 1) + return map { item -> + val level = item.level.coerceIn(0, MAX_LIST_LEVEL) + val kind = item.kindIn(listType) + for (deeper in level + 1..MAX_LIST_LEVEL) { + counter[deeper] = 0 + runKind[deeper] = null + } + counter[level] = if (runKind[level] == kind) counter[level] + 1 else 1 + runKind[level] = kind + if (kind == BlockType.LIST_NUMBER) counter[level] else 0 + } +} + +/** [this] row placed where [other] is: at its level and of its kind. */ +internal fun ItemListBlock.alongside(other: ItemListBlock): ItemListBlock = copy(level = other.level, kind = other.kind) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ListMoves.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ListMoves.kt new file mode 100644 index 00000000..cf68db83 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ListMoves.kt @@ -0,0 +1,74 @@ +package com.pasich.encly.editor.model + +// Changing the levels and the places of list items: indenting, moving among siblings, finding where a drag landed, and +// making a sub-list of another kind. Each takes rows with valid levels (see normalizedLevels) and returns new rows. + +/** + * The rows with the subtree of the row at [index] indented one level ([deeper]) or outdented. Null when it + * cannot: the first row and a row already one deeper than the row before it cannot go deeper, nothing + * goes past [MAX_LIST_LEVEL], and a row at level 0 cannot come out. [this] must have valid levels. + */ +public fun List.shifted(index: Int, deeper: Boolean, listType: BlockType): List? { + val item = getOrNull(index) + val end = if (item == null) index else subtreeEnd(index) + val allowed = item != null && if (deeper) { + index > 0 && item.level <= this[index - 1].level && (index until end).none { this[it].level >= MAX_LIST_LEVEL } + } else { + item.level > 0 + } + val delta = if (deeper) 1 else -1 + return if (!allowed) { + null + } else { + mapIndexed { at, row -> if (at in index until end) row.copy(level = row.level + delta) else row } + .normalizedLevels(listType) + } +} + +/** + * The rows with the subtree of the row at [index] moved [offset] places among its siblings (up is + * negative), and the row's new position. Null when there is no sibling that far. [this] must have valid levels. + */ +public fun List.movedBy(index: Int, offset: Int): Pair, Int>? { + if (index !in indices || offset == 0) return null + val siblings = siblingStarts(index) + val at = siblings.indexOf(index) + val target = at + offset + return if (target !in siblings.indices) { + null + } else { + val end = subtreeEnd(index) + val moved = subList(index, end) + val rest = take(index) + drop(end) + val insertAt = if (target < at) siblings[target] else subtreeEnd(siblings[target]) - moved.size + (rest.take(insertAt) + moved + rest.drop(insertAt)) to insertAt + } +} + +/** How many siblings from the row at [index] the position [y] is: negative above it, positive below. */ +public fun List.siblingOffsetAt( + index: Int, + rowTops: List, + rowBottoms: List, + y: Float, +): Int { + if (rowTops.size < size || rowBottoms.size < size) return 0 + val siblings = siblingStarts(index) + // The sibling whose rows (with its children) the position is in, else the nearest end. + val target = siblings.indices.firstOrNull { s -> + y in rowTops[siblings[s]]..rowBottoms[subtreeEnd(siblings[s]) - 1] + } ?: if (y < rowTops[siblings.first()]) 0 else siblings.lastIndex + return target - siblings.indexOf(index) +} + +/** The rows with the kind of the row at [index] and its siblings set to [kind]; null when that changes nothing. */ +public fun List.withSiblingsOfKind( + index: Int, + kind: BlockType, + listType: BlockType, +): List? { + val item = getOrNull(index) + if (item == null || item.level == 0 || item.kindIn(listType) == kind) return null + val siblings = siblingStarts(index).toSet() + return mapIndexed { at, row -> if (at in siblings) row.copy(kind = kind) else row }.normalizedLevels(listType) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TableData.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TableData.kt new file mode 100644 index 00000000..fd242286 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TableData.kt @@ -0,0 +1,107 @@ +package com.pasich.encly.editor.model + +/** + * One cell of a [TableData]. [id] keys the cell's field state in Compose, so adding or removing a row or + * column can never hand one cell's text field to another. It survives `copy()` and is never serialized. + */ +public data class TableCell(val value: String = "", val id: String = newBlockId()) + +/** One row of a [TableData]: always as many [cells] as the table has columns. */ +public data class TableRow(val cells: List) + +/** + * The content of a table block: [rows] of plain-text cells (at least 1 x 1, every row equally wide) and whether + * the first row is a [header]. Immutable: every edit makes a new value, which is what the history stores. The + * operations that would leave less than one row or column do nothing. A grid that is not rectangular, or has no + * cell, is refused at construction ([of] pads one instead), so no cell lookup can meet a missing one. + */ +public data class TableData(val rows: List, val header: Boolean = true) { + init { + require(rows.isNotEmpty() && rows[0].cells.isNotEmpty()) { "A table has at least one cell" } + require(rows.all { it.cells.size == rows[0].cells.size }) { "Every row of a table is as wide as the first" } + } + + /** How many rows the table has. */ + val rowCount: Int get() = rows.size + + /** How many columns the table has. */ + val columnCount: Int get() = rows[0].cells.size + + /** The text of the cell at [row], [column]; empty outside the table. */ + public fun valueAt(row: Int, column: Int): String = + rows.getOrNull(row)?.let { it.cells.getOrNull(column)?.value }.orEmpty() + + /** Where the cell [cellId] is, as row and column; null when it is not in the table. */ + public fun locate(cellId: String): Pair? { + rows.forEachIndexed { r, row -> + val c = row.cells.indexOfFirst { it.id == cellId } + if (c >= 0) return r to c + } + return null + } + + /** The cell [cellId]; null when it is not in the table. */ + public fun cell(cellId: String): TableCell? = rows.firstNotNullOfOrNull { row -> + row.cells.firstOrNull { + it.id == + cellId + } + } + + /** This table with the text of cell [cellId] set to [value]; the same table when there is no such cell. */ + public fun withCell(cellId: String, value: String): TableData = + copy(rows = rows.map { row -> TableRow(row.cells.map { if (it.id == cellId) it.copy(value = value) else it }) }) + + /** This table with a new empty row at [at] (0 to [rowCount]; clamped). */ + public fun addRow(at: Int): TableData { + val fresh = TableRow(List(columnCount) { TableCell() }) + val index = at.coerceIn(0, rowCount) + return copy(rows = rows.take(index) + fresh + rows.drop(index)) + } + + /** This table without row [at]; unchanged when it is the only row or there is no such row. */ + public fun removeRow(at: Int): TableData = + if (rowCount <= 1 || at !in rows.indices) this else copy(rows = rows.filterIndexed { i, _ -> i != at }) + + /** This table with a new empty column at [at] (0 to [columnCount]; clamped). */ + public fun addColumn(at: Int): TableData { + val index = at.coerceIn(0, columnCount) + return copy( + rows = rows.map { row -> TableRow(row.cells.take(index) + TableCell() + row.cells.drop(index)) }, + ) + } + + /** This table without column [at]; unchanged when it is the only column or there is no such column. */ + public fun removeColumn(at: Int): TableData = if (columnCount <= 1 || at !in 0 until columnCount) { + this + } else { + copy(rows = rows.map { row -> TableRow(row.cells.filterIndexed { i, _ -> i != at }) }) + } + + /** This table with the header row switched on or off. */ + public fun withHeader(on: Boolean): TableData = copy(header = on) + + /** A copy that shares no cell identity with this table. */ + internal fun duplicate(): TableData = + copy(rows = rows.map { row -> TableRow(row.cells.map { it.copy(id = newBlockId()) }) }) + + /** Ways to make a table. */ + public companion object { + /** A new table of [rows] x [columns] empty cells (each at least 1), with a header row. */ + public fun empty(rows: Int = 2, columns: Int = 2): TableData = TableData( + List(rows.coerceAtLeast(1)) { TableRow(List(columns.coerceAtLeast(1)) { TableCell() }) }, + ) + + /** + * A table of [grid]'s rows, short rows padded with empty cells to the widest one; an empty grid gives a + * 1 x 1 table. + */ + public fun of(grid: List>, header: Boolean = true): TableData { + val width = grid.maxOfOrNull { it.size }?.coerceAtLeast(1) ?: 1 + val rows = grid.ifEmpty { listOf(emptyList()) }.map { row -> + TableRow((row + List(width - row.size) { "" }).map { TableCell(it) }) + } + return TableData(rows, header) + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TextualBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TextualBlock.kt new file mode 100644 index 00000000..a90885dd --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TextualBlock.kt @@ -0,0 +1,15 @@ +package com.pasich.encly.editor.model + +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Interface for blocks that contain text. + * Used for uniform handling of text blocks of different types. + */ +public interface TextualBlock { + /** The text of the block, live. */ + public val text: MutableStateFlow +} + +/** Whether the block's text is empty. */ +internal fun TextualBlock.isEmpty(): Boolean = text.value.isEmpty() diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt new file mode 100644 index 00000000..df58e948 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt @@ -0,0 +1,48 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.newBlockId +import kotlinx.coroutines.flow.MutableStateFlow + +/** Duplicates the block the user works on (see [duplicateBlockOf]). */ +public fun BlockEditorState.duplicateInteracted() { + duplicateBlockOf(blocks[selection.interactedIndex].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. + */ +public fun BlockEditorState.duplicateBlockOf(blockId: String): Boolean { + val index = indexOf(blockId) + val block = blocks.getOrNull(index) ?: return false + val copy = block.duplicate() + 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.CodeBlock -> copy(text = MutableStateFlow(text.value), id = newBlockId()) + + is Block.TableBlock -> copy(table = MutableStateFlow(table.value.duplicate()), id = newBlockId()) + + is Block.ListBlock -> copy( + items = MutableStateFlow(items.value.map { it.copy(id = newBlockId()) }), + id = newBlockId(), + locked = MutableStateFlow(locked.value), + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt similarity index 85% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt index ccb1d5c7..32f1ac7d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt @@ -1,10 +1,10 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 com.pasich.encly.dynamicBlocks.TextualBlock +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.TextualBlock import kotlinx.coroutines.flow.MutableStateFlow // How writing moves text between blocks: Enter splits a block at the cursor, a paste of several @@ -17,10 +17,11 @@ import kotlinx.coroutines.flow.MutableStateFlow * the block at the cursor, the text after it going to a new paragraph; pasted lines become blocks. * Returns the text the field keeps; null when the block cannot take it (the break stays text). */ -fun BlockEditorState.breakLine(blockId: String, lineBreak: LineBreak): String? { +public fun BlockEditorState.breakLine(blockId: String, lineBreak: LineBreak): String? { val index = indexOf(blockId) val block = blocks.getOrNull(index) - if (block !is TextualBlock) return null + // Code keeps its line breaks as text: Enter and a paste never split it. + if (block !is TextualBlock || block is Block.CodeBlock) return null return if (lineBreak.isEnter) { enter(index, block, lineBreak.head, lineBreak.tail) } else { @@ -69,7 +70,7 @@ private fun BlockEditorState.enter(index: Int, block: TextualBlock, head: String * under a paragraph takes the text of the one above instead (see mergeIntoCurrent). Returns false * when there is nothing to do (the first block, or an empty paragraph, which the caller removes). */ -fun BlockEditorState.backspaceAtStart(blockId: String): Boolean { +public fun BlockEditorState.backspaceAtStart(blockId: String): Boolean { val index = indexOf(blockId) return when (val block = blocks.getOrNull(index)) { // An empty paragraph under a paragraph merges the upper one into itself too (see mergeIntoCurrent), @@ -78,7 +79,7 @@ fun BlockEditorState.backspaceAtStart(blockId: String): Boolean { (block.text.value.isNotEmpty() || blocks.getOrNull(index - 1) is Block.TextBlock) && joinToPrevious(index, block.text.value) - is Block.HBlock, is Block.QuoteBlock -> { + is Block.HBlock, is Block.QuoteBlock, is Block.CodeBlock -> { val text = (block as TextualBlock).text.value val paragraph = Block.TextBlock(MutableStateFlow(text)) batch { replaceBlock(index, paragraph) } @@ -107,11 +108,13 @@ private fun BlockEditorState.joinToPrevious(index: Int, text: String): Boolean { is Block.QuoteBlock -> joinText(index, previous.text, text) + is Block.CodeBlock -> joinText(index, previous.text, text) + // A locked checklist takes no edits: Backspace at the start of the block below it does nothing. is Block.ListBlock -> if (!previous.locked.value) joinToList(index, previous, text) - // The first block, or a link card, which has no text to join. - is Block.LinkBlock, null -> return false + // The first block, or a link card or a table, which has no text to join. + is Block.LinkBlock, is Block.TableBlock, null -> return false } return true } @@ -122,12 +125,16 @@ private fun BlockEditorState.joinToList(index: Int, list: Block.ListBlock, text: val last = items.lastOrNull() val paragraph = blocks[index].id batch { - val joined = last?.let { items.dropLast(1) + it.copy(value = it.value + text) } ?: listOf(ItemListBlock(text)) + val joined = if (last == null) { + listOf(ItemListBlock(text)) + } else { + items.dropLast(1) + last.copy(value = last.value + text) + } setValue(list.id, list.items, joined) removeBlock(index) } val item = list.items.value.last() - val join = last?.value?.length ?: 0 + val join = last?.value.orEmpty().length attachCaretToLastStep(CaretTarget(paragraph, null, 0), CaretTarget(list.id, item.id, join)) selection.focusAt(index - 1, caret = join, itemId = item.id) } @@ -166,7 +173,7 @@ private fun BlockEditorState.joinText(index: Int, previousText: MutableStateFlow * block with the text after the mark. The typing stays its own undo step, so undo first brings * back the paragraph with the mark as typed. */ -fun BlockEditorState.applyShortcut(blockId: String, typed: String, shortcut: Shortcut): Boolean { +public fun BlockEditorState.applyShortcut(blockId: String, typed: String, shortcut: Shortcut): Boolean { val index = indexOf(blockId) val block = blocks.getOrNull(index) as? Block.TextBlock ?: return false changeValue(block.id, block.text, typed, mergeable = true) @@ -175,8 +182,7 @@ fun BlockEditorState.applyShortcut(blockId: String, typed: String, shortcut: Sho } else { val replacement = shortcutBlock(shortcut, typed.removePrefix(shortcut.marker)) batch { replaceBlock(index, replacement) } - val item = (replacement as? Block.ListBlock)?.items?.value?.firstOrNull() - selection.focusAt(index, caret = 0, itemId = item?.id) + selection.focusAt(index, caret = 0, itemId = replacement.firstItemId()) } return true } @@ -185,7 +191,7 @@ fun BlockEditorState.applyShortcut(blockId: String, typed: String, shortcut: Sho * [pasted], a lone web address, pasted into the empty paragraph [blockId]: it becomes a link card * ([link]), and a paragraph follows it to go on writing in. Undo turns it back into the text. */ -fun BlockEditorState.pasteLink(blockId: String, pasted: String, link: LinkDataBlock): Boolean { +public fun BlockEditorState.pasteLink(blockId: String, pasted: String, link: LinkDataBlock): Boolean { val index = indexOf(blockId) val block = blocks.getOrNull(index) as? Block.TextBlock if (block == null || block.text.value.isNotEmpty()) return false diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt new file mode 100644 index 00000000..a2e11cb2 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt @@ -0,0 +1,305 @@ +package com.pasich.encly.editor.state + +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.MarkdownBlockParser +import com.pasich.encly.editor.codec.MarkdownBlockMapper +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockUtils +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** How a block is removed: by its delete button, or by Backspace in an empty one (in a list: its last item). */ +public enum class BlockRemoveAction { REMOVE, REMOVE_BACKSPACE, REMOVE_BACKSPACE_LIST } + +/** + * The block editor's content: the blocks, their undo/redo history ([BlockOperations]) and + * which block the user works on ([selection]). Plain state without coroutines or UI objects, + * so it is testable on the JVM. The ViewModel decides *whether* an edit is allowed (a locked or + * read-only editor); this class decides *how* it is applied. + * + * Invariant: the note always has at least one block. + */ +@Suppress("TooManyFunctions") // The editor's single entry point: one function per kind of edit. +public class BlockEditorState( + /** Reads pasted Markdown for Smart Paste. */ + public val markdown: MarkdownBlockParser = MarkdownBlockMapper, + clock: () -> Long = ::currentTimeMillis, +) { + private val _blocks = SnapshotStateList().apply { add(emptyNoteBlock()) } + + /** The blocks of the note, in order; always at least one. Snapshot state: a composable reading it recomposes. */ + public val blocks: List get() = _blocks + + private val history = BlockOperations(_blocks, clock) + + /** Which block the user is working in, and focus requests for the blocks. */ + public val selection: BlockSelection = BlockSelection(_blocks) + + /** The blocks picked as whole blocks (block selection mode); see [SelectedBlocks]. */ + public val pickedBlocks: SelectedBlocks = SelectedBlocks(_blocks) + + /** The text selection that runs across blocks; see [TextRangeSelection]. */ + public val textRange: TextRangeSelection = TextRangeSelection(_blocks) + + init { + selection.onFieldFocused = { + pickedBlocks.clear() + textRange.clear() + } + } + + /** The slash picker: closes at any structural change, so it never outlives its paragraph. */ + public val slash: SlashPicker = SlashPicker() + + /** Find in this note: transient, read-only, forgotten when the note is loaded or the vault locks. */ + public val find: NoteFind = NoteFind() + + // What the last copy or cut put on the clipboard; in memory only, forgotten when a note is loaded (the lock too). + private var copied: CopiedBlocks? = null + + private val _canUndo = MutableStateFlow(false) + + /** Whether there is an edit to undo. */ + public val canUndo: StateFlow = _canUndo.asStateFlow() + + private val _canRedo = MutableStateFlow(false) + + /** Whether there is an undone edit to redo. */ + public val canRedo: StateFlow = _canRedo.asStateFlow() + + /** Emits when blocks are added, removed or moved, or any block's content changes. */ + @OptIn(ExperimentalCoroutinesApi::class) + public val contentChanges: Flow = snapshotFlow { _blocks.toList() }.flatMapLatest { current -> + val contentFlows = current.mapNotNull { it.contentFlow() } + // combine() of no flows never emits; a note of only separators still changes. + if (contentFlows.isEmpty()) flowOf(Unit) else combine(contentFlows) { } + } + + /** + * Shows a stored note's [loaded] blocks in place of the current ones. The history starts + * over: undo never reaches back into what was shown before (nor keeps it in memory). + */ + public fun load(loaded: List) { + slash.dismiss() + find.close() + pickedBlocks.clear() + textRange.clear() + copied = null + _blocks.clear() + _blocks.addAll(loaded) + history.clear() + afterChange() + } + + /** + * Remembers that a copy or cut put [text] on the clipboard for [blocks] (the text as the host rendered them): a + * later paste of exactly that text inserts these blocks again, tables, code and nested lists intact, instead of + * reading the text. The blocks are copied now, so editing or removing the originals changes nothing. Kept in + * memory only, until the next copy or until a note is loaded (which the vault lock does with an empty one). + */ + public fun rememberCopied(text: String, blocks: List) { + copied = if (text.isEmpty() || blocks.isEmpty()) null else CopiedBlocks(text, blocks.map { it.duplicate() }) + } + + /** New copies of the blocks of the last copy or cut when it put exactly [text] on the clipboard, else null. */ + internal fun copiedFor(text: String): List? { + val remembered = copied + return if (remembered != null && remembered.text == text) remembered.blocks.map { it.duplicate() } else null + } + + /** + * Adds [block] after the focused block (or the one the user last worked on) and focuses it. + * An empty first text block at that point is replaced instead, in one undo step, so undo can + * never leave the note without blocks. + */ + public fun addBlock(block: Block) { + slash.dismiss() + val anchor = selection.workingIndex.coerceIn(-1, _blocks.lastIndex) + val replacesEmptyFirst = block !is Block.TextBlock && _blocks.isEmptyFirstTextBlock(anchor) + + val target = if (replacesEmptyFirst) anchor else anchor + 1 + if (replacesEmptyFirst) history.replaceBlock(anchor, block) else history.addBlock(target, block) + afterChange() + + selection.focusAt(target) + } + + /** + * Removes [block] if [action] allows it: one block always remains, and Backspace removes + * only an empty block. With [refocus], the block above (or the first focusable) takes focus. + */ + public fun removeBlock(block: Block, action: BlockRemoveAction, refocus: Boolean = true) { + val index = _blocks.indexOfFirst { it.id == block.id } + val allowed = when (action) { + BlockRemoveAction.REMOVE, BlockRemoveAction.REMOVE_BACKSPACE_LIST -> _blocks.size > 1 + BlockRemoveAction.REMOVE_BACKSPACE -> _blocks.size > 1 && BlockUtils.isBlockEmpty(block) + } + if (index == -1 || !allowed) return + + slash.dismiss() + history.removeBlock(index) + afterChange() + if (refocus) selection.focusAt(_blocks.previousFocusableIndex(index), cursorToEnd = true) + } + + /** + * Replaces the block at [index] with [newBlock] and focuses the new field. A converted block keeps + * its text, so its cursor stays where it was: in the same list item, or on the same line. + */ + public fun replaceBlock(index: Int, newBlock: Block): Boolean { + if (index !in _blocks.indices) return false + slash.dismiss() + val old = _blocks[index] + val caret = selection.caretOf(old.id) + val item = selection.itemOf(old.id) + // Where the cursor was, and where it is carried to; null for either when there is none. + val carried = caret?.let { c -> + carriedCaret(old, newBlock, c, item)?.let { carriedTo -> CaretTarget(old.id, item, c) to carriedTo } + } + history.replaceBlock(index, newBlock) + // Undone, the old block comes back with the cursor where it was; redone, where it was carried. + carried?.let { (from, to) -> attachCaretToLastStep(from, to) } + afterChange() + val carriedTo = carried?.second + selection.focusAt(index, caret = carriedTo?.caret, itemId = carriedTo?.itemId) + return true + } + + /** Moves the block at [fromIndex] to [toIndex]; it keeps focus. */ + internal 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) + } + + /** + * Sets [state], a field of block [blockId], to [newValue] as one undoable edit. [mergeable] + * edits (typing) in quick succession collapse into one undo step. + */ + public fun changeValue( + blockId: String, + state: MutableStateFlow, + newValue: T, + mergeable: Boolean, + step: TypingStep? = null, + ) { + history.changeValue(blockId, state, newValue, mergeable, step) + afterChange() + } + + /** Makes the operations of [build] as one undo step (see [BlockOperations.batch]). */ + internal fun batch(build: BlockOperations.Batch.() -> Unit) { + slash.dismiss() + history.batch(build) + afterChange() + } + + /** Undoes the last edit (a run of typing is one edit); does nothing when there is none. */ + public fun undo(): Unit = moveHistory(undo = true) + + /** Redoes the last undone edit; does nothing when there is none. */ + public fun redo(): Unit = moveHistory(undo = false) + + /** + * Undo or redo. The cursor goes where the step itself says ([CaretTarget], from what its fields + * held before and after, or the merge's own record); a step with no text in it leaves the cursor + * alone, and a block that is gone sends it to the nearest one above. + */ + private fun moveHistory(undo: Boolean) { + slash.dismiss() + pickedBlocks.clear() + textRange.clear() + val focused = selection.focusedBlockId.value + val fields = history.fieldsOfNext(undo) + val before = fields.map { it.second.value } + if (!(if (undo) history.undo() else history.redo())) return + afterChange() + if (focused == null) return + val step = history.lastMovedCaret() + val target = if (step != null) { + if (undo) step.before else step.after + } else { + fields.indices.firstNotNullOfOrNull { caretTargetOf(fields[it].first, before[it], fields[it].second.value) } + } + val index = target?.let { t -> blocks.indexOfFirst { it.id == t.blockId } } ?: -1 + if (target != null && index >= 0 && blocks[index].canTakeFocus()) { + selection.focusAt(index, caret = target.caret, itemId = target.itemId) + } else { + selection.keepFocus(blocks, focused) + } + } + + /** The step recorded last puts the cursor at [before] when undone and at [after] when redone. */ + internal fun attachCaretToLastStep(before: CaretTarget, after: CaretTarget) = + history.attachCaret(BlockOperations.CaretStep(before, after)) + + private fun afterChange() { + if (_blocks.isEmpty()) _blocks.add(emptyNoteBlock()) + pickedBlocks.prune() + _canUndo.value = history.canUndo() + _canRedo.value = history.canRedo() + } +} + +/** The block a note without content shows. */ +private fun emptyNoteBlock(): Block = Block.TextBlock(placeholder = EditorString.PRESS_TO_EDIT) + +private fun List.isEmptyFirstTextBlock(index: Int): Boolean { + val block = getOrNull(index) + return index == 0 && block is Block.TextBlock && block.text.value.isEmpty() +} + +/** The content of [this] block, as a flow; null for a block without content (a separator). */ +private fun Block.contentFlow(): Flow? = when (this) { + is Block.TextBlock -> text + + is Block.HBlock -> text + + is Block.QuoteBlock -> text + + is Block.LinkBlock -> block.map { it.url } + + // The lock is content too: locking a list must be saved like typing in it. + is Block.ListBlock -> combine(items, locked) { list, locked -> list to locked } + + is Block.SeparatorBlock -> null + + is Block.CodeBlock -> text + + is Block.TableBlock -> table +} + +/** + * Undo and redo may rebuild the field the cursor was in (a list item, a block that was replaced), + * and a field that leaves composition takes the keyboard with it. When a field had focus + * ([blockId]), it is asked for again: the same block if it is still there, else the nearest one above. + */ +private fun BlockSelection.keepFocus(blocks: List, blockId: String) { + val index = blocks.indexOfFirst { it.id == blockId } + if (index >= 0 && blocks[index].canTakeFocus()) { + // Same block: the cursor goes back where it was (and into the same list item); the field + // clamps it if the undone text is shorter. Only a cursor never seen means "the end". + // An item that has gone (undone away) is not asked for: the list falls back to its last item. + val list = blocks[index] as? Block.ListBlock + val item = itemOf(blockId)?.takeIf { id -> list == null || list.items.value.any { it.id == id } } + val caret = caretOf(blockId)?.takeIf { list == null || item != null } + focusAt(index, cursorToEnd = caret == null, caret = caret, itemId = item) + } else { + // The block is gone: the nearest one above, the cursor at its end. + focusAt(blocks.previousFocusableIndex(interactedIndex + 1), cursorToEnd = true) + } +} + +/** The blocks of a copy or cut and the exact text it put on the clipboard. */ +private class CopiedBlocks(val text: String, val blocks: List) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorToolbar.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorToolbar.kt new file mode 100644 index 00000000..432a94b9 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorToolbar.kt @@ -0,0 +1,65 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block + +// Editor toolbar actions: they act on the block the user last worked on. + +/** Whether the block the user works on can move one place [up] (or down). */ +public fun BlockEditorState.canMoveInteracted(up: Boolean): Boolean { + val from = selection.interactedIndex + val to = if (up) from - 1 else from + 1 + return to in blocks.indices +} + +/** Moves the block the user works on one place [up] (or down). */ +public fun BlockEditorState.moveInteracted(up: Boolean) { + if (!canMoveInteracted(up)) return + val from = selection.interactedIndex + moveBlock(from, if (up) from - 1 else from + 1) +} + +/** Position of block [blockId]; -1 when it is not in the note (any more). */ +public fun BlockEditorState.indexOf(blockId: String): Int = blocks.indexOfFirst { it.id == blockId } + +/** Adds [block] after position [afterIndex] and focuses it. */ +public fun BlockEditorState.addBlockAfter(afterIndex: Int, block: Block) { + val target = (afterIndex + 1).coerceIn(0, blocks.size) + batch { addBlock(target, block) } + selection.focusAt(target) +} + +/** Moves block [blockId] one place [up] (or down), if there is a place to go. */ +public fun BlockEditorState.moveBlockOf(blockId: String, up: Boolean) { + val from = indexOf(blockId) + val to = if (up) from - 1 else from + 1 + if (from >= 0 && to in blocks.indices) moveBlock(from, to) +} + +/** Whether the block the user works on can be removed: one block always remains. */ +public fun BlockEditorState.canRemoveInteracted(): Boolean = blocks.size > 1 + +/** Removes the block the user works on. */ +public fun BlockEditorState.removeInteracted() { + if (!canRemoveInteracted()) return + removeBlock(blocks[selection.interactedIndex], BlockRemoveAction.REMOVE) +} + +/** Where the block the user works on is: [BLOCK_AT_TOP], [BLOCK_AT_BOTTOM] or [BLOCK_IN_MIDDLE]. */ +public fun BlockEditorState.interactedBlockPosition(): Int = when (selection.interactedIndex) { + 0 -> BLOCK_AT_TOP + blocks.lastIndex -> BLOCK_AT_BOTTOM + else -> BLOCK_IN_MIDDLE +} + +/** Focuses the trailing text block, adding one first unless the note already ends in one. */ +public fun BlockEditorState.addBlockToEnd() { + if (blocks.lastOrNull() is Block.TextBlock) { + selection.focusAt(blocks.lastIndex) + } else { + addBlockAfter(blocks.lastIndex, Block.TextBlock()) + } +} + +internal const val BLOCK_AT_TOP = 1 +internal const val BLOCK_AT_BOTTOM = 2 +internal const val BLOCK_IN_MIDDLE = -1 diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockOperations.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt similarity index 87% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockOperations.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt index 411ed391..4204fbe5 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockOperations.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt @@ -1,8 +1,12 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state import androidx.compose.runtime.snapshots.SnapshotStateList -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.model.Block import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.time.Clock + +/** Milliseconds since the epoch: the default clock of the undo history. */ +internal fun currentTimeMillis(): Long = Clock.System.now().toEpochMilliseconds() /** * Undo/redo for the block editor. @@ -18,9 +22,9 @@ import kotlinx.coroutines.flow.MutableStateFlow * operations (Enter splitting a block, a paste becoming blocks) is recorded with [batch] and * undone as one step. */ -class BlockOperations( +internal class BlockOperations( private val blocks: SnapshotStateList, - private val clock: () -> Long = System::currentTimeMillis, + private val clock: () -> Long = ::currentTimeMillis, ) { private val undoStack = ArrayDeque() private val redoStack = ArrayDeque() @@ -93,12 +97,13 @@ class BlockOperations( as? ValueChangeOperation if (mergeTarget != null && mergeTarget.canMerge(blockId, state, now, typing)) { // Same undo (back to the value before the first keystroke), newer redo. + forget(mergeTarget) undoStack[undoStack.lastIndex] = mergeTarget.copy(applyChange = apply, timestamp = now, step = typing) } else { val revert = { state.value = oldValue } pushUndo(ValueChangeOperation(blockId, state, apply, revert, mergeable, now, typing)) } - redoStack.clear() + dropAll(redoStack) breakMerge = false } @@ -112,7 +117,7 @@ class BlockOperations( val operations = batch.operations if (operations.isEmpty()) return pushUndo(operations.singleOrNull() ?: CompositeOperation(operations)) - redoStack.clear() + dropAll(redoStack) breakMerge = false } @@ -160,7 +165,8 @@ class BlockOperations( */ internal class CaretStep(val before: CaretTarget, val after: CaretTarget) - private val caretSteps = java.util.IdentityHashMap() + // Matched by identity: operations compare by content, and two equal steps are still two steps. + private val caretSteps = mutableListOf>() private var lastMoved: Operation? = null /** @@ -176,13 +182,20 @@ class BlockOperations( else -> emptyList() } + /** How many steps carry a cursor step: what the side list holds, for tests of what is still reachable. */ + internal fun caretStepCount(): Int = caretSteps.size + /** Attaches [step] to the step recorded last. */ internal fun attachCaret(step: CaretStep) { - undoStack.lastOrNull()?.let { caretSteps[it] = step } + undoStack.lastOrNull()?.let { last -> + caretSteps.removeAll { it.first === last } + caretSteps += last to step + } } /** The [CaretStep] of the step the last undo or redo moved, if it had one. */ - internal fun lastMovedCaret(): CaretStep? = lastMoved?.let { caretSteps[it] } + internal fun lastMovedCaret(): CaretStep? = + lastMoved?.let { moved -> caretSteps.firstOrNull { it.first === moved }?.second } /** * Undoes the last operation. @@ -215,6 +228,8 @@ class BlockOperations( fun clear() { undoStack.clear() redoStack.clear() + caretSteps.clear() + lastMoved = null breakMerge = false } @@ -231,7 +246,7 @@ class BlockOperations( private fun record(operation: Operation) { execute(operation) pushUndo(operation) - redoStack.clear() + dropAll(redoStack) breakMerge = false } @@ -241,7 +256,18 @@ class BlockOperations( } private fun trim(stack: ArrayDeque) { - while (stack.size > MAX_HISTORY) stack.removeFirst() + while (stack.size > MAX_HISTORY) forget(stack.removeFirst()) + } + + private fun dropAll(stack: ArrayDeque) { + stack.forEach(::forget) + stack.clear() + } + + /** An operation that left both stacks keeps neither its cursor step nor its place as the last one moved. */ + private fun forget(operation: Operation) { + caretSteps.removeAll { it.first === operation } + if (lastMoved === operation) lastMoved = null } /** diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt new file mode 100644 index 00000000..f18bd107 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt @@ -0,0 +1,168 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Asks the editor UI to focus the field of block [blockId]: the cursor at [caret] (clamped to the + * text), else at the end when [cursorToEnd]. In a list, [itemId] names the item to focus. With a + * [caretX] the cursor goes to that horizontal position instead, so Up and Down keep the column. + */ +public data class FocusRequest( + val blockId: String, + val cursorToEnd: Boolean = false, + val caret: Int? = null, + val itemId: String? = null, + /** Put the cursor at this horizontal position of the target's first line (last, [fromBelow]). */ + val caretX: Float? = null, + /** The cursor comes up from below: a list takes its last item, a field its last line. */ + val fromBelow: Boolean = false, +) { + /** Where the cursor goes: an offset, [Int.MAX_VALUE] for the end, null to leave it. */ + val caretOffset: Int? get() = caret ?: if (cursorToEnd) Int.MAX_VALUE else null +} + +/** + * Which block the user works on, by block id, and the focus requests the editor UI carries out. + * + * Plain state: the FocusRequesters themselves live in composition (BlockFocusRegistry), so + * nothing here outlives the screen or needs a UI thread. [blocks] is the editor's live list. + */ +public class BlockSelection(private val blocks: List) { + private val _focusedBlockId = MutableStateFlow(null) + + /** The block whose field has focus; null while none has (the keyboard toggle reads it). */ + public val focusedBlockId: StateFlow = _focusedBlockId.asStateFlow() + + // Where the cursor last was in each block's field, so a block converted by a tool or + // focused again by "Show keyboard" gets its cursor back where the user left it. + private val carets = mutableMapOf() + + private val _interactedBlockId = MutableStateFlow(null) + + /** The block the user last worked on: focused, tapped, or opened the block sheet of. */ + public val interactedBlockId: StateFlow = _interactedBlockId.asStateFlow() + + // Where the interacted block was. If it disappears (undo, delete), the block now at that + // position stands in for it, so toolbar actions never read a position past the end. + private var interactedIndexHint = 0 + + private val focusRequests = Channel(Channel.CONFLATED) + + /** + * Carries out a focus request at once where its field is already composed (set by the editor + * screen). Focus then moves before the field being left is disposed, so the keyboard never sees a + * moment with nothing focused; fields not composed yet still get [requests]. + */ + public var immediateFocus: ((FocusRequest) -> Unit)? = null + + /** Focus requests for the editor UI; only the latest one matters. Collect from one place. */ + public val requests: Flow = focusRequests.receiveAsFlow() + + /** Position of the focused block, or -1 when no block field has focus. */ + internal val focusedIndex: Int get() = blocks.indexOfFirst { it.id == _focusedBlockId.value } + + /** Position of the block the user works on, always inside the list (0 for an empty one). */ + public val interactedIndex: Int + get() { + val index = blocks.indexOfFirst { it.id == _interactedBlockId.value } + return if (index >= 0) index else interactedIndexHint.coerceIn(0, blocks.lastIndex.coerceAtLeast(0)) + } + + /** The focused block, else the one the user last worked on: where toolbar actions apply. */ + internal val workingIndex: Int get() = focusedIndex.takeIf { it >= 0 } ?: interactedIndex + + // Told whenever a field takes focus: block selection mode ends then (set by the editor state). + internal var onFieldFocused: () -> Unit = {} + + /** The field of [blockId] took focus. */ + public fun onFocused(blockId: String) { + onFieldFocused() + _focusedBlockId.value = blockId + onInteraction(blockId) + } + + /** + * The field of [blockId] lost focus. It no longer decides where a new block goes; the block + * the user last worked on ([interactedIndex]) does, until another field takes focus. + */ + public fun onFocusLost(blockId: String) { + if (_focusedBlockId.value == blockId) _focusedBlockId.value = null + } + + /** The cursor in the field of [blockId] is now at [offset]. */ + public fun onCaret(blockId: String, offset: Int) { + carets[blockId] = offset + } + + /** Where the cursor last was in the field of [blockId]; null if it never had one. */ + internal fun caretOf(blockId: String): Int? = carets[blockId] + + // The item of each list the cursor was last in, so a list can be focused back in the same item. + private val listItems = mutableMapOf() + + /** The cursor in item [itemId] of the list [blockId] is at [offset]. */ + public fun onItemCaret(blockId: String, itemId: String, offset: Int) { + listItems[blockId] = itemId + carets[blockId] = offset + } + + /** The item of list [blockId] the cursor was last in; null for a block that is not a list. */ + internal fun itemOf(blockId: String): String? = listItems[blockId] + + /** The user works on [blockId] without its field taking focus (a block sheet). */ + public fun onInteraction(blockId: String) { + val index = blocks.indexOfFirst { it.id == blockId } + if (index < 0) return + _interactedBlockId.value = blockId + interactedIndexHint = index + } + + /** + * Focuses the block now at [index] once its field is composed, the cursor at [caret] (else at + * the end with [cursorToEnd]); [itemId] picks a list's item. + */ + internal fun focusAt(index: Int, cursorToEnd: Boolean = false, caret: Int? = null, itemId: String? = null) { + val block = blocks.getOrNull(index) ?: return + val blockId = block.id + // A block with no field to focus (a separator, a locked checklist) never takes focus, so the field that + // has it keeps it and keeps being the one history keys and toolbar actions apply to. + if (block.canTakeFocus()) onFocused(blockId) else onInteraction(blockId) + caret?.let { carets[blockId] = it } + // The item asked for is where the cursor now is, before its field reports it back. + itemId?.let { listItems[blockId] = it } + val request = FocusRequest(blockId, cursorToEnd, caret, itemId) + immediateFocus?.invoke(request) + focusRequests.trySend(request) + } +} + +/** The id of the first item of a list block; null for any other block (or a list without items). */ +internal fun Block.firstItemId(): String? = (this as? Block.ListBlock)?.let { it.items.value.firstOrNull()?.id } + +/** Whether [this] block has a field that can take focus. */ +internal fun Block.canTakeFocus(): Boolean = when (this) { + is Block.TextBlock, is Block.HBlock, is Block.QuoteBlock, is Block.CodeBlock, is Block.TableBlock -> true + + // A list focuses its own items; a locked checklist has none to focus. + is Block.ListBlock -> !locked.value + + // A link has a field only while its URL is being entered. + is Block.LinkBlock -> block.value.url.isBlank() + + is Block.SeparatorBlock -> false +} + +/** + * The block to focus instead of the one at [index]: the nearest focusable block above it, + * else the first focusable block of the note (0 when there is none). + */ +internal fun List.previousFocusableIndex(index: Int): Int { + val above = (index - 1 downTo 0).firstOrNull { it < size && this[it].canTakeFocus() } + return above ?: indexOfFirst { it.canTakeFocus() }.coerceAtLeast(0) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt new file mode 100644 index 00000000..7e8fc6ce --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt @@ -0,0 +1,137 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockFactory +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TextualBlock +import kotlinx.coroutines.flow.MutableStateFlow + +// The toolbar's block tools: they act on the block the user works on. + +/** Focuses the first block that has a field (the title's "Next"); adds a paragraph when none has. */ +public fun BlockEditorState.focusFirstBlock() { + val first = blocks.indexOfFirst { it.canTakeFocus() } + if (first >= 0) selection.focusAt(first) else addBlockToEnd() +} + +/** + * Brings the keyboard back to where the user was writing: the block they worked on (or the + * nearest one above that has a field), the cursor where they left it (else at the end); the + * first block if none. + */ +public fun BlockEditorState.focusWorkingBlock() { + val index = selection.workingIndex + // The note always has a block, and both indexes lie inside it. + val target = if (blocks[index].canTakeFocus()) index else blocks.previousFocusableIndex(index) + val block = blocks[target] + if (block.canTakeFocus()) { + // A list's items keep their own cursors; its field target puts it at the end. + val caret = selection.caretOf(block.id).takeIf { block !is Block.ListBlock } + // A table goes back to the cell the cursor was in, if that cell is still there. + val cell = (block as? Block.TableBlock)?.let { table -> + selection.itemOf(table.id)?.takeIf { table.table.value.cell(it) != null } + } + selection.focusAt(target, cursorToEnd = true, caret = caret, itemId = cell) + } else { + focusFirstBlock() + } +} + +/** + * Locks or unlocks checklist [blockId]: its text can no longer be focused or edited while its boxes + * still toggle. One undo step. False when the block is not a checklist. + */ +public fun BlockEditorState.toggleChecklistLock(blockId: String): Boolean { + val list = (blocks.firstOrNull { it.id == blockId } as? Block.ListBlock) + ?.takeIf { it.blockType == BlockType.LIST_CHECK } + list?.let { batch { setValue(it.id, it.locked, !it.locked.value) } } + return list != null +} + +/** The Heading tool: headings of every level show it active, and it makes an H2. */ +public val HEADING_TOOL: BlockType = BlockType.H2 + +/** The toolbar tool shown active for [this] block; null for a paragraph. */ +public fun Block.toolType(): BlockType? = when (this) { + is Block.TextBlock -> null + is Block.HBlock -> HEADING_TOOL + is Block.QuoteBlock -> BlockType.QUOTE + is Block.ListBlock -> blockType + is Block.LinkBlock -> BlockType.LINK + is Block.SeparatorBlock -> BlockType.SEPARATOR + is Block.CodeBlock -> BlockType.CODE + is Block.TableBlock -> BlockType.TABLE +} + +/** + * Applies toolbar [tool] to the block the user works on: a text block turns into that type and + * keeps its text, and the tool it already shows turns it back into a paragraph ([exact]: only + * the very same type does, so "Heading 3" turns an H1 into an H3). Otherwise (a link or + * separator, or a block that cannot change) a new block of [tool] is added after it. + */ +public fun BlockEditorState.applyTool(tool: BlockType, exact: Boolean = false) { + val index = selection.workingIndex + // A locked checklist keeps its kind: converting it would drop the lock the user set. + if ((blocks[index] as? Block.ListBlock)?.let { it.locked.value } == true) return + if (retargetNestedItem(blocks[index], tool)) return + val converted = convertBlock(blocks[index], tool, exact) + when { + converted != null -> replaceBlock(index, converted) + tool == BlockType.SEPARATOR -> addSeparator() + else -> addBlock(BlockFactory.createBlock(tool)) + } +} + +/** A separator, then a paragraph to go on writing in (the one already after it, if any). */ +private fun BlockEditorState.addSeparator() { + val separator = Block.SeparatorBlock() + addBlock(separator) + val at = blocks.indexOfFirst { it.id == separator.id } + if (blocks.getOrNull(at + 1) is Block.TextBlock) selection.focusAt(at + 1) else addBlockAfter(at, Block.TextBlock()) +} + +/** [block] as [tool], keeping its text; null when it cannot be converted (see [applyTool]). */ +internal fun convertBlock(block: Block, tool: BlockType, exact: Boolean = false): Block? { + val text = block.plainText() + val shown = if (exact) block.exactType() else block.toolType() + return when { + text == null || (block is Block.TextBlock && tool == BlockType.TEXT) -> null + shown == tool -> Block.TextBlock(MutableStateFlow(text)) + else -> convertText(block, text, tool) + } +} + +private fun convertText(block: Block, text: String, tool: BlockType): Block? = when (tool) { + BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4 -> + Block.HBlock(MutableStateFlow(text.replace('\n', ' ')), blockType = tool) + + BlockType.QUOTE -> Block.QuoteBlock(MutableStateFlow(text)) + + BlockType.LIST_CHECK, BlockType.LIST_BULLET, BlockType.LIST_NUMBER -> { + val items = if (block is Block.ListBlock) block.items.value else text.split('\n').map { ItemListBlock(it) } + Block.ListBlock(MutableStateFlow(items.map { it.copy(kind = null) }), blockType = tool) + } + + // Only an empty paragraph becomes a link; text is never thrown away. + BlockType.LINK -> Block.LinkBlock().takeIf { block is Block.TextBlock && text.isEmpty() } + + BlockType.TEXT -> Block.TextBlock(MutableStateFlow(text)) + + BlockType.CODE -> Block.CodeBlock(MutableStateFlow(text)) + + // A table is added next to the block, never made of its text. + BlockType.SEPARATOR, BlockType.TABLE -> null +} + +private fun Block.exactType(): BlockType? = when (this) { + is Block.TextBlock -> BlockType.TEXT + is Block.HBlock -> blockType + else -> toolType() +} + +private fun Block.plainText(): String? = when (this) { + is Block.ListBlock -> items.value.joinToString("\n") { it.value } + is TextualBlock -> text.value + else -> null +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/CaretMove.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretMove.kt similarity index 77% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/CaretMove.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretMove.kt index 431fd745..f628f079 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/CaretMove.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretMove.kt @@ -1,11 +1,11 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state /** * A cursor key that would leave the field it is pressed in: Up from its first line, Down from its * last, Left from its start, Right from its end. [Up] and [Down] carry the cursor's horizontal - * position ([x], null when unknown) so the cursor keeps its column in the next field. + * position (`x`, null when unknown) so the cursor keeps its column in the next field. */ -sealed interface CaretMove { +internal sealed interface CaretMove { data class Up(val x: Float?) : CaretMove data class Down(val x: Float?) : CaretMove data object Left : CaretMove @@ -13,14 +13,14 @@ sealed interface CaretMove { } /** Whether [this] move goes to the block above. */ -val CaretMove.goesUp: Boolean get() = this is CaretMove.Up || this == CaretMove.Left +internal val CaretMove.goesUp: Boolean get() = this is CaretMove.Up || this == CaretMove.Left /** * The request that focuses [blockId] after [this] move: up and left arrive from below, at the * column the cursor had (up) or at the end (left); down and right arrive from above, at the column * (down) or at the start (right). */ -fun CaretMove.requestFor(blockId: String): FocusRequest = when (this) { +internal fun CaretMove.requestFor(blockId: String): FocusRequest = when (this) { is CaretMove.Up -> x?.let { FocusRequest(blockId, caretX = it, fromBelow = true) } ?: FocusRequest(blockId, cursorToEnd = true, fromBelow = true) diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/CaretTarget.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt similarity index 80% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/CaretTarget.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt index 1aedcc28..b2c63d76 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/CaretTarget.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt @@ -1,8 +1,9 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.TextualBlock +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.model.TextualBlock /** Where the cursor goes after an undo or a redo: block [blockId], in a list its item [itemId], at [caret]. */ internal data class CaretTarget(val blockId: String, val itemId: String?, val caret: Int) @@ -19,6 +20,8 @@ internal fun caretTargetOf(blockId: String, before: Any?, after: Any?): CaretTar before is List<*> && after is List<*> -> listCaret(blockId, before.filterIsInstance(), after.filterIsInstance()) + before is TableData && after is TableData -> tableCaret(blockId, before, after) + else -> null } @@ -34,9 +37,22 @@ internal fun changedCaret(before: String, after: String): Int? { while (prefix < limit && before[prefix] == after[prefix]) prefix++ var suffix = 0 while (suffix < limit - prefix && before[before.length - 1 - suffix] == after[after.length - 1 - suffix]) suffix++ - return after.length - suffix + return after.snappedToCodePoint(after.length - suffix) } +/** + * [offset] in this text, moved back to the start of the character when it falls between the two + * halves of a surrogate pair: a cursor there makes the next keystroke split the emoji. + */ +internal fun CharSequence.snappedToCodePoint(offset: Int): Int = + if (offset in 1 until length && this[offset - 1].isHighSurrogate() && + this[offset].isLowSurrogate() + ) { + offset - 1 + } else { + offset + } + private fun listCaret(blockId: String, before: List, after: List): CaretTarget? { val beforeIds = before.mapTo(HashSet()) { it.id } val afterIds = after.mapTo(HashSet()) { it.id } @@ -73,8 +89,7 @@ private fun mergedOrRemoved( } /** The cursor in [now], an item that took the text of the one below onto [was]: at the join. */ -private fun joinCaret(was: String, now: String): Int = - if (now.startsWith(was)) was.length else changedCaret(was, now) ?: now.length +private fun joinCaret(was: String, now: String): Int = if (now.startsWith(was)) was.length else changedCaret(was, now)!! /** * Where the cursor goes when block [old] is converted into [new] with the same text: [caret] (in a @@ -83,8 +98,8 @@ private fun joinCaret(was: String, now: String): Int = */ internal fun carriedCaret(old: Block, new: Block, caret: Int?, itemId: String?): CaretTarget? { caret ?: return null - val oldItems = (old as? Block.ListBlock)?.items?.value - val newItems = (new as? Block.ListBlock)?.items?.value + val oldItems = old.listItems() + val newItems = new.listItems() return when { oldItems != null && newItems != null -> itemId?.takeIf { id -> newItems.any { it.id == id } }?.let { CaretTarget(new.id, it, caret) } @@ -103,6 +118,9 @@ internal fun carriedCaret(old: Block, new: Block, caret: Int?, itemId: String?): } } +/** The items of a list block; null for any other block. */ +private fun Block.listItems(): List? = if (this is Block.ListBlock) items.value else null + /** [caret], an offset in the lines of [items] joined by line breaks, as an item and an offset in it. */ private fun lineCaret(blockId: String, items: List, caret: Int): CaretTarget? { var left = caret diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt new file mode 100644 index 00000000..be00c7f8 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt @@ -0,0 +1,210 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.alongside +import com.pasich.encly.editor.model.normalizedLevels +import kotlinx.coroutines.flow.MutableStateFlow + +// Enter, Backspace and a paste in a list's items: an item splits at the cursor, an empty item +// leaves the list for a paragraph, and an item joins the one above. Each is one undo step. + +/** + * [lineBreak] in item [itemId] of list [listId]. Enter splits the item at the cursor (the text + * after it goes to a new item); in an empty item it leaves the list. Pasted lines become items. + * Returns the text the item's field keeps; null when the item is gone. + */ +public fun BlockEditorState.listLineBreak(listId: String, itemId: String, lineBreak: LineBreak): String? { + val item = itemAt(listId, itemId) ?: return null + return if (lineBreak.isEnter) { + listEnter(item, lineBreak.head, lineBreak.tail) + } else { + listPasteText(item, lineBreak.head, lineBreak.lines, lineBreak.tail) + } +} + +/** Item [at] of [list], the block at [index]. */ +internal data class ListItemAt(val index: Int, val list: Block.ListBlock, val at: Int) + +internal fun BlockEditorState.itemAt(listId: String, itemId: String): ListItemAt? { + val index = indexOf(listId) + val list = blocks.getOrNull(index) as? Block.ListBlock + val at = list?.let { l -> l.items.value.indexOfFirst { it.id == itemId } } ?: -1 + return if (list != null && at >= 0) ListItemAt(index, list, at) else null +} + +/** + * Pasted [lines] in an item, after its text [head] and before its [tail]: the first joins it, the others become + * items after it. One line stays in the item. + */ +internal fun BlockEditorState.listPasteText(item: ListItemAt, head: String, lines: List, tail: String): String { + val (index, list, at) = item + val items = list.items.value + val rest = lines.drop(1) + val kept = head + lines.first() + val here = items[at] + if (rest.isEmpty()) { + val whole = kept + tail + batch { setValue(list.id, list.items, items.toMutableList().apply { this[at] = here.copy(value = whole) }) } + selection.focusAt(index, caret = kept.length, itemId = here.id) + return whole + } + // Pasted lines go in at the level of the item they are pasted into. + val middle = rest.dropLast(1).filter { it.isNotBlank() }.map { listItemText(it).alongside(here) } + val lastLine = listItemText(rest.last()).alongside(here) + val last = lastLine.copy(value = lastLine.value + tail) + val newItems = items.toMutableList().apply { + this[at] = items[at].copy(value = kept) + addAll(at + 1, middle + last) + } + batch { setValue(list.id, list.items, newItems) } + selection.focusAt(index, caret = lastLine.value.length, itemId = last.id) + return kept +} + +private fun BlockEditorState.listEnter(where: ListItemAt, head: String, tail: String): String { + val (index, list, at) = where + val items = list.items.value + val item = items[at] + return when { + // An empty nested item comes out one level first; an empty item at the top leaves the list. + head.isEmpty() && tail.isEmpty() -> { + if (items.normalizedLevels(list.blockType)[at].level > 0) { + outdentEmpty(index, list, at) + } else { + exitList(index, list, at, from = CaretTarget(list.id, item.id, 0)) + } + "" + } + + // At the start of an item: an empty item opens above it. + head.isEmpty() -> { + val above = ItemListBlock("").alongside(item) + batch { setValue(list.id, list.items, items.toMutableList().apply { add(at, above) }) } + CaretTarget(list.id, item.id, 0).let { attachCaretToLastStep(it, it) } + selection.focusAt(index, caret = 0, itemId = item.id) + tail + } + + else -> { + val next = ItemListBlock(tail).alongside(item) + val newItems = items.toMutableList().apply { + this[at] = item.copy(value = head) + add(at + 1, next) + } + batch { setValue(list.id, list.items, newItems) } + attachCaretToLastStep(CaretTarget(list.id, item.id, head.length), CaretTarget(list.id, next.id, 0)) + selection.focusAt(index, caret = 0, itemId = next.id) + head + } + } +} + +/** The empty nested item [at] comes out one level (with its children), the cursor staying in it. */ +private fun BlockEditorState.outdentEmpty(index: Int, list: Block.ListBlock, at: Int) { + val item = list.items.value[at] + shiftListItem(list.id, item.id, deeper = false) + selection.focusAt(index, caret = 0, itemId = item.id) +} + +/** + * The empty item [at] leaves the list: a paragraph takes its place, after the list for its last + * item, before it for its first, and in the middle it splits the list in two around it. + */ +private fun BlockEditorState.exitList(index: Int, list: Block.ListBlock, at: Int, from: CaretTarget) { + val items = list.items.value + val before = items.take(at) + val after = items.drop(at + 1).normalizedLevels(list.blockType) + val paragraph = Block.TextBlock() + batch { + when { + before.isEmpty() && after.isEmpty() -> replaceBlock(index, paragraph) + + before.isEmpty() -> { + setValue(list.id, list.items, after) + addBlock(index, paragraph) + } + + else -> { + setValue(list.id, list.items, before) + addBlock(index + 1, paragraph) + if (after.isNotEmpty()) addBlock(index + 2, Block.ListBlock(MutableStateFlow(after), list.blockType)) + } + } + } + attachCaretToLastStep(from, CaretTarget(paragraph.id, null, 0)) + selection.focusAt(indexOf(paragraph.id), caret = 0) +} + +/** + * Backspace with the cursor at the start of item [itemId]: a nested item comes out one level first, the + * first item leaves the list as a paragraph above it, and any other item, empty or not, joins the item + * above (see [joinIntoCurrent]). + */ +public fun BlockEditorState.listBackspaceAtStart(listId: String, itemId: String): Boolean { + val where = itemAt(listId, itemId) ?: return false + val (index, list, at) = where + val items = list.items.value + val item = items[at] + when { + items.normalizedLevels(list.blockType)[at].level > 0 -> { + shiftListItem(list.id, item.id, deeper = false) + selection.focusAt(index, caret = 0, itemId = item.id) + } + + at == 0 -> { + val paragraph = Block.TextBlock(MutableStateFlow(item.value)) + batch { + if (items.size == 1) { + replaceBlock(index, paragraph) + } else { + setValue(list.id, list.items, items.drop(1).normalizedLevels(list.blockType)) + addBlock(index, paragraph) + } + } + attachCaretToLastStep(CaretTarget(list.id, item.id, 0), CaretTarget(paragraph.id, null, 0)) + selection.focusAt(indexOf(paragraph.id), caret = 0) + } + + else -> joinIntoCurrent(where) + } + return true +} + +/** + * Item [where] and the one above it become one: the item above is removed and its text (and its + * ticked state) goes in front of the item's own, which keeps the focus and the keyboard. Backspace held + * down then goes on deleting, item after item, in the field that has it: when the focus moves to another + * field the keyboard starts a new input and stops repeating the key. One undo step. + */ +private fun BlockEditorState.joinIntoCurrent(where: ListItemAt) { + val (index, list, at) = where + val items = list.items.value + val item = items[at] + val previous = items[at - 1] + val join = previous.value.length + val newItems = items.toMutableList().apply { + this[at] = item.copy( + value = previous.value + item.value, + isCheck = previous.isCheck, + level = previous.level, + kind = previous.kind, + ) + removeAt(at - 1) + } + batch { setValue(list.id, list.items, newItems.normalizedLevels(list.blockType)) } + attachCaretToLastStep(CaretTarget(list.id, item.id, 0), CaretTarget(list.id, item.id, join)) + selection.focusAt(index, caret = join, itemId = item.id) +} + +/** + * The [TypingStep] of a list edit from [old] to [new] items: typing in one item, else null (a + * check, an item added or removed is a step of its own). + */ +public fun listTypingStep(old: List, new: List): TypingStep? { + val sameItems = old.size == new.size && + old.indices.all { old[it].id == new[it].id && old[it].isCheck == new[it].isCheck } + val changed = if (sameItems) old.indices.filter { old[it].value != new[it].value } else emptyList() + val i = changed.singleOrNull() ?: return null + return typingStep(old[i].id, old[i].value, new[i].value) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt new file mode 100644 index 00000000..2414feec --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt @@ -0,0 +1,91 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.isListKind +import com.pasich.encly.editor.model.movedBy +import com.pasich.encly.editor.model.normalizedLevels +import com.pasich.encly.editor.model.shifted +import com.pasich.encly.editor.model.withSiblingsOfKind + +// Indenting, outdenting and moving the items of a list. An item moves with the items nested under it, and +// each operation is one undo step. The cursor stays in the item when its field has it. A locked checklist takes +// none of them: its text and its structure stay as they are until it is unlocked. + +/** The list `listId` and its items with valid levels, and the position of `itemId` in them. */ +private class ListRows(val index: Int, val list: Block.ListBlock, val rows: List, val at: Int) + +private fun BlockEditorState.rowsOf(listId: String, itemId: String): ListRows? { + val where = itemAt(listId, itemId)?.takeUnless { it.list.locked.value } ?: return null + val rows = where.list.items.value.normalizedLevels(where.list.blockType) + return ListRows(where.index, where.list, rows, where.at) +} + +/** Whether item [itemId] of list [listId] can go one level [deeper] (or out). */ +public fun BlockEditorState.canShiftListItem(listId: String, itemId: String, deeper: Boolean): Boolean = + rowsOf(listId, itemId)?.let { it.rows.shifted(it.at, deeper, it.list.blockType) } != null + +/** + * Indents ([deeper]) or outdents item [itemId] of list [listId] one level, with the items nested under it. + * False, and nothing changes, when it cannot (see [shifted]). + */ +public fun BlockEditorState.shiftListItem(listId: String, itemId: String, deeper: Boolean): Boolean { + val where = rowsOf(listId, itemId) + val shifted = where?.let { it.rows.shifted(it.at, deeper, it.list.blockType) } + if (where == null || shifted == null) return false + commit(where, shifted, itemId) + return true +} + +/** Whether item [itemId] of list [listId] can move [by] places among the items of its own level and parent. */ +public fun BlockEditorState.canMoveListItem(listId: String, itemId: String, by: Int): Boolean = + rowsOf(listId, itemId)?.let { it.rows.movedBy(it.at, by) } != null + +/** + * Moves item [itemId] of list [listId], with the items nested under it, [by] places among the items that share + * its parent (up is negative). False, and nothing changes, when there is no such place. + */ +public fun BlockEditorState.moveListItem(listId: String, itemId: String, by: Int): Boolean { + val where = rowsOf(listId, itemId) + val moved = where?.let { it.rows.movedBy(it.at, by) } + if (where == null || moved == null) return false + commit(where, moved.first, itemId) + return true +} + +/** + * Makes item [itemId] of list [listId] and its siblings (the items of its level under the same parent) a list + * of [kind]: a checklist, bulleted or numbered. False for an item at the top level, which takes the kind of its + * whole list (see applyTool), or when it already is [kind]. + */ +public fun BlockEditorState.setNestedListKind(listId: String, itemId: String, kind: BlockType): Boolean { + val where = if (kind.isListKind()) rowsOf(listId, itemId) else null + val changed = where?.let { it.rows.withSiblingsOfKind(it.at, kind, it.list.blockType) } + if (where == null || changed == null) return false + commit(where, changed, itemId) + return true +} + +/** In a nested item of a list, a list tool sets the kind of that sub-list instead of converting the whole list. */ +internal fun BlockEditorState.retargetNestedItem(block: Block, tool: BlockType): Boolean { + val list = block as? Block.ListBlock + val itemId = list?.let { selection.itemOf(it.id) } + return list != null && itemId != null && setNestedListKind(list.id, itemId, tool) +} + +private fun BlockEditorState.commit(where: ListRows, items: List, itemId: String) { + val list = where.list + batch { setValue(list.id, list.items, items) } + // The item keeps the focus it had, its cursor where it was; one that was not being written in is left alone. + if (selection.focusedBlockId.value == list.id) { + selection.focusAt(where.index, caret = selection.caretOf(list.id), itemId = itemId) + } +} + +/** The list and item the toolbar acts on: the item the user last worked in, in the block they work on. */ +public fun BlockEditorState.interactedListItem(): Pair? { + val list = blocks.getOrNull(selection.interactedIndex) as? Block.ListBlock + val itemId = list?.let { l -> selection.itemOf(l.id)?.takeIf { id -> l.items.value.any { it.id == id } } } + return if (list != null && itemId != null) list.id to itemId else null +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteFind.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteFind.kt new file mode 100644 index 00000000..86951fed --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteFind.kt @@ -0,0 +1,131 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.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. + */ +public 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. */ +internal 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). */ +public data class FindState( + val isOpen: Boolean = false, + val query: String = "", + /** Where the query was found, in note order. */ + public val matches: List = emptyList(), + val current: Int = -1, +) { + /** The matches of each field, for drawing. */ + internal val byField: Map> by lazy(LazyThreadSafetyMode.NONE) { + matches.groupBy { it.field } + } + + /** The match the find bar is on; null when there is none. */ + public 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. + */ +internal 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.CodeBlock -> found.addMatches(block.id, null, block.text.value, query) + + is Block.TableBlock -> block.table.value.rows.flatMap { it.cells } + .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. */ +public class NoteFind { + private val _state = MutableStateFlow(FindState()) + + /** The find bar, live. */ + public val state: StateFlow = _state.asStateFlow() + + internal val isOpen: Boolean get() = _state.value.isOpen + + /** Opens the find bar. */ + public fun open() { + _state.update { it.copy(isOpen = true) } + } + + /** Closes the bar and forgets the query and every match. */ + public fun close() { + _state.value = FindState() + } + + /** Searches [blocks] for [query]; the first match becomes the current one. */ + public 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. + */ + public 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. */ + public fun next(): Unit = step(1) + + /** The match before, wrapping round before the first. */ + public fun previous(): Unit = 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/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteTemplates.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteTemplates.kt new file mode 100644 index 00000000..4ea9d515 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteTemplates.kt @@ -0,0 +1,120 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockFactory +import com.pasich.encly.editor.model.BlockType +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 keys, so they work offline and +// store nothing of their own; a template is just the blocks it makes. + +/** One piece of a template. */ +public sealed interface TemplatePart { + /** A heading of [type] (H1-H4) reading the string [text]. */ + public data class Heading(val type: BlockType, val text: EditorString) : TemplatePart + + /** An empty paragraph to write in. */ + public data object Paragraph : TemplatePart + + /** An empty list of [type] (bullets, numbers or checklist). */ + public data class ListOf(val type: BlockType) : TemplatePart +} + +/** The templates a new note can start from, in the order they are offered. */ +public enum class NoteTemplate(public val label: EditorString, public val parts: List) { + /** What a new note is today: nothing. */ + BLANK(EditorString.TEMPLATE_BLANK, emptyList()), + + MEETING( + EditorString.TEMPLATE_MEETING, + listOf( + heading(EditorString.TEMPLATE_ATTENDEES), + TemplatePart.ListOf(BlockType.LIST_BULLET), + heading(EditorString.TEMPLATE_AGENDA), + TemplatePart.ListOf(BlockType.LIST_NUMBER), + heading(EditorString.TEMPLATE_NOTES), + TemplatePart.Paragraph, + heading(EditorString.TEMPLATE_ACTION_ITEMS), + TemplatePart.ListOf(BlockType.LIST_CHECK), + ), + ), + + JOURNAL( + EditorString.TEMPLATE_JOURNAL, + listOf( + heading(EditorString.TEMPLATE_JOURNAL_TODAY), + TemplatePart.Paragraph, + heading(EditorString.TEMPLATE_JOURNAL_GRATITUDE), + TemplatePart.ListOf(BlockType.LIST_BULLET), + heading(EditorString.TEMPLATE_JOURNAL_TOMORROW), + TemplatePart.Paragraph, + ), + ), + + PROJECT( + EditorString.TEMPLATE_PROJECT, + listOf( + heading(EditorString.TEMPLATE_PROJECT_GOAL), + TemplatePart.Paragraph, + heading(EditorString.TEMPLATE_PROJECT_TASKS), + TemplatePart.ListOf(BlockType.LIST_CHECK), + heading(EditorString.TEMPLATE_PROJECT_DECISIONS), + TemplatePart.ListOf(BlockType.LIST_BULLET), + heading(EditorString.TEMPLATE_PROJECT_QUESTIONS), + TemplatePart.ListOf(BlockType.LIST_BULLET), + ), + ), + + CHECKLIST( + EditorString.BLOCK_CHECKLIST, + listOf( + heading(EditorString.TEMPLATE_CHECKLIST_TODO), + TemplatePart.ListOf(BlockType.LIST_CHECK), + ), + ), +} + +private fun heading(text: EditorString) = 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]. + */ +public fun NoteTemplate.blocks(resolve: (EditorString) -> 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. */ +public fun BlockEditorState.isBlankNote(): Boolean = + (blocks.singleOrNull() as? Block.TextBlock)?.let { it.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. + */ +public 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 + selection.focusAt(target, caret = 0, itemId = template[target].firstItemId()) + 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/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/PasteEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/PasteEditing.kt new file mode 100644 index 00000000..5e724b91 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/PasteEditing.kt @@ -0,0 +1,158 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.TextualBlock +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Pasted lines in a paragraph, heading or quote (Smart Paste). The text is read as Markdown + * ([pastedBlocks]): a paste that is one paragraph goes into the block at the cursor, line breaks + * and all; otherwise its first paragraph joins the text before the cursor, the other blocks + * follow, and the text after the cursor goes on at the end of the last paragraph, as the kind of + * block it came from (text after the cursor in a heading stays a heading of that level, in a + * quote a quote). When the cursor is after text, the first pasted line joins it as plain text + * and is never read as a mark; so is a single line with a trailing line break, next to any text. + * Whitespace-only lines at either end of the paste are dropped; everything else is kept. One + * undo step. + */ +internal fun BlockEditorState.pasteLines(index: Int, block: TextualBlock, lineBreak: LineBreak): String { + val pasted = trimBlankLines(lineBreak.lines) + // One content line is only a line break at the end of the copied text: no marks to read, + // unless it lands in an empty block, where a lone address or a mark is what the user meant. + val inText = lineBreak.head.isNotEmpty() || lineBreak.tail.isNotEmpty() + val mapped = if (pasted.size == 1 && + inText + ) { + emptyList() + } else { + pastedBlocks(pasted, lineBreak.head.isNotEmpty(), markdown) + } + return when { + pasted.isEmpty() -> { + // Nothing but blank lines: no content to paste. + selection.focusAt(index, caret = lineBreak.head.length) + lineBreak.head + lineBreak.tail + } + + // One line (its line break is just the end of the copied text), or text the mapper left + // nothing of: pasted as typed. + mapped.isEmpty() || mapped.singleOrNull() is Block.TextBlock -> + pasteParagraph(PasteSite(index, block, lineBreak.head, lineBreak.tail), pasted) + + else -> pasteBlocks( + PasteSite(index, block, lineBreak.head, lineBreak.tail), + mapped, + lead = pasted.first().takeWhile { it.isWhitespace() }, + trail = pasted.last().takeLastWhile { it.isWhitespace() }, + ) + } +} + +/** + * [pasted], the whole text of an insertion into the field of block [blockId] (item [itemId] when it is a list) that + * read [head] before it and [tail] after it, when it is exactly what the last copy or cut of this editor put on the + * clipboard ([BlockEditorState.rememberCopied]): the blocks that were copied go in, as they were, instead of the text + * being read. In a paragraph, heading or quote the blocks follow the rules of [pasteLines]; in a list item they + * become items, as plain text (a list item holds no other block). One undo step. Returns the text the field keeps, + * or null when the paste is not that text or the field takes no paste (code, a table cell, a link). + */ +public fun BlockEditorState.pasteCopied( + blockId: String, + itemId: String?, + head: String, + pasted: String, + tail: String, +): String? { + val text = normalizeLineBreaks(pasted) + val copied = copiedFor(text) ?: return null + val index = indexOf(blockId) + return when (val block = blocks.getOrNull(index)) { + is Block.CodeBlock -> null + + is TextualBlock -> { + val single = copied.singleOrNull() as? Block.TextBlock + val site = PasteSite(index, block, head, tail) + if (single != null) { + pasteParagraph(site, single.text.value.split('\n')) + } else { + pasteBlocks(site, copied, lead = "", trail = "") + } + } + + is Block.ListBlock -> itemId?.let { itemAt(blockId, it) } + ?.let { listPasteText(it, head, copied.plainText().split('\n'), tail) } + + else -> null + } +} + +/** Where a paste lands: the block at [index], which read [head] before the pasted text and [tail] after it. */ +private data class PasteSite(val index: Int, val block: TextualBlock, val head: String, val tail: String) + +/** A paste that is one paragraph: it goes into the block at the cursor, line breaks and all. */ +private fun BlockEditorState.pasteParagraph(site: PasteSite, pasted: List): String { + val index = site.index + val block = site.block + val head = site.head + val tail = site.tail + val text = pasted.joinToString("\n").let { if (block is Block.HBlock) it.replace('\n', ' ') else it } + val kept = head + text + tail + batch { setValue(blocks[index].id, block.text, kept) } + selection.focusAt(index, caret = head.length + text.length) + return kept +} + +/** + * A paste of several paragraphs or marks: see [pasteLines]. [lead] is the whitespace the first pasted line started + * with and [trail] the whitespace the last one ended in. + */ +private fun BlockEditorState.pasteBlocks(site: PasteSite, mapped: List, lead: String, trail: String): String { + val index = site.index + val block = site.block + val head = site.head + val tail = site.tail + val first = mapped.first() as? Block.TextBlock + val before = when (first) { + null -> head + else -> head + lead + first.text.value.flattenFor(block) + } + var rest = if (first == null) mapped else mapped.drop(1) + var caret = 0 + if (tail.isEmpty()) { + rest = withTrailingParagraph(rest) + } else { + // The pasted text goes on into what followed the cursor. + val last = rest.last() as? Block.TextBlock + if (last == null) { + rest = rest + block.sameKind(tail) + } else { + val joined = last.text.value + trail + caret = joined.length + rest = rest.dropLast(1) + block.sameKind(joined + tail) + } + } + // Nothing before the cursor and nothing to join: the pasted blocks take the block's place. + val replaceCurrent = first == null && head.isEmpty() + batch { + if (replaceCurrent) { + replaceBlock(index, rest.first()) + rest.drop(1).forEachIndexed { i, added -> addBlock(index + 1 + i, added) } + } else { + setValue(blocks[index].id, block.text, before) + 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 before +} + +/** 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/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocks.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocks.kt new file mode 100644 index 00000000..8f017567 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocks.kt @@ -0,0 +1,108 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * The blocks the user has picked as whole blocks (block selection mode), by block id. Transient + * editor state: it is never stored with the note, and it is forgotten when the note is loaded, an + * edit is undone or redone, or a field takes focus. Text selected inside one block is not part of + * it; that stays the field's own. + * + * [blocks] is the editor's live list. A picked block that has left it (deleted, undone away) drops + * out on the next [prune], and the mode ends when none is left. + */ +public class SelectedBlocks(private val blocks: List) { + private val _ids = MutableStateFlow>(emptySet()) + + /** The ids of the picked blocks; empty while block selection mode is off. */ + public val ids: StateFlow> = _ids.asStateFlow() + + // A range grows from the anchor towards the head with Shift+arrows. + private var anchorId: String? = null + private var headId: String? = null + + /** Whether block selection mode is on: at least one block is picked. */ + public val isActive: Boolean get() = _ids.value.isNotEmpty() + + /** How many blocks are picked. */ + public val count: Int get() = _ids.value.size + + /** The picked blocks, in the order of the note. */ + public fun picked(): List = _ids.value.let { ids -> blocks.filter { it.id in ids } } + + /** Starts block selection mode with [blockId] (a long press on its handle); the other picks are dropped. */ + public fun select(blockId: String) { + if (blocks.none { it.id == blockId }) return + anchorId = blockId + headId = blockId + _ids.value = setOf(blockId) + } + + /** Picks [blockId], or lets it go when it is picked already; the mode ends with the last pick. */ + public fun toggle(blockId: String) { + if (blocks.none { it.id == blockId }) return + val ids = _ids.value + if (blockId in ids) { + _ids.value = ids - blockId + if (_ids.value.isEmpty()) forgetRange() else repairRange() + } else { + if (ids.isEmpty()) anchorId = blockId + headId = blockId + _ids.value = ids + blockId + } + } + + /** Picks every block of the note. */ + public fun selectAll() { + anchorId = blocks.first().id + headId = blocks.last().id + _ids.value = blocks.mapTo(mutableSetOf()) { it.id } + } + + /** + * Shift+Up ([up]) or Shift+Down: moves the end of the range one block, picking the block it + * reaches, or letting go of the one it leaves when it moves back towards the anchor. Does + * nothing outside block selection mode or past the first or last block. + */ + public fun extend(up: Boolean) { + if (!isActive) return + val anchor = blocks.indexOfFirst { it.id == anchorId } + val head = blocks.indexOfFirst { it.id == headId } + val next = if (up) head - 1 else head + 1 + if (next !in blocks.indices) return + val shrinking = if (up) head > anchor else head < anchor + headId = blocks[next].id + _ids.value = if (shrinking) _ids.value - blocks[head].id else _ids.value + blocks[next].id + } + + /** Leaves block selection mode. */ + public fun clear() { + _ids.value = emptySet() + forgetRange() + } + + /** Lets go of picked blocks that are not in the note any more. */ + internal fun prune() { + if (!isActive) return + val present = blocks.mapTo(mutableSetOf()) { it.id } + val kept = _ids.value.filterTo(mutableSetOf()) { it in present } + if (kept.size == _ids.value.size) return + _ids.value = kept + if (kept.isEmpty()) forgetRange() else repairRange() + } + + // The range's ends must be picked blocks: after a pick is let go or lost they move to the nearest picked ones. + private fun repairRange() { + val picked = picked() + if (picked.none { it.id == anchorId }) anchorId = picked.first().id + if (picked.none { it.id == headId }) headId = picked.last().id + } + + private fun forgetRange() { + anchorId = null + headId = null + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt new file mode 100644 index 00000000..92416b65 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt @@ -0,0 +1,69 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.TextualBlock +import com.pasich.encly.editor.model.hasContent +import com.pasich.encly.editor.model.kindIn +import com.pasich.encly.editor.model.normalizedLevels +import com.pasich.encly.editor.model.numbers + +/** + * Removes every picked block as one undo step and leaves block selection mode. The note keeps at + * least one block: when every block is picked, the first becomes an empty paragraph, inside the same + * step. The cursor goes to the end of the block above the gap (the first one with a field when there + * is none), so the keyboard's undo works at once. False when no block was picked. + */ +public fun BlockEditorState.deleteSelectedBlocks(): Boolean { + val indexes = pickedBlocks.picked().map { indexOf(it.id) }.sorted() + if (indexes.isEmpty()) return false + val removesAll = indexes.size == blocks.size + batch { + // Highest first: a removal never shifts the position of one still to come. + indexes.asReversed().forEach { index -> + if (removesAll && index == 0) { + replaceBlock(0, Block.TextBlock(placeholder = EditorString.PRESS_TO_EDIT)) + } else { + removeBlock(index) + } + } + } + pickedBlocks.clear() + selection.focusAt(blocks.previousFocusableIndex(indexes.first()), cursorToEnd = true) + return true +} + +/** The picked blocks as plain text (see [plainText]); empty when none is picked. */ +public fun BlockEditorState.pickedPlainText(): String = pickedBlocks.picked().plainText() + +/** + * [this] list of blocks as plain text: one paragraph per block, a blank line between blocks, and + * lists one item to a line with a plain marker ("- ", "1. ", `[ ] ` or `[x] `). Blocks with no + * content are left out. Markdown, for a host that wants it, is the host's own renderer. + */ +public fun List.plainText(): String = filter { it.hasContent() }.joinToString("\n\n") { it.plainLines() } + +private fun Block.plainLines(): String = when (this) { + is Block.TextBlock, is Block.HBlock, is Block.QuoteBlock, is Block.CodeBlock -> (this as TextualBlock).text.value + + is Block.SeparatorBlock -> "---" + + is Block.TableBlock -> table.value.rows.joinToString("\n") { row -> row.cells.joinToString(" | ") { it.value } } + + is Block.LinkBlock -> block.value.let { if (it.title.isBlank()) it.url else "${it.title} (${it.url})" } + + is Block.ListBlock -> { + // A nested item is indented two spaces a level, and numbers start again at every level. + val shown = items.value.filter { it.value.isNotBlank() }.normalizedLevels(blockType) + val numbers = shown.numbers(blockType) + shown.withIndex().joinToString("\n") { (at, item) -> + val marker = when (item.kindIn(blockType)) { + BlockType.LIST_NUMBER -> "${numbers[at]}. " + BlockType.LIST_CHECK -> if (item.isCheck) "[x] " else "[ ] " + else -> "- " + } + " ".repeat(item.level) + marker + item.value + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt index b4f3d18b..48cc65e4 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt @@ -1,17 +1,17 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 com.pasich.encly.dynamicBlocks.TextualBlock +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.TextualBlock import kotlinx.coroutines.flow.MutableStateFlow // The markdown-like marks a writer types at the start of a paragraph ("# ", "- ", "1. ", // "[] ", "> ", "---"), and the same marks in pasted text. -/** A paragraph mark: the paragraph becomes a block of [type], [checked] for "[x] ". */ -data class Shortcut(val type: BlockType, val marker: String, val checked: Boolean = false) +/** A paragraph mark: the paragraph becomes a block of [type], [checked] for `[x] `. */ +public data class Shortcut(val type: BlockType, val marker: String, val checked: Boolean = false) private val HEADINGS = listOf(BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4) private val NUMBER_MARK = Regex("^\\d{1,3}[.)] ") @@ -22,7 +22,7 @@ private const val QUOTE_MARK = "> " private const val TYPED_RULE = "---" /** The mark [line] starts with, if any; the text after it is the block's. */ -fun leadingShortcut(line: String): Shortcut? { +internal fun leadingShortcut(line: String): Shortcut? { val hashes = line.takeWhile { it == '#' }.length return when { hashes in 1..HEADINGS.size && line.startsWith("${"#".repeat(hashes)} ") -> @@ -50,13 +50,13 @@ fun leadingShortcut(line: String): Shortcut? { * "---" as the paragraph's whole text. Null otherwise, so a mark pasted or typed mid-text stays * text. */ -fun typedShortcut(text: String, cursor: Int): Shortcut? = when { +internal fun typedShortcut(text: String, cursor: Int): Shortcut? = when { text == TYPED_RULE && cursor == text.length -> Shortcut(BlockType.SEPARATOR, text) else -> leadingShortcut(text)?.takeIf { cursor == it.marker.length } } /** [shortcut] applied: the block that replaces the paragraph, holding [rest], the text after the mark. */ -fun shortcutBlock(shortcut: Shortcut, rest: String): Block = when (shortcut.type) { +internal fun shortcutBlock(shortcut: Shortcut, rest: String): Block = when (shortcut.type) { BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4 -> Block.HBlock(MutableStateFlow(rest), blockType = shortcut.type) @@ -69,16 +69,17 @@ fun shortcutBlock(shortcut: Shortcut, rest: String): Block = when (shortcut.type BlockType.LINK -> Block.LinkBlock(MutableStateFlow(LinkDataBlock(url = rest))) - BlockType.TEXT -> Block.TextBlock(MutableStateFlow(rest)) + // Code and tables have no mark to type. + BlockType.TEXT, BlockType.CODE, BlockType.TABLE -> Block.TextBlock(MutableStateFlow(rest)) } private val LONE_WEB_ADDRESS = Regex("^https?://[^\\s\\\\]+$", RegexOption.IGNORE_CASE) /** 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()) +internal fun isLoneWebAddress(line: String): Boolean = LONE_WEB_ADDRESS.matches(line.trim()) /** A pasted line as a list item's text: without a list mark of its own. */ -fun listItemText(line: String): ItemListBlock { +internal fun listItemText(line: String): ItemListBlock { val shortcut = leadingShortcut(line)?.takeIf { it.type in LIST_TYPES } ?: return ItemListBlock(line) return ItemListBlock(line.removePrefix(shortcut.marker), shortcut.checked) } @@ -86,7 +87,7 @@ fun listItemText(line: String): ItemListBlock { private val LIST_TYPES = setOf(BlockType.LIST_BULLET, BlockType.LIST_NUMBER, BlockType.LIST_CHECK) /** Line breaks as the editor splits on them: `\r\n`, `\r` and the Unicode separators become `\n`. */ -fun normalizeLineBreaks(text: String): String = +internal fun normalizeLineBreaks(text: String): String = text.replace("\r\n", "\n").replace('\r', '\n').replace('\u2028', '\n').replace('\u2029', '\n') /** [pasted] followed by a paragraph to go on writing in, unless it ends in a field already. */ diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SlashCommands.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SlashCommands.kt new file mode 100644 index 00000000..3d87caaf --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SlashCommands.kt @@ -0,0 +1,149 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockFactory +import com.pasich.encly.editor.model.BlockType +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. + */ +public enum class SlashCommand( + internal val type: BlockType, + internal val label: EditorString, + internal val headingLevel: Int? = null, + internal val keywords: List = emptyList(), +) { + TEXT(BlockType.TEXT, EditorString.TEXT, keywords = listOf("paragraph", "plain")), + H1(BlockType.H1, EditorString.BLOCK_HEADING_LEVEL, headingLevel = 1, keywords = listOf("h1", "title")), + H2(BlockType.H2, EditorString.BLOCK_HEADING_LEVEL, headingLevel = 2, keywords = listOf("h2")), + H3(BlockType.H3, EditorString.BLOCK_HEADING_LEVEL, headingLevel = 3, keywords = listOf("h3")), + H4(BlockType.H4, EditorString.BLOCK_HEADING_LEVEL, headingLevel = 4, keywords = listOf("h4")), + CHECKLIST(BlockType.LIST_CHECK, EditorString.BLOCK_CHECKLIST, keywords = listOf("todo", "task", "checkbox")), + BULLETS(BlockType.LIST_BULLET, EditorString.BLOCK_BULLETED_LIST, keywords = listOf("bullets", "ul")), + NUMBERED(BlockType.LIST_NUMBER, EditorString.BLOCK_NUMBERED_LIST, keywords = listOf("ordered", "ol")), + QUOTE(BlockType.QUOTE, EditorString.QUOTE, keywords = listOf("citation")), + LINK(BlockType.LINK, EditorString.BLOCK_LINK, keywords = listOf("url", "bookmark")), + DIVIDER(BlockType.SEPARATOR, EditorString.BLOCK_SEPARATOR, keywords = listOf("divider", "rule", "line", "hr")), + CODE(BlockType.CODE, EditorString.BLOCK_CODE, keywords = listOf("snippet", "pre", "monospace")), + TABLE(BlockType.TABLE, EditorString.BLOCK_TABLE, keywords = listOf("grid", "rows", "columns")), +} + +/** + * 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. + */ +internal 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). + */ +public 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. + */ +public class SlashPicker { + private val _menu = MutableStateFlow(null) + + /** The open slash menu; null while it is closed. */ + public val menu: StateFlow = _menu.asStateFlow() + + private var labels: Map = emptyMap() + + /** The localized label of every command, for matching the query; set by the UI. */ + internal fun setLabels(labels: Map) { + this.labels = labels + _menu.value?.let { current -> _menu.value = current.withQuery(current.query) } + } + + /** "/" was typed into the empty paragraph [blockId]. */ + public fun open(blockId: String) { + _menu.value = SlashMenu(blockId, "", slashMatches("", labels), highlighted = 0, committed = false) + } + + /** The "/" is committed text, so the menu may show. */ + public fun commit() { + _menu.value = _menu.value?.copy(committed = true) + } + + /** Paragraph [blockId] now reads [text]: the query follows it, or the menu closes. */ + public 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. */ + public 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) + } + + /** Closes the slash menu. */ + public 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. + */ +public 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) } + selection.focusAt(index, caret = 0, itemId = replacement.firstItemId()) + } + } + return true +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SmartPaste.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SmartPaste.kt new file mode 100644 index 00000000..a5a54a25 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SmartPaste.kt @@ -0,0 +1,57 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.MarkdownBlockParser +import com.pasich.encly.editor.codec.MarkdownBlockMapper +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.LinkDataBlock +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. + */ +internal 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 [markdown] (by default the editor's own + * [com.pasich.encly.editor.codec.MarkdownBlockMapper]) in its paste mode (nothing is rewritten or + * dropped): marks become their blocks, a lone web address a link, a rule a separator, anything else + * a paragraph. A blank line (any number of them) starts the next paragraph while a single line + * break stays inside its paragraph, and consecutive items of one kind of list become one list. + * Whitespace-only lines at either end are not content and are dropped; none in between is. With + * [plainFirstLine] the first line stays plain text, marks and all: a paste that continues what is + * already on the line must not turn it into a heading, list or link card. + */ +internal fun pastedBlocks( + lines: List, + plainFirstLine: Boolean = false, + markdown: MarkdownBlockParser = MarkdownBlockMapper, +): List { + val text = trimBlankLines(lines).withIndex() + .joinToString("\n") { (i, line) -> if (i == 0 && plainFirstLine) line else asMarkdownMark(line) } + return markdown.parsePaste(text, plainFirstLine, ::pastedAddress) +} + +/** [line] with the editor's own short marks (`[] `, `[x] `, `• `) written the way Markdown does. */ +private fun asMarkdownMark(line: String): String { + val shortcut = leadingShortcut(line) ?: return line + val rest by lazy { line.removePrefix(shortcut.marker) } + return when { + shortcut.type == BlockType.LIST_CHECK && !shortcut.marker.startsWith("-") -> + (if (shortcut.checked) "- [x] " else "- [ ] ") + rest + + shortcut.marker == "• " -> "- $rest" + + else -> line + } +} + +private fun pastedAddress(line: String): Block? = line.trim().takeIf(::isLoneWebAddress) + ?.let { Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "", url = it))) } diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TableEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TableEditing.kt new file mode 100644 index 00000000..50efcb0b --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TableEditing.kt @@ -0,0 +1,100 @@ +@file:Suppress("TooManyFunctions") // One small function per table command. + +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.TableData + +// What editing a table is, apart from drawing it: where a key takes the cursor, and what each command of the cell +// menu does to the grid. Pure functions of a [TableData]; the block hands the result to the editor as one edit. + +/** + * The [TypingStep] of a table edit from [old] to [new]: typing in one cell, else null (a row or a column added or + * removed, the header switched, is a step of its own). + */ +public fun tableTypingStep(old: TableData, new: TableData): TypingStep? { + val sameShape = old.rowCount == new.rowCount && old.columnCount == new.columnCount && old.header == new.header + val changed = if (sameShape) old.cells().zip(new.cells()).filter { (a, b) -> a != b } else emptyList() + val cell = changed.singleOrNull()?.takeIf { (a, b) -> a.id == b.id } ?: return null + return typingStep(cell.first.id, cell.first.value, cell.second.value) +} + +private fun TableData.cells() = rows.flatMap { it.cells } + +/** A step of the cursor between the cells of a table, in reading order or along a column. */ +internal enum class CellStep { PREVIOUS, NEXT, UP, DOWN } + +/** The id of the cell [step] away from [cellId]; null at the edge of the table or for a cell that is not in it. */ +internal fun TableData.neighborOf(cellId: String, step: CellStep): String? { + val (row, column) = locate(cellId) ?: return null + val target = when (step) { + CellStep.PREVIOUS -> if (column > 0) row to column - 1 else (row - 1) to columnCount - 1 + CellStep.NEXT -> if (column < columnCount - 1) row to column + 1 else (row + 1) to 0 + CellStep.UP -> (row - 1) to column + CellStep.DOWN -> (row + 1) to column + } + return rows.getOrNull(target.first)?.let { it.cells[target.second].id } +} + +/** The grid [table] an edit produced, and the cell the cursor goes to ([focusCellId]; null leaves it). */ +internal class TableEdit(val table: TableData, val focusCellId: String?) + +/** Enter in [cellId]: the cell below, a new row first when this is the last one. */ +internal fun TableData.enterFrom(cellId: String): TableEdit? { + val (row, column) = locate(cellId) ?: return null + return if (row < rowCount - 1) { + TableEdit(this, rows[row + 1].cells[column].id) + } else { + addRowBelow(cellId) + } +} + +/** Tab in [cellId]: the next cell; after the last one a new row, the cursor in its first cell. */ +internal fun TableData.tabFrom(cellId: String): TableEdit? { + val next = neighborOf(cellId, CellStep.NEXT) + if (next != null || locate(cellId) == null) return next?.let { TableEdit(this, it) } + val grown = addRow(rowCount) + return TableEdit(grown, grown.rows.last().cells.first().id) +} + +/** A new row under the row of [cellId], the cursor in its cell of the same column. */ +internal fun TableData.addRowBelow(cellId: String): TableEdit? { + val (row, column) = locate(cellId) ?: return null + val grown = addRow(row + 1) + return TableEdit(grown, grown.rows[row + 1].cells[column].id) +} + +/** A new column after the column of [cellId], in reading order (so on its left in a right-to-left table). */ +internal fun TableData.addColumnAfter(cellId: String): TableEdit? { + val (row, column) = locate(cellId) ?: return null + val grown = addColumn(column + 1) + return TableEdit(grown, grown.rows[row].cells[column + 1].id) +} + +/** The row of [cellId] removed, the cursor in the same column of the row now there (above, for the last row). */ +internal fun TableData.removeRowOf(cellId: String): TableEdit? = locate(cellId)?.let { (row, column) -> + removeRow(row).takeIf { it != this }?.let { shrunk -> + TableEdit(shrunk, shrunk.rows[row.coerceAtMost(shrunk.rowCount - 1)].cells[column].id) + } +} + +/** The column of [cellId] removed, the cursor in the same row of the column now there (before, for the last one). */ +internal fun TableData.removeColumnOf(cellId: String): TableEdit? = locate(cellId)?.let { (row, column) -> + removeColumn(column).takeIf { it != this }?.let { shrunk -> + TableEdit(shrunk, shrunk.rows[row].cells[column.coerceAtMost(shrunk.columnCount - 1)].id) + } +} + +/** The header row switched, the cursor left where it is. */ +internal fun TableData.toggleHeader(): TableEdit = TableEdit(withHeader(!header), null) + +/** Where the cursor goes after an undo or redo that changed a cell's text: that cell, after the changed part. */ +internal fun tableCaret(blockId: String, before: TableData, after: TableData): CaretTarget? = + after.cells().firstNotNullOfOrNull { cell -> + before.cell(cell.id)?.let { old -> changedCaret(old.value, cell.value) } + ?.let { CaretTarget(blockId, cell.id, it) } + } + +/** A line of text as a cell holds it: no line breaks (they become one space each). */ +internal fun cellText(text: String): String = normalizeLineBreaks(text).replace(LINE_BREAKS, " ") + +private val LINE_BREAKS = Regex("\n+") diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/TextEdits.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextEdits.kt similarity index 83% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/TextEdits.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextEdits.kt index 6d097b7d..045134f3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/TextEdits.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextEdits.kt @@ -1,7 +1,7 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state /** One edit of a text as the range it replaced: at [start], [removed] became [inserted]. */ -data class TextDiff(val start: Int, val removed: String, val inserted: String) { +public data class TextDiff(val start: Int, val removed: String, val inserted: String) { /** Where the inserted text ends in the new text. */ val end: Int get() = start + inserted.length } @@ -11,7 +11,7 @@ data class TextDiff(val start: Int, val removed: String, val inserted: String) { * insertion exactly: typing a line break next to an existing one is otherwise ambiguous. Without * it (or when it does not fit), the smallest differing range is taken. */ -fun textDiff(old: String, new: String, cursor: Int = -1): TextDiff { +public fun textDiff(old: String, new: String, cursor: Int = -1): TextDiff { if (isInsertionEndingAt(old, new, cursor)) { val start = cursor - (new.length - old.length) return TextDiff(start, removed = "", inserted = new.substring(start, cursor)) @@ -32,21 +32,21 @@ private fun isInsertionEndingAt(old: String, new: String, cursor: Int): Boolean } /** What a typing edit did, for undo: typed, deleted, or replaced (autocorrect, a paste over a selection). */ -enum class TypingKind { INSERT, DELETE, REPLACE } +public enum class TypingKind { INSERT, DELETE, REPLACE } /** * How one typing edit joins the undo history. Typing in one [field] (a block, or one list item) * stays one step while it keeps doing the same [kind] of edit, and a new word ([startsWord]) * starts a new step: undo takes back the last word, not one letter or the whole session. */ -data class TypingStep(val field: String, val kind: TypingKind, val startsWord: Boolean) { +public data class TypingStep(val field: String, val kind: TypingKind, val startsWord: Boolean) { /** Whether this edit may join the step [previous] started. */ - fun continues(previous: TypingStep): Boolean = + internal fun continues(previous: TypingStep): Boolean = field == previous.field && kind == previous.kind && kind != TypingKind.REPLACE && !startsWord } /** The [TypingStep] of the edit from [old] to [new] in [field]. */ -fun typingStep(field: String, old: String, new: String): TypingStep { +internal fun typingStep(field: String, old: String, new: String): TypingStep { val diff = textDiff(old, new) val kind = when { diff.removed.isEmpty() -> TypingKind.INSERT @@ -64,7 +64,7 @@ fun typingStep(field: String, old: String, new: String): TypingStep { * field now reads [head], then [lines] joined by line breaks, then [tail]. A typed Enter is two * empty [lines]. */ -data class LineBreak(val head: String, val lines: List, val tail: String) { +public data class LineBreak(val head: String, val lines: List, val tail: String) { init { require(lines.size >= 2) { "A line break makes at least two lines" } } @@ -72,9 +72,9 @@ data class LineBreak(val head: String, val lines: List, val tail: String /** Enter, as opposed to pasted text. */ val isEnter: Boolean get() = lines.size == 2 && lines.all { it.isEmpty() } - companion object { + internal companion object { /** The line break [diff] made in a field that read [old]; null when it inserted none. */ - fun of(old: String, diff: TextDiff): LineBreak? { + internal fun of(old: String, diff: TextDiff): LineBreak? { val inserted = normalizeLineBreaks(diff.inserted) if ('\n' !in inserted) return null return LineBreak( diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt new file mode 100644 index 00000000..dc7f4ee5 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt @@ -0,0 +1,488 @@ +package com.pasich.encly.editor.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TextualBlock +import com.pasich.encly.editor.model.normalizedLevels +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A place in the note's text: block [blockId], a logical character [offset] into its text. In a list, [itemId] names + * the item; without it the point is the list's first item when it starts a selection, its last when it ends one. + */ +public data class TextPoint(val blockId: String, val offset: Int, val itemId: String? = null) + +/** The two ends of a text selection that may run across blocks: where it began and where it is now. */ +public data class TextRangeEnds(val anchor: TextPoint, val head: TextPoint) + +/** + * Where Shift plus a movement key takes the end of a text selection. Previous and next are logical (towards the start + * and towards the end of the stored text), so a right-to-left paragraph just swaps which arrow is which. + */ +public enum class HeadMove { + PREVIOUS, + NEXT, + UP, + DOWN, + FIELD_START, + FIELD_END, + PAGE_UP, + PAGE_DOWN, + NOTE_START, + NOTE_END, +} + +/** How many fields Page Up and Page Down move the end of a selection. */ +private const val PAGE_FIELDS = 5 + +/** + * A text selection across blocks: from [TextRangeEnds.anchor] to [TextRangeEnds.head], in either direction. The + * first and last fields (a paragraph, heading, quote or list item) are selected from and to their offsets, the + * fields between them whole. Offsets are logical (positions in the stored text, never screen sides), so a block's + * text direction does not matter. A link or a separator at an end, or between, is selected whole. Transient: never + * stored with the note, forgotten when the note is loaded, an edit is undone or redone, or a field takes focus. + */ +@Suppress("TooManyFunctions") // One call for each way a selection is made, moved and read. +public class TextRangeSelection(private val blocks: List) { + private val _ends = MutableStateFlow(null) + + // The same value as snapshot state, so what draws the selection redraws when it changes. + private var current by mutableStateOf(null) + + /** The ends of the selection; null while there is none. */ + public val ends: StateFlow = _ends.asStateFlow() + + /** Whether a selection exists. */ + public val isActive: Boolean get() = current != null + + private fun set(value: TextRangeEnds?) { + current = value + _ends.value = value + } + + /** Selects from [anchor] to [head]; ignored when either block is not in the note. */ + public fun select(anchor: TextPoint, head: TextPoint) { + if (indexOf(anchor.blockId) >= 0 && indexOf(head.blockId) >= 0) set(TextRangeEnds(anchor, head)) + } + + /** Moves the head to [head], keeping the anchor (Shift+click, Shift+arrows, dragging a handle). */ + public fun extendTo(head: TextPoint) { + current?.let { select(it.anchor, head) } + } + + /** Selects the whole note as text: from the start of its first block to the end of its last. */ + public fun selectAll() { + select(TextPoint(blocks.first().id, 0), TextPoint(blocks.last().id, Int.MAX_VALUE)) + } + + /** + * The start and the end of the selection in the order of the note, as points with offsets kept inside the text + * and, in a list, the item; null without a selection or when an end is in a block that holds no text. + */ + public fun orderedPoints(): Pair? { + val r = resolved() ?: return null + val from = pointAt(r.first, r.firstItem, r.firstOffset) + val to = pointAt(r.last, r.lastItem, r.lastOffset) + val ends = listOfNotNull(from, to) + return if (ends.size == 2) ends[0] to ends[1] else null + } + + private fun pointAt(block: Int, item: Int, offset: Int): TextPoint? = when (val b = blocks[block]) { + is TextualBlock -> TextPoint(b.id, offset) + is Block.ListBlock -> TextPoint(b.id, offset, b.items.value.getOrNull(item)?.id) + else -> null + } + + /** + * Whether the selection runs from one field into another. A cell of a table is a field here too, though a range + * only ever takes a table whole (see [resolved]): what is typed over a selection from a cell into a paragraph + * replaces the range, it does not go into the cell. + */ + public val isCrossField: Boolean + get() = range(trimTables = false)?.let { it.first != it.last || it.firstItem != it.lastItem } == true + + /** + * Shift plus [move] in a field whose own selection runs from [anchor] to [caret] (the same point when it is + * only a cursor), [onEdgeLine] being whether the cursor is on the field's first line (Up) or last (Down). Takes + * the key, true, when it leaves the field or the selection already runs across fields: the end of the selection + * moves. Otherwise false, and the field moves its own selection. + */ + public fun extend(move: HeadMove, anchor: TextPoint, caret: TextPoint, onEdgeLine: Boolean): Boolean { + val across = isCrossField + val leaves = across || leavesField(move, caret, onEdgeLine) + if (leaves && !across) select(anchor, caret) + if (leaves) moveHead(move) + return leaves + } + + /** Moves the end of the selection by [move]; does nothing without a selection. */ + public fun moveHead(move: HeadMove) { + val ends = current ?: return + val head = ends.head + val fields = textFields() + val at = fieldIndex(fields, head) + if (at < 0) return + val offset = head.offset.coerceIn(0, fields[at].text.length) + extendTo(target(move, fields, at, offset)) + } + + private fun target(move: HeadMove, fields: List, at: Int, offset: Int): TextPoint = when (move) { + HeadMove.PREVIOUS -> previous(fields, at, offset) + HeadMove.NEXT -> next(fields, at, offset) + HeadMove.UP -> pointIn(fields, at - 1, if (at > 0) offset else 0) + HeadMove.DOWN -> pointIn(fields, at + 1, if (at < fields.lastIndex) offset else Int.MAX_VALUE) + HeadMove.FIELD_START -> pointIn(fields, at, 0) + HeadMove.FIELD_END -> pointIn(fields, at, Int.MAX_VALUE) + HeadMove.PAGE_UP -> pointIn(fields, at - PAGE_FIELDS, offset) + HeadMove.PAGE_DOWN -> pointIn(fields, at + PAGE_FIELDS, offset) + HeadMove.NOTE_START -> pointIn(fields, 0, 0) + HeadMove.NOTE_END -> pointIn(fields, fields.lastIndex, Int.MAX_VALUE) + } + + private fun previous(fields: List, at: Int, offset: Int): TextPoint = when { + offset > 0 -> pointIn(fields, at, stepped(fields[at].text, offset, -1)) + at > 0 -> pointIn(fields, at - 1, Int.MAX_VALUE) + else -> pointIn(fields, at, 0) + } + + private fun next(fields: List, at: Int, offset: Int): TextPoint = when { + offset < fields[at].text.length -> pointIn(fields, at, stepped(fields[at].text, offset, 1)) + at < fields.lastIndex -> pointIn(fields, at + 1, 0) + else -> pointIn(fields, at, Int.MAX_VALUE) + } + + private fun leavesField(move: HeadMove, caret: TextPoint, onEdgeLine: Boolean): Boolean { + val fields = textFields() + val at = fieldIndex(fields, caret) + val length = fields.getOrNull(at)?.let { it.text.length } ?: 0 + return when (move) { + HeadMove.PREVIOUS -> at > 0 && caret.offset == 0 + HeadMove.NEXT -> at in 0 until fields.lastIndex && caret.offset >= length + HeadMove.UP -> at > 0 && onEdgeLine + HeadMove.DOWN -> at in 0 until fields.lastIndex && onEdgeLine + HeadMove.PAGE_UP, HeadMove.PAGE_DOWN, HeadMove.NOTE_START, HeadMove.NOTE_END -> at >= 0 + HeadMove.FIELD_START, HeadMove.FIELD_END -> false + } + } + + private class TextField(val blockId: String, val itemId: String?, val text: String) + + private fun textFields(): List = blocks.flatMap { block -> + when (block) { + is TextualBlock -> listOf(TextField(block.id, null, block.text.value)) + is Block.ListBlock -> block.items.value.map { TextField(block.id, it.id, it.value) } + else -> emptyList() + } + } + + // The field of [point]; an unnamed point in a list is in its last item. + private fun fieldIndex(fields: List, point: TextPoint): Int { + val named = fields.indexOfFirst { it.blockId == point.blockId && it.itemId == point.itemId } + return if (named >= 0) named else fields.indexOfLast { it.blockId == point.blockId } + } + + private fun pointIn(fields: List, at: Int, offset: Int): TextPoint { + val field = fields[at.coerceIn(0, fields.lastIndex)] + return TextPoint(field.blockId, offset.coerceIn(0, field.text.length), field.itemId) + } + + /** Offset [offset] moved one character in [direction], a surrogate pair counting as one. */ + private fun stepped(text: String, offset: Int, direction: Int): Int { + val next = offset + direction + return if (direction < 0) next.roundedDown(text) else next.roundedUp(text) + } + + /** Forgets the selection. */ + public fun clear() { + set(null) + } + + /** How many blocks the selection touches (for "N blocks selected"); 0 without one. */ + public fun blockCount(): Int = resolved()?.let { it.last - it.first + 1 } ?: 0 + + /** + * The part of the field of block [blockId] (list item [itemId]) of text length [length] that the selection + * covers, as a start and an end offset; null when it covers none of it. + */ + public fun coverOf(blockId: String, itemId: String?, length: Int): Pair? { + val r = resolved() ?: return null + val at = indexOf(blockId) + val block = blocks.getOrNull(at) + val item = if (block is Block.ListBlock) block.items.value.indexOfFirst { it.id == itemId } else -1 + val field = Field(at, item) + val covered = field >= Field(r.first, r.firstItem) && field <= Field(r.last, r.lastItem) + return if (covered) { + val from = if (field == Field(r.first, r.firstItem)) r.firstOffset else 0 + val to = if (field == Field(r.last, r.lastItem)) r.lastOffset.coerceAtMost(length) else length + from.coerceAtMost(to) to to + } else { + null + } + } + + /** + * The selection in the order of the note with its offsets kept inside the text; null without one. A table at an + * end of a selection that runs into another block is part of it only when the selection covers the whole table + * (from the start of its first cell, or to the end of its last): a drag that ends in the middle of a table leaves + * the table alone and selects only what lies outside it. + */ + internal fun resolved(): ResolvedRange? = range(trimTables = true) + + private fun range(trimTables: Boolean): ResolvedRange? { + val ends = current?.takeIf { indexOf(it.anchor.blockId) >= 0 && indexOf(it.head.blockId) >= 0 } + ?: return null + val ordered = ends.ordered().let { if (trimTables) withoutPartialTables(it) else it } + return ordered?.let { placed(it, ends.head) } + } + + // A table at an end of a selection across blocks, covered only in part, is left out: the selection then starts at + // the block after it or ends at the block before it. Null when nothing is left. + private fun withoutPartialTables(ordered: Pair): Pair? { + val (start, end) = ordered + val from = indexOf(start.blockId) + val to = indexOf(end.blockId) + if (from == to) return ordered + val first = if (isPartialTable(from, start, atStart = true)) from + 1 else from + val last = if (isPartialTable(to, end, atStart = false)) to - 1 else to + return if (first > last) { + null + } else { + (if (first == from) start else TextPoint(blocks[first].id, 0)) to + (if (last == to) end else TextPoint(blocks[last].id, Int.MAX_VALUE)) + } + } + + private fun isPartialTable(index: Int, point: TextPoint, atStart: Boolean): Boolean { + val block = blocks[index] as? Block.TableBlock ?: return false + val cells = block.table.value.rows.flatMap { it.cells } + val cell = if (atStart) cells.first() else cells.last() + val inEdgeCell = point.itemId == null || point.itemId == cell.id + val atEdge = if (atStart) point.offset <= 0 else point.offset >= cell.value.length + return !(inEdgeCell && atEdge) + } + + /** [this] as (start, end) in the order of the note. */ + private fun TextRangeEnds.ordered(): Pair { + val a = fieldOf(anchor, isStart = true) + val h = fieldOf(head, isStart = true) + val forward = a < h || (a == h && anchor.offset <= head.offset) + return if (forward) anchor to head else head to anchor + } + + /** Where [point] is: its block and, in a list, its item (the first or, unless [isStart], the last when unnamed). */ + private fun fieldOf(point: TextPoint, isStart: Boolean): Field { + val at = indexOf(point.blockId) + val list = blocks[at] as? Block.ListBlock + val named = list?.let { l -> l.items.value.indexOfFirst { it.id == point.itemId } } ?: -1 + val item = when { + list == null -> -1 + named >= 0 -> named + isStart -> 0 + else -> list.items.value.lastIndex + } + return Field(at, item) + } + + private fun placed(ordered: Pair, head: TextPoint): ResolvedRange { + val (start, end) = ordered + val from = fieldOf(start, isStart = true) + val to = fieldOf(end, isStart = false) + val firstText = textAt(from) + val lastText = textAt(to) + return ResolvedRange( + first = from.block, + firstItem = from.item, + firstOffset = firstText?.let { start.offset.coerceIn(0, it.length).roundedDown(it) } ?: 0, + last = to.block, + lastItem = to.item, + lastOffset = lastText?.let { end.offset.coerceIn(0, it.length).roundedUp(it) } ?: 0, + head = head, + ) + } + + private fun textAt(field: Field): String? = when (val block = blocks[field.block]) { + is TextualBlock -> block.text.value + is Block.ListBlock -> block.items.value.getOrNull(field.item)?.value + else -> null + } + + private fun indexOf(blockId: String): Int = blocks.indexOfFirst { it.id == blockId } +} + +/** A field of the note by position: block [block] and, in a list, item [item] (-1 for any other block). */ +private data class Field(val block: Int, val item: Int) : Comparable { + override fun compareTo(other: Field): Int = compareValuesBy(this, other, Field::block, Field::item) +} + +/** + * A [TextRangeSelection] placed in the note: blocks [first] to [last] (in a list, items [firstItem] and [lastItem], + * else -1), offsets into those two fields (0 if not text), and the [head] as it was given. + */ +@Suppress("LongParameterList") // Both ends of the selection, each as block, item and offset. +internal class ResolvedRange( + val first: Int, + val firstItem: Int, + val firstOffset: Int, + val last: Int, + val lastItem: Int, + val lastOffset: Int, + val head: TextPoint, +) + +/** [this] offset moved back out of the middle of a surrogate pair, so an emoji is never cut in two. */ +private fun Int.roundedDown(text: String): Int = if (splitsPair(text)) this - 1 else this + +/** [this] offset moved forward out of the middle of a surrogate pair: the emoji at the end is selected whole. */ +private fun Int.roundedUp(text: String): Int = if (splitsPair(text)) this + 1 else this + +private fun Int.splitsPair(text: String): Boolean = + this in 1 until text.length && text[this - 1].isHighSurrogate() && text[this].isLowSurrogate() + +/** + * The selected part of the note as blocks of their own, nothing shared with the note: the first and last fields cut + * to their offsets (in a list, the items outside the selection left out), everything between whole. Empty without a + * selection. Hand them to a plain text or Markdown renderer to copy. + */ +public fun BlockEditorState.textRangeBlocks(): List { + val r = textRange.resolved() ?: return emptyList() + return (r.first..r.last).map { index -> + val copy = blocks[index].duplicate() + val from = if (index == r.first) r.firstOffset else 0 + val to = if (index == r.last) r.lastOffset else Int.MAX_VALUE + when (copy) { + is TextualBlock -> copy.text.value = copy.text.value.let { it.substring(from, to.coerceAtMost(it.length)) } + + // The copied items start at the top, whatever level the first one had. + is Block.ListBlock -> copy.items.value = copy.items.value.slice( + firstItem = if (index == r.first) r.firstItem else 0, + firstOffset = from, + lastItem = if (index == r.last) r.lastItem else copy.items.value.lastIndex, + lastOffset = to, + ).normalizedLevels(copy.blockType) + + else -> Unit + } + copy + } +} + +/** Items [firstItem] to [lastItem] of [this], the first cut from [firstOffset] and the last cut at [lastOffset]. */ +private fun List.slice(firstItem: Int, firstOffset: Int, lastItem: Int, lastOffset: Int) = + subList(firstItem, lastItem + 1).mapIndexed { at, item -> + val start = if (at == 0) firstOffset else 0 + val end = if (firstItem + at == lastItem) lastOffset.coerceAtMost(item.value.length) else item.value.length + item.copy(value = item.value.substring(start.coerceAtMost(end), end)) + } + +/** The selected part of the note as plain text (see [plainText]). */ +public fun BlockEditorState.textRangePlainText(): String = textRangeBlocks().plainText() + +/** + * Replaces the selected text with [replacement] ("" deletes it) as one undo step; false without a selection. What + * stays of the first field and of the last join into one around [replacement], and the blocks between are removed. + * The first block's type wins (Notion's rule); where the first block is not text, the last one's does, and where + * neither is, a paragraph holds the result. A list that the selection starts in keeps its earlier items and takes + * the items after the selection's end; one it ends in, with a paragraph before it, stays as the items after the end. + * The cursor goes after [replacement]; undo puts it back at the selection's head, redo after [replacement] again. + * A selection that reaches a locked checklist, at an end or between, changes nothing and gives false, as in a locked + * editor: the lock keeps the list's text out of reach, so it has to be unlocked first (see [canReplaceTextRange]). + */ +public fun BlockEditorState.replaceTextRange(replacement: String): Boolean { + val r = textRange.resolved() + if (r == null || touchesLockedList(r)) return false + val plan = RangePlan(this, r, replacement) + batch { plan.apply(this) } + textRange.clear() + val target = blocks.indexOfFirst { it.id == plan.keeper.id } + attachCaretToLastStep( + CaretTarget(r.head.blockId, r.head.itemId, r.head.offset), + CaretTarget(plan.keeper.id, plan.keeperItem, plan.caret), + ) + selection.focusAt(target, caret = plan.caret, itemId = plan.keeperItem) + return true +} + +/** Whether [replaceTextRange] can change the selection: there is one, and it reaches no locked checklist. */ +public fun BlockEditorState.canReplaceTextRange(): Boolean = + textRange.resolved()?.let { !touchesLockedList(it) } == true + +private fun BlockEditorState.touchesLockedList(r: ResolvedRange): Boolean = + (r.first..r.last).any { index -> blocks[index].let { it is Block.ListBlock && it.locked.value } } + +/** What replacing a selection does: the block that holds the result, its new content and the blocks that go. */ +private class RangePlan(state: BlockEditorState, private val r: ResolvedRange, replacement: String) { + private val blocks = state.blocks + private val first = blocks[r.first] + private val last = blocks[r.last] + private val prefix = fieldText(first, r.firstItem)?.substring(0, r.firstOffset).orEmpty() + private val suffix = fieldText(last, r.lastItem)?.substring(r.lastOffset).orEmpty() + private val joined = prefix + replacement + suffix + private val startsInText = first is TextualBlock || first is Block.ListBlock + private val endsInText = last is TextualBlock || last is Block.ListBlock + private val isNew = !startsInText && !endsInText + + /** The block that holds the result. */ + val keeper: Block = when { + startsInText -> first + endsInText -> last + else -> Block.TextBlock(MutableStateFlow(joined), EditorString.PRESS_TO_EDIT) + } + private val keeperIndex = if (!startsInText && endsInText) r.last else r.first + private val keeperItemIndex = if (keeper === first) r.firstItem else r.lastItem + private val keptList = keeper as? Block.ListBlock + + // Items of the last list after the selection's end; they stay, in the first list or as a list of their own. + private val tail: List = + (last as? Block.ListBlock)?.let { it.items.value.drop(r.lastItem + 1) }.orEmpty() + + /** The list item the cursor goes into; null when the result is not in a list. */ + val keeperItem: String? = keptList?.let { it.items.value[keeperItemIndex].id } + + /** Where the cursor goes in the result, after the replacement. */ + val caret: Int = prefix.length + replacement.length + + fun apply(batch: BlockOperations.Batch) { + // A paragraph that the selection starts in leaves the rest of a list it ends in as a list of its own. + val keepsLast = tail.isNotEmpty() && keeper !== last && keptList == null + when { + keptList != null && keeper === first -> + batch.setValue( + keptList.id, + keptList.items, + (keptList.items.value.take(r.firstItem) + item(keptList) + tail) + .normalizedLevels(keptList.blockType), + ) + + keptList != null -> batch.setValue( + keptList.id, + keptList.items, + (listOf(item(keptList)) + tail).normalizedLevels(keptList.blockType), + ) + + isNew -> batch.replaceBlock(r.first, keeper) + + else -> batch.setValue(keeper.id, (keeper as TextualBlock).text, joined) + } + if (keepsLast) { + val remaining = last as Block.ListBlock + batch.setValue(remaining.id, remaining.items, tail.normalizedLevels(remaining.blockType)) + } + // Highest first: a removal never shifts the position of one still to come. + val gone = (r.first..r.last).filter { it != keeperIndex && !(keepsLast && it == r.last) } + for (index in gone.asReversed()) batch.removeBlock(index) + } + + private fun item(list: Block.ListBlock): ItemListBlock = list.items.value[keeperItemIndex].copy(value = joined) +} + +private fun fieldText(block: Block, item: Int): String? = when (block) { + is TextualBlock -> block.text.value + is Block.ListBlock -> block.items.value.getOrNull(item)?.value + else -> null +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt new file mode 100644 index 00000000..9c72d17c --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt @@ -0,0 +1,198 @@ +package com.pasich.encly.editor.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.unit.dp + +/** + * The icons the editor draws: the block kinds of the slash picker, the link card's globe, the + * "Add link" plus, a checked box's tick and the rows of the block sheets. They carry no colour of + * their own; `Icon` tints them. The defaults are 24 dp line icons (viewBox 24, stroke 1.75, round + * caps and joins); a host may pass its own set. + */ +@Immutable +@Suppress("LongParameterList") // One icon per use. +public class EditorIcons( + internal val text: ImageVector = DefaultIcons.Type, + internal val heading1: ImageVector = DefaultIcons.Heading1, + internal val heading2: ImageVector = DefaultIcons.Heading2, + internal val heading3: ImageVector = DefaultIcons.Heading3, + internal val heading4: ImageVector = DefaultIcons.Heading4, + internal val checklist: ImageVector = DefaultIcons.Checklist, + internal val bulletList: ImageVector = DefaultIcons.ListBullet, + internal val numberedList: ImageVector = DefaultIcons.ListNumbered, + internal val quote: ImageVector = DefaultIcons.Quote, + internal val link: ImageVector = DefaultIcons.Link, + internal val separator: ImageVector = DefaultIcons.Separator, + internal val globe: ImageVector = DefaultIcons.Globe, + internal val plus: ImageVector = DefaultIcons.Plus, + internal val check: ImageVector = DefaultIcons.Check, + internal val external: ImageVector = DefaultIcons.External, + internal val copy: ImageVector = DefaultIcons.Copy, + internal val edit: ImageVector = DefaultIcons.Edit, + internal val arrowUp: ImageVector = DefaultIcons.ArrowUp, + internal val arrowDown: ImageVector = DefaultIcons.ArrowDown, + internal val duplicate: ImageVector = DefaultIcons.Duplicate, + internal val trash: ImageVector = DefaultIcons.Trash, + internal val close: ImageVector = DefaultIcons.Close, + internal val cut: ImageVector = DefaultIcons.Cut, + internal val lock: ImageVector = DefaultIcons.Lock, + internal val lockOpen: ImageVector = DefaultIcons.LockOpen, + internal val grip: ImageVector = DefaultIcons.Grip, + internal val code: ImageVector = DefaultIcons.Code, + internal val table: ImageVector = DefaultIcons.Table, +) + +private const val STROKE = 1.75f + +// Two columns of three dots: the handle of a block. +private const val GRIP_DOTS = "M8.4 6.5a1.1 1.1 0 1 0 2.2 0a1.1 1.1 0 1 0 -2.2 0zM13.4 6.5a1.1 1.1 0 1 0 2.2 0" + + "a1.1 1.1 0 1 0 -2.2 0zM8.4 12a1.1 1.1 0 1 0 2.2 0a1.1 1.1 0 1 0 -2.2 0zM13.4 12a1.1 1.1 0 1 0 2.2 0" + + "a1.1 1.1 0 1 0 -2.2 0zM8.4 17.5a1.1 1.1 0 1 0 2.2 0a1.1 1.1 0 1 0 -2.2 0zM13.4 17.5a1.1 1.1 0 1 0 2.2 0" + + "a1.1 1.1 0 1 0 -2.2 0z" +private val Ink = SolidColor(Color.Black) + +@Suppress("LongMethod") // The path data of every default icon, in one place. +private object DefaultIcons { + val Type = icon("Type", "M5 7V5h14v2", "M12 5v14", "M9.5 19h5") + + val Heading1 = icon("Heading1", "M6 5v14M16 5v14M6 12h10", "M19 13.5l1.5-1v6.5") + + val Heading2 = icon("Heading2", "M6 5v14M16 5v14M6 12h10", "M18 13.9a1.5 1.5 0 0 1 3 .4c0 1.5-3 3.2-3 4.7h3") + + val Heading3 = icon("Heading3", "M6 5v14M16 5v14M6 12h10", "M18 12.5h3l-1.8 2.4a2.1 2.1 0 1 1-1.6 3.7") + + val Heading4 = icon("Heading4", "M6 5v14M16 5v14M6 12h10", "M20.5 19v-6.5L17.8 17h3.7") + + val ListBullet = icon( + "ListBullet", + "M9.5 6.5h11M9.5 12h11M9.5 17.5h11", + dots = "M3.9 6.5a0.9 0.9 0 1 0 1.8 0a0.9 0.9 0 1 0 -1.8 0zM3.9 12a0.9 0.9 0 1 0 1.8 0" + + "a0.9 0.9 0 1 0 -1.8 0zM3.9 17.5a0.9 0.9 0 1 0 1.8 0a0.9 0.9 0 1 0 -1.8 0z", + ) + + val ListNumbered = icon( + "ListNumbered", + "M10 6.5h10.5M10 12h10.5M10 17.5h10.5", + "M4 5l1.5-1v5", + "M3.8 13.2a1.4 1.4 0 0 1 2.6.6c0 1-2.6 2.4-2.6 2.4h2.8", + ) + + val Checklist = icon( + "Checklist", + "M5 4.5H8A1.5 1.5 0 0 1 9.5 6V9A1.5 1.5 0 0 1 8 10.5H5A1.5 1.5 0 0 1 3.5 9V6A1.5 1.5 0 0 1 5 4.5z", + "M5 7.6l1.2 1.2 2-2.2", + "M5 13.5H8A1.5 1.5 0 0 1 9.5 15V18A1.5 1.5 0 0 1 8 19.5H5A1.5 1.5 0 0 1 3.5 18V15A1.5 1.5 0 0 1 5 13.5z", + "M13 7.5h7.5M13 16.5h7.5", + ) + + val Quote = icon("Quote", "M5 18c2.5-.8 4-3 4-6V7H5v5h4", "M14 18c2.5-.8 4-3 4-6V7h-4v5h4") + + val Link = icon( + "Link", + "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1", + "M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1", + ) + + val Separator = icon("Separator", "M3.5 12h3.5M10.25 12h3.5M17 12h3.5") + + val Globe = icon( + "Globe", + "M3.5 12a8.5 8.5 0 1 0 17 0a8.5 8.5 0 1 0 -17 0z", + "M3.5 12h17M12 3.5c2.3 2.4 3.5 5.2 3.5 8.5s-1.2 6.1-3.5 8.5c-2.3-2.4-3.5-5.2-3.5-8.5S9.7 5.9 12 3.5z", + ) + + val Plus = icon("Plus", "M12 5v14M5 12h14") + + val Check = icon("Check", "M5 12.5l4.5 4.5L19 7.5") + + val External = icon( + "External", + "M14 4.5h5.5V10", + "M19.5 4.5L11 13", + "M17 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 4 18.5v-10A1.5 1.5 0 0 1 5.5 7H10", + ) + + val Copy = icon( + "Copy", + "M10.5 8.5H18.5A2 2 0 0 1 20.5 10.5V18.5A2 2 0 0 1 18.5 20.5H10.5A2 2 0 0 1 8.5 18.5V10.5A2 2 0 0 1 10.5 8.5z", + "M15.5 8.5v-3a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h3", + ) + + val Edit = icon("Edit", "M16.5 4.5a2.1 2.1 0 0 1 3 3L8 19l-4 1 1-4z", "M14.5 6.5l3 3") + + val ArrowUp = icon("ArrowUp", "M12 19V5M6 11l6-6 6 6") + + val ArrowDown = icon("ArrowDown", "M12 5v14M6 13l6 6 6-6") + + val Duplicate = icon( + "Duplicate", + "M10.5 8.5H18.5A2 2 0 0 1 20.5 10.5V18.5A2 2 0 0 1 18.5 20.5H10.5A2 2 0 0 1 8.5 18.5V10.5A2 2 0 0 1 10.5 8.5z", + "M15.5 8.5v-3a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h3", + "M14.5 12v5M12 14.5h5", + ) + + val Trash = icon( + "Trash", + "M4 7h16", + "M9.5 7V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2", + "M6 7l.9 12.1a1.5 1.5 0 0 0 1.5 1.4h7.2a1.5 1.5 0 0 0 1.5-1.4L18 7", + "M10 11v5.5M14 11v5.5", + ) + + val Close = icon("Close", "M6 6l12 12M18 6L6 18") + + val Cut = icon( + "Cut", + "M6 3.5a2.5 2.5 0 1 0 0 5a2.5 2.5 0 1 0 0-5z", + "M6 15.5a2.5 2.5 0 1 0 0 5a2.5 2.5 0 1 0 0-5z", + "M7.8 7.6L20 18.5M7.8 16.4L20 5.5", + ) + + val Lock = icon( + "Lock", + "M6.5 10.5h11A1.5 1.5 0 0 1 19 12v7a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 19v-7a1.5 1.5 0 0 1 1.5-1.5z", + "M8 10.5V8a4 4 0 0 1 8 0v2.5", + ) + + val LockOpen = icon( + "LockOpen", + "M6.5 10.5h11A1.5 1.5 0 0 1 19 12v7a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 19v-7a1.5 1.5 0 0 1 1.5-1.5z", + "M8 10.5V8a4 4 0 0 1 7.5-1.9", + ) + + val Grip = icon("Grip", dots = GRIP_DOTS) + + val Code = icon("Code", "M8 8l-4 4 4 4", "M16 8l4 4-4 4", "M13.5 6l-3 12") + + val Table = + icon( + "Table", + "M4 6.5A1.5 1.5 0 0 1 5.5 5h13A1.5 1.5 0 0 1 20 6.5v11a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z", + "M4 10.5h16M4 14.8h16M10 5v14", + ) + + private fun icon(name: String, vararg paths: String, dots: String? = null): ImageVector = ImageVector.Builder( + name = "Editor.$name", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + paths.forEach { data -> + addPath( + pathData = addPathNodes(data), + stroke = Ink, + strokeLineWidth = STROKE, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) + } + dots?.let { addPath(pathData = addPathNodes(it), fill = Ink) } + }.build() +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorTheme.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorTheme.kt new file mode 100644 index 00000000..862ad1cf --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorTheme.kt @@ -0,0 +1,107 @@ +package com.pasich.encly.editor.theme + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * What the editor looks like beyond the host's Material theme (colors, shapes and the base type + * scale come from `MaterialTheme`): sizes, the writing fonts, the line spacing, two text styles and + * the icons. The host maps its design system onto it and provides it as [LocalEditorTheme]; an + * editor without a provided theme uses the defaults, which are what the Encly app ships. + */ +@Immutable +public data class EditorTheme( + val spacing: EditorSpacing = EditorSpacing(), + val fontSizes: EditorFontSizes = EditorFontSizes(), + val fontFamilies: EditorFontFamilies = EditorFontFamilies(), + val lineSpacing: EditorLineSpacing = EditorLineSpacing.DEFAULT, + /** The text style of a quote block. */ + val quoteStyle: TextStyle = TextStyle(fontStyle = FontStyle.Italic, fontSize = 19.sp, lineHeight = 27.sp), + /** The small figures style (a numbered list's numbers). */ + val dataStyle: TextStyle = TextStyle(fontSize = 12.sp, lineHeight = 16.sp), + val icons: EditorIcons = EditorIcons(), +) + +/** The editor's theme; see [EditorTheme]. */ +public val LocalEditorTheme: ProvidableCompositionLocal = staticCompositionLocalOf { EditorTheme() } + +/** Spacing and sizes on a 4 dp base. */ +@Immutable +@Suppress("LongParameterList") // One named token each. +public data class EditorSpacing( + val xxs: Dp = 4.dp, + val xs: Dp = 8.dp, + val s: Dp = 12.dp, + val m: Dp = 16.dp, + val l: Dp = 24.dp, + /** Gap between a title and its supporting line. */ + val textGap: Dp = 2.dp, + /** Gap between a label and its field. */ + val labelGap: Dp = 6.dp, + /** Gap between a leading icon and the text of a row. */ + val rowGap: Dp = 14.dp, + /** Gap between stacked form fields. */ + val fieldGap: Dp = 18.dp, + /** Hairlines. */ + val hairline: Dp = 1.dp, + /** Focus rings and checkbox borders. */ + val stroke: Dp = 2.dp, + val minTouchTarget: Dp = 48.dp, + val buttonHeight: Dp = 52.dp, + /** Minimum height of a sheet row. */ + val rowHeight: Dp = 56.dp, + val icon: Dp = 24.dp, + val iconMedium: Dp = 22.dp, + val iconSmall: Dp = 20.dp, + /** A link card's globe tile. */ + val tileSmall: Dp = 40.dp, + /** Side padding of a block sheet. */ + val gutter: Dp = 24.dp, + /** The empty area under the last block; a tap there adds a block. */ + val tapArea: Dp = 200.dp, +) + +/** Font sizes of the block kinds, already scaled by the host's text size setting. */ +@Immutable +public data class EditorFontSizes( + val textBlock: TextUnit = 16.sp, + val h1: TextUnit = 28.sp, + val h2: TextUnit = 24.sp, + val h3: TextUnit = 22.sp, + val h4: TextUnit = 20.sp, + val quote: TextUnit = 18.sp, + val list: TextUnit = 16.sp, +) + +/** The fonts of headings and of body text. */ +@Immutable +public data class EditorFontFamilies( + val heading: FontFamily = FontFamily.Default, + val body: FontFamily = FontFamily.Default, +) + +private val BlockBleed = 8.dp +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. + */ +internal fun Modifier.editorBlockFrame(verticalInset: Dp?): Modifier = + padding(horizontal = BlockBleed, vertical = verticalInset ?: BlockInset) + +/** Room the host's toolbar takes above the keyboard; floating menus keep clear of it. */ +internal val EditorToolbarReserve = 72.dp + +/** How faint a placeholder is drawn. */ +internal const val EDITOR_PLACEHOLDER_ALPHA = 0.38f diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/NoteSpacing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/NoteSpacing.kt new file mode 100644 index 00000000..59a9ed6a --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/NoteSpacing.kt @@ -0,0 +1,86 @@ +package com.pasich.encly.editor.theme + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// 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 const val DEFAULT_LINE_FACTOR = 1.5f +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 + +/** How tightly the editor sets its lines and blocks; the host maps its own setting onto it. */ +public enum class EditorLineSpacing { COMPACT, DEFAULT, COMFORTABLE } + +/** Whether lines sit as close as the font allows (no leading, no gaps between blocks). */ +public val EditorLineSpacing.isCompact: Boolean get() = this == EditorLineSpacing.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. A text larger than the theme's body grows its line + * with it (1.5× the size, the theme's own ratio at 16sp), so big text never overruns its line. + */ +internal fun EditorLineSpacing.lineHeight(fontSize: TextUnit, normal: TextUnit): TextUnit { + val default = maxOf(normal.value, fontSize.value * DEFAULT_LINE_FACTOR).sp + return when (this) { + EditorLineSpacing.COMPACT -> fontSize + EditorLineSpacing.DEFAULT -> default + EditorLineSpacing.COMFORTABLE -> default * COMFORTABLE_LINE_FACTOR + } +} + +/** Vertical padding inside the frame of a paragraph, list or quote. */ +internal val EditorLineSpacing.frameInset: Dp + get() = when (this) { + EditorLineSpacing.COMPACT -> 0.dp + EditorLineSpacing.DEFAULT -> FRAME_INSET_DEFAULT + EditorLineSpacing.COMFORTABLE -> FRAME_INSET_COMFORTABLE + } + +/** The gap between the editor's blocks, given the default gap [base]. */ +public fun EditorLineSpacing.blockGap(base: Dp): Dp = when (this) { + EditorLineSpacing.COMPACT -> 0.dp + EditorLineSpacing.DEFAULT -> base + EditorLineSpacing.COMFORTABLE -> base + BLOCK_GAP_EXTRA_COMFORTABLE +} + +/** The gap between the rows of a list; a checklist's rows are tall enough already. */ +internal fun EditorLineSpacing.listItemGap(checklist: Boolean, base: Dp): Dp = when { + this == EditorLineSpacing.COMPACT || (checklist && this == EditorLineSpacing.DEFAULT) -> 0.dp + this == EditorLineSpacing.COMFORTABLE -> LIST_ITEM_GAP_COMFORTABLE + else -> base +} + +// A list's marker follows the text: the checkbox and the gap after it grow with the font size, and +// bullets and numbers use the same column, so every kind of list lines its text up the same way. +private const val MARKER_BOX_PER_FONT = 1.1f +private const val MARKER_GAP_PER_FONT = 0.375f +private const val LIST_INDENT_PER_FONT = 0.75f +private val MARKER_BOX_MIN = 16.dp +private val MARKER_BOX_MAX = 28.dp +private val MARKER_GAP_MIN = 6.dp +private val MARKER_GAP_MAX = 14.dp +private val LIST_INDENT_MIN = 10.dp +private val LIST_INDENT_MAX = 18.dp + +/** The checkbox side for list text of [font] size: 1.1 times it, from 16 to 28 dp. */ +internal fun markerBoxSize(font: Dp): Dp = (font * MARKER_BOX_PER_FONT).coerceIn(MARKER_BOX_MIN, MARKER_BOX_MAX) + +/** The gap between the marker and the text for list text of [font] size: 3/8 of it, from 6 to 14 dp. */ +internal fun markerGap(font: Dp): Dp = (font * MARKER_GAP_PER_FONT).coerceIn(MARKER_GAP_MIN, MARKER_GAP_MAX) + +/** The marker column of a list: the box and the gap after it. */ +internal fun markerColumn(font: Dp): Dp = markerBoxSize(font) + markerGap(font) + +/** + * How far a whole list (its markers and its text together) sits in from the text column, for list text of [font] + * size: three quarters of it, from 10 to 18 dp. A list then reads as under the heading or paragraph above it, as + * a rendered Markdown list does, instead of its marker standing under that text with a wide gap before its own. + */ +internal fun listIndent(font: Dp): Dp = (font * LIST_INDENT_PER_FONT).coerceIn(LIST_INDENT_MIN, LIST_INDENT_MAX) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt new file mode 100644 index 00000000..682d0415 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt @@ -0,0 +1,155 @@ +package com.pasich.encly.editor.ui + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.CaretMove +import com.pasich.encly.editor.state.FocusRequest +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.SlashMenu +import com.pasich.encly.editor.state.TextRangeSelection +import kotlinx.coroutines.flow.StateFlow + +/** + * What a block composable can do. Content edits go through [onTextChanged], + * [onListItemsChanged] and [onLinkChanged], never by writing the block's state directly, + * so every edit is saved and can be undone. + */ +@Suppress("TooManyFunctions") // Everything a block's fields can ask of the editor, in one place. +internal interface BlockActions { + fun onAddParagraph() + fun onRemoveBlock(blockRemoveAction: BlockRemoveAction) + fun onReplaceBlock(type: Block) + fun onTextChanged(newText: String) + + /** [mergeable] is true for typing, false for discrete edits (check, add/remove item). */ + fun onListItemsChanged(newItems: List, mergeable: Boolean) + fun onLinkChanged(newLink: LinkDataBlock) + + /** + * A line break typed (Enter) or pasted into this block's field. + * @return the text the field keeps; null to keep the break as text + */ + fun onLineBreak(lineBreak: LineBreak): String? + + /** + * [pasted] went into this block's field (item [itemId] of a list), between [head] and [tail]: when it is exactly + * what the last copy or cut put on the clipboard, the blocks it was made of go in instead. + * @return the text the field keeps; null when it is not that text (the field keeps the paste as it is) + */ + fun onPasteCopied(itemId: String?, head: String, pasted: String, tail: String): String? + + /** Backspace with the cursor at the start of this block's field; false when it does nothing. */ + fun onBackspaceAtStart(): Boolean + + /** A mark typed at the start of this paragraph, which now reads [typed]; true when applied. */ + fun onShortcut(typed: String, shortcut: Shortcut): Boolean + + /** A lone web address pasted into this empty paragraph; true when it became [link]. */ + fun onLinkPasted(pasted: String, link: LinkDataBlock): Boolean + + /** [onLineBreak] in item [itemId] of this list. */ + fun onListLineBreak(itemId: String, lineBreak: LineBreak): String? + + /** [onBackspaceAtStart] in item [itemId] of this list. */ + fun onListBackspaceAtStart(itemId: String): Boolean + + /** + * A cursor key leaves this block's field ([move]): focuses the next or previous block that has a + * field, the cursor placed by the key. False when there is none (or it is not on screen), and the + * key is then left to the field. Nothing is ever added by it. + */ + fun navigate(move: CaretMove): Boolean + + /** + * Registers how to put the cursor of this block's field at a horizontal position (on its last + * line when it arrives from below, else its first). + * @return a function that removes the registration + */ + fun registerCaretAtX(callback: (Float, Boolean) -> Unit): () -> Unit + + /** The id of this block. */ + val blockId: String + + /** Indents ([deeper]) or outdents item [itemId] of this list one level; false when it cannot. */ + fun onListItemShift(itemId: String, deeper: Boolean): Boolean + + /** Moves item [itemId] of this list [by] places among its siblings (up is negative); false when it cannot. */ + fun onListItemMove(itemId: String, by: Int): Boolean + + /** The text selection across blocks. */ + val textRange: TextRangeSelection + + /** + * Ctrl or Cmd+A in a field whose text is all selected already: selects the whole note's text. + * False when that is not offered (the key is then left to the field). + */ + fun selectAllText(): Boolean + + /** Replaces the text selection across blocks with [replacement]; false when it cannot (the field then edits). */ + fun replaceTextRange(replacement: String): Boolean + + /** Copies the text selection across blocks; true when it did. */ + fun copyTextRange(markdown: Boolean): Boolean + + /** Cuts the text selection across blocks; true when it did. */ + fun cutTextRange(): Boolean + + /** The cursor in item [itemId] of this list moved to [offset]. */ + fun onListItemCaret(itemId: String, offset: Int) + + /** The cursor in this block's field moved to [offset]. */ + fun onCaretMoved(offset: Int) + + /** + * Registers how to put the cursor of this block's field at an offset ([Int.MAX_VALUE]: the end). + * @return a function that removes the registration + */ + fun registerCaret(callback: (Int) -> Unit): () -> Unit + + /** + * Registers how to focus a block with several fields (a list): the item a request names, + * else its last field with the cursor at the end when asked for the end, else its first. + * @return a function that removes the registration + */ + 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) +} + +/** What a paragraph's slash picker can do. The editor decides whether it may open (Simple Edit, read-only). */ +internal 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/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt new file mode 100644 index 00000000..e7cf5ddc --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt @@ -0,0 +1,139 @@ +package com.pasich.encly.editor.ui + +import com.pasich.encly.editor.focus.BlockFocusRegistry +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.CaretMove +import com.pasich.encly.editor.state.FocusRequest +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.SlashMenu +import com.pasich.encly.editor.state.TextRangeSelection +import com.pasich.encly.editor.state.goesUp +import com.pasich.encly.editor.state.requestFor +import kotlinx.coroutines.flow.StateFlow + +/** + * Block actions bound to one [block]. Its position is looked up when an action runs, so an + * action never targets whatever block happens to sit at a stale index. Focus moves go through + * [focusRegistry], which owns the fields' FocusRequesters. + */ +@Suppress("TooManyFunctions") // Implements BlockActions, one editor call each. +internal class BlockActionsImpl( + private val block: Block, + private val controller: EditorController, + private val focusRegistry: BlockFocusRegistry, + override val highlights: FindHighlights, +) : BlockActions { + private val index: Int get() = controller.indexOfBlock(block) + + override fun onAddParagraph() { + val current = index + if (current >= 0) controller.addBlockAfter(current, BlockType.TEXT) + } + + override fun onRemoveBlock(blockRemoveAction: BlockRemoveAction) { + controller.removeBlock(block, blockRemoveAction) + } + + override fun onReplaceBlock(type: Block) { + val current = index + if (current >= 0) controller.replaceBlock(current, type) + } + + override fun onTextChanged(newText: String) { + controller.onBlockTextChanged(block, newText) + } + + override fun onListItemsChanged(newItems: List, mergeable: Boolean) { + if (block is Block.ListBlock) controller.onListItemsChanged(block, newItems, mergeable) + } + + override fun onLinkChanged(newLink: LinkDataBlock) { + if (block is Block.LinkBlock) controller.onLinkChanged(block, newLink) + } + + override fun onLineBreak(lineBreak: LineBreak): String? = controller.onLineBreak(block, lineBreak) + + override fun onPasteCopied(itemId: String?, head: String, pasted: String, tail: String): String? = + controller.onPasteCopied(block, itemId, head, pasted, tail) + + override fun onBackspaceAtStart(): Boolean = controller.onBackspaceAtStart(block) + + override fun onShortcut(typed: String, shortcut: Shortcut): Boolean = controller.onShortcut(block, typed, shortcut) + + override fun onLinkPasted(pasted: String, link: LinkDataBlock): Boolean = + controller.onLinkPasted(block, pasted, link) + + override fun onListLineBreak(itemId: String, lineBreak: LineBreak): String? = + (block as? Block.ListBlock)?.let { controller.onListLineBreak(it, itemId, lineBreak) } + + override fun onListBackspaceAtStart(itemId: String): Boolean = + (block as? Block.ListBlock)?.let { controller.onListBackspaceAtStart(it, itemId) } == true + + override val blockId: String get() = block.id + + override fun onListItemShift(itemId: String, deeper: Boolean): Boolean = + (block as? Block.ListBlock)?.let { controller.onListItemShift(it, itemId, deeper) } == true + + override fun onListItemMove(itemId: String, by: Int): Boolean = + (block as? Block.ListBlock)?.let { controller.onListItemMove(it, itemId, by) } == true + + override val textRange: TextRangeSelection get() = controller.textRange + + override fun selectAllText(): Boolean = controller.selectAllText() + + override fun replaceTextRange(replacement: String): Boolean = controller.replaceTextRange(replacement) + + override fun copyTextRange(markdown: Boolean): Boolean = controller.copyTextRange(markdown) + + override fun cutTextRange(): Boolean = controller.cutTextRange() + + override fun onCaretMoved(offset: Int) = controller.onCaretMoved(block, offset) + + override fun onListItemCaret(itemId: String, offset: Int) = controller.onListItemCaret(block, itemId, offset) + + override fun registerCaret(callback: (Int) -> Unit): () -> Unit = focusRegistry.registerCaret(block.id, callback) + + 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() = controller.slashPicker.menu + + override fun commit() = controller.slashPicker.commit() + + override fun select(command: SlashCommand) { + controller.applySlash(block, command) + } + + override fun dismiss() = controller.slashPicker.dismiss() + + override fun moveHighlight(delta: Int) = controller.slashPicker.moveHighlight(delta) + + override fun setLabels(labels: Map) = controller.slashPicker.setLabels(labels) + } + + override fun onSlashTyped(text: String, started: Boolean) = controller.onSlashTyped(block, text, started) + + override fun navigate(move: CaretMove): Boolean { + val target = if (move.goesUp) { + focusRegistry.previousFocusableBlock(block.id) + } else { + focusRegistry.nextFocusableBlock(block.id) + } + // Only a field that is on screen can be focused; otherwise the key stays with the field. + val moved = target != null && focusRegistry.focus(move.requestFor(target.id)) + if (moved) controller.onBlockFocused(target!!) + return moved + } + + override fun registerCaretAtX(callback: (Float, Boolean) -> Unit): () -> Unit = + focusRegistry.registerCaretAtX(block.id, callback) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockChrome.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockChrome.kt new file mode 100644 index 00000000..5f40b43a --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockChrome.kt @@ -0,0 +1,185 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.testTag +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 com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.blocks.BlockSelectAllIme +import com.pasich.encly.editor.ui.blocks.ReorderHandleWidth +import com.pasich.encly.editor.ui.components.EditorTooltip +import com.pasich.encly.editor.ui.components.editorInteractive + +/** The test tag of the lock toggle at the end of a checklist. */ +internal const val CHECKLIST_LOCK_TAG = "checklist-lock-toggle" + +/** The width of the strip at a block's start (the page gutter) that a long press selects the block from. */ +private val HandleWidth = 24.dp +private val GripSize = 18.dp +private val LockSize = 28.dp +private val HighlightInset = 8.dp +private val HighlightRadius = 8.dp +private const val HIGHLIGHT_ALPHA = 0.16f +private const val GRIP_ALPHA = 0.5f + +/** + * What goes around a block on screen, outside its own content: the highlight of a picked block, a handle in the + * page gutter at its start (a long press picks the block; a grip marks it on the block the user works on), the + * lock toggle at the end of a checklist the user works on, and the keyboard's Select all. While blocks are being + * picked a touch anywhere on a block picks or lets go of it, and its text takes no touch. Nothing here changes the + * block's layout. + */ +@Composable +internal fun BlockChrome( + block: Block, + controller: EditorController, + isLocked: Boolean, + content: @Composable () -> Unit, +) { + val selection = controller.selectedBlocks + val ids by selection.ids.collectAsState() + val picked = block.id in ids + val picking = ids.isNotEmpty() + BlockSelectAllIme(onSelectAll = controller::selectAllText) { + Box( + modifier = Modifier + .fillMaxWidth() + .pickedBackground(picked, MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)) + .semantics { selected = picked }, + ) { + content() + if (picking) { + PickOverlay(picked) { selection.toggle(block.id) } + } else { + val worksOn = controller.interactedBlockId.collectAsState().value == block.id + BlockHandle(worksOn) { selection.select(block.id) } + if (worksOn && !isLocked) ChecklistLockToggle(block, controller) + } + } + } +} + +/** Draws [color] behind a picked block, a little inside the page edges. */ +private fun Modifier.pickedBackground(picked: Boolean, color: Color): Modifier = if (!picked) { + this +} else { + drawBehind { + val inset = HighlightInset.toPx() + drawRoundRect( + color = color, + topLeft = Offset(inset, 0f), + size = Size(size.width - 2 * inset, size.height), + cornerRadius = CornerRadius(HighlightRadius.toPx()), + ) + } +} + +/** Covers the block while blocks are being picked: a tap picks it or lets it go; TalkBack reads its state. */ +@Composable +private fun BoxScope.PickOverlay(picked: Boolean, onToggle: () -> Unit) { + Box( + Modifier + .matchParentSize() + .toggleable( + value = picked, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Checkbox, + onValueChange = { onToggle() }, + ), + ) +} + +/** The strip at the start of a block that a long press picks the block from; [marked] draws a grip in it. */ +@Composable +private fun BoxScope.BlockHandle(marked: Boolean, onSelect: () -> Unit) { + // The strip is as tall as the block: an outer box takes the block's size, which a child cannot ask for. + Box(Modifier.matchParentSize()) { + Box( + Modifier + .width(HandleWidth) + .fillMaxHeight() + .pointerInput(onSelect) { detectTapGestures(onLongPress = { onSelect() }) }, + ) { + if (marked) { + Icon( + LocalEditorTheme.current.icons.grip, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = GRIP_ALPHA), + modifier = Modifier.align(Alignment.TopCenter).padding(top = 4.dp).size(GripSize), + ) + } + } + } +} + +/** + * The lock of a checklist, at the end of its first item while the user works in it: the same lock as the block's + * menu entry. It shows the state the checklist is in, and a press changes it. Nothing for any other block. + */ +@Composable +private fun BoxScope.ChecklistLockToggle(block: Block, controller: EditorController) { + val checklist = (block as? Block.ListBlock)?.takeIf { it.blockType == BlockType.LIST_CHECK } ?: return + val locked by checklist.locked.collectAsState() + val items by checklist.items.collectAsState() + val strings = LocalEditorStrings.current + val icons = LocalEditorTheme.current.icons + val colors = MaterialTheme.colorScheme + EditorTooltip( + label = strings.text(if (locked) EditorString.CHECKLIST_UNLOCK else EditorString.CHECKLIST_LOCK), + // Beside the drag handle of the first item, not over it. + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = if (items.size > 1 && !locked) ReorderHandleWidth else 0.dp), + ) { + Surface( + onClick = { controller.toggleChecklistLock(checklist) }, + shape = CircleShape, + color = if (locked) colors.primaryContainer else colors.surfaceContainerHigh, + contentColor = if (locked) colors.onPrimaryContainer else colors.onSurfaceVariant, + modifier = Modifier.editorInteractive(CircleShape).size(LockSize).testTag(CHECKLIST_LOCK_TAG), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + if (locked) icons.lock else icons.lockOpen, + contentDescription = strings.text( + if (locked) EditorString.CHECKLIST_UNLOCK else EditorString.CHECKLIST_LOCK, + ), + modifier = Modifier.size(LocalEditorTheme.current.spacing.iconSmall - 4.dp), + ) + } + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockFocusHooks.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockFocusHooks.kt new file mode 100644 index 00000000..c21b4137 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockFocusHooks.kt @@ -0,0 +1,25 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.FocusRequester +import com.pasich.encly.editor.LocalEditorLogger +import com.pasich.encly.editor.focus.BlockFocusRegistry +import com.pasich.encly.editor.model.Block + +/** The focus registry of one editor showing [blocks] (its live block list). */ +@Composable +public fun rememberBlockFocusRegistry(blocks: List): BlockFocusRegistry { + val logger = LocalEditorLogger.current + return remember(blocks, logger) { BlockFocusRegistry(logger) { blocks } } +} + +/** Registers [focusRequester], the field of block [blockId], while it is composed. */ +@Composable +internal fun RegisterFocusRequester(blockId: String, registry: BlockFocusRegistry, focusRequester: FocusRequester) { + DisposableEffect(blockId, registry, focusRequester) { + val unregister = registry.registerFocusTarget(blockId) { focusRequester.requestFocus() } + onDispose { unregister() } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockMoveKeys.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockMoveKeys.kt new file mode 100644 index 00000000..bec764ea --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockMoveKeys.kt @@ -0,0 +1,28 @@ +package com.pasich.encly.editor.ui + +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.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type + +/** + * Whether [event] moves the focused block: Ctrl or ⌘ with Shift and the Up arrow moves it up + * (returns true), the Down arrow moves it down (false), anything else is null. The chord replaces the + * text field's "select to the start / end", which a block editor has no use for: the selection ends at + * the block anyway. Plain arrows, Alt and the plain Shift+arrows are left to the field. + */ +internal fun blockMoveDirection(event: KeyEvent): Boolean? { + val chord = event.type == KeyEventType.KeyDown && + (event.isCtrlPressed || event.isMetaPressed) && event.isShiftPressed && !event.isAltPressed + return when { + !chord -> null + event.key == Key.DirectionUp -> true + event.key == Key.DirectionDown -> false + else -> null + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt new file mode 100644 index 00000000..fcf835a7 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt @@ -0,0 +1,410 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.focus.BlockFocusRegistry +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.theme.editorBlockFrame +import com.pasich.encly.editor.theme.frameInset +import com.pasich.encly.editor.ui.blocks.CodeBlock +import com.pasich.encly.editor.ui.blocks.HBlock +import com.pasich.encly.editor.ui.blocks.LinkBlock +import com.pasich.encly.editor.ui.blocks.ListBlock +import com.pasich.encly.editor.ui.blocks.QuoteBlock +import com.pasich.encly.editor.ui.blocks.ReadingDirection +import com.pasich.encly.editor.ui.blocks.SeparatorBlock +import com.pasich.encly.editor.ui.blocks.TableBlock +import com.pasich.encly.editor.ui.blocks.TextBlock +import com.pasich.encly.editor.ui.components.ContextEntry +import com.pasich.encly.editor.ui.components.EditorContextMenu +import com.pasich.encly.editor.ui.sheets.BlockSheetKind +import kotlinx.coroutines.flow.MutableStateFlow + +/** What the editor's blocks need from the screen that shows them. */ +public class EditorBlocksHost( + internal val controller: EditorController, + internal val focusRegistry: BlockFocusRegistry, + internal val highlights: FindHighlights, + internal val onOpenSheet: (BlockSheetKind, Block) -> Unit, +) + +/** + * The note's blocks as lazy items, one per block keyed by its id, so a long note composes and + * 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; [afterBlocks], if given, is shown + * between the last block and that area. + */ +public fun LazyListScope.editorBlocks( + host: EditorBlocksHost, + blocks: List, + isLocked: Boolean, + editingLinkIds: Set, + afterBlocks: (@Composable () -> Unit)? = null, +) { + host.highlights.textRange = host.controller.textRange + itemsIndexed( + items = blocks, + key = { _, block -> block.id }, + contentType = { _, block -> block::class.simpleName }, + ) { index, block -> + val blockActions = + remember(block, host) { BlockActionsImpl(block, host.controller, host.focusRegistry, host.highlights) } + val callbacks = remember(block, host) { + EditorBlockCallbacks( + onFocus = { host.controller.onBlockFocused(block) }, + onInteract = { host.controller.onBlockInteraction(block) }, + onToggleLock = { host.controller.toggleChecklistLock(block) }, + onFocusLost = { host.controller.onBlockFocusLost(block) }, + onOpenSheet = { type -> + host.controller.onBlockInteraction(block) + host.onOpenSheet(type, block) + }, + onMove = { up -> host.controller.moveBlockOf(block, up) }, + onDuplicate = { host.controller.duplicateBlock(block) }, + onDelete = { host.controller.removeBlock(block, BlockRemoveAction.REMOVE) }, + onSelect = { host.controller.selectedBlocks.select(block.id) }, + onTableChange = { table, mergeable -> + (block as? Block.TableBlock)?.let { host.controller.onTableChanged(it, table, mergeable) } + }, + ) + } + BlockChrome(block, host.controller, isLocked) { + EditorBlock( + block = block, + blockActions = blockActions, + callbacks = callbacks, + focusRegistry = host.focusRegistry, + isLocked = isLocked, + isEditingLink = block.id in editingLinkIds, + isOnlyBlock = blocks.size == 1, + position = BlockPosition(first = index == 0, last = index == blocks.lastIndex), + ) + } + } + + // 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) { + val keyboardController = LocalSoftwareKeyboardController.current + Spacer( + Modifier + .height(LocalEditorTheme.current.spacing.tapArea) + .fillMaxWidth() + .clickable( + enabled = !isLocked, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + host.controller.addBlockToEnd() + // The last field may still hold focus with the keyboard hidden: focusing it again shows nothing. + keyboardController?.show() + }, + ) + } +} + +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. */ +@Suppress("LongParameterList") // One callback per thing a block reports or is asked to do. +private class EditorBlockCallbacks( + val onFocus: () -> Unit, + val onInteract: () -> Unit, + val onToggleLock: () -> Unit, + val onFocusLost: () -> Unit, + val onOpenSheet: (BlockSheetKind) -> Unit, + val onMove: (up: Boolean) -> Unit, + val onDuplicate: () -> Unit, + val onDelete: () -> Unit, + val onSelect: () -> Unit, + val onTableChange: (TableData, mergeable: Boolean) -> Unit, +) + +/** Where a block is in the note: TalkBack offers to move it only where it can go. */ +private data class BlockPosition(val first: Boolean, val last: Boolean) + +@Composable +private fun EditorBlock( + block: Block, + blockActions: BlockActions, + callbacks: EditorBlockCallbacks, + focusRegistry: BlockFocusRegistry, + position: BlockPosition, + isLocked: Boolean = false, + isEditingLink: Boolean = false, + isOnlyBlock: Boolean = false, +) { + val theme = LocalEditorTheme.current + val focusRequester = remember { FocusRequester() } + // A list or a table registers its own target: it focuses one of its items or cells. + if (!block.hasItemFields()) RegisterFocusRequester(block.id, focusRegistry, focusRequester) + // Whether the block is being edited: its placeholder shows then. + var hasFocus by remember { mutableStateOf(false) } + + val checklist = (block as? Block.ListBlock)?.takeIf { it.blockType == BlockType.LIST_CHECK } + val lockedFlow = remember(checklist) { checklist?.locked ?: MutableStateFlow(false) } + val checklistLocked by lockedFlow.collectAsState() + val commands = blockCommands(callbacks, position, isOnlyBlock, checklist?.let { checklistLocked }) + // The block's actions for TalkBack, for the keyboard (Ctrl / Cmd+Shift+Up and Down move it) and for a + // right click on a desktop. + EditorContextMenu( + entries = { commands.map { ContextEntry(it.label, it.icon, it.destructive, it.run) } }, + enabled = !isLocked, + // A separator and a link card have no text to select: the menu takes their right click. A text + // field keeps its own (cut, copy, paste). + claimPress = block.hasNoText(), + ) { + Box( + modifier = Modifier + .onPreviewKeyEvent { event -> moveBlockByKey(event, callbacks, position, isLocked) } + // A touch anywhere in a list makes it the block the toolbar acts on, even where its text + // cannot take focus (a locked checklist), so its lock can be undone from the toolbar. + .then(if (block is Block.ListBlock) Modifier.markInteractionOn(callbacks.onInteract) else Modifier) + // Blocks carry an 8 dp frame of their own: they sit that much inside the gutter. + .padding(horizontal = theme.spacing.m) + .onFocusChanged { focusState -> + hasFocus = focusState.hasFocus + // A list has one field per item, a table one per cell: any of them focused counts as the + // block focused, so a new block goes after it and not after the block focused before it. + val focused = if (block.hasItemFields()) focusState.hasFocus else focusState.isFocused + when { + focused -> callbacks.onFocus() + !focusState.hasFocus -> callbacks.onFocusLost() + } + } + .editorBlockFrame( + theme.lineSpacing.frameInset.takeIf { + block is Block.TextBlock || block is Block.ListBlock || block is Block.QuoteBlock + }, + ), + ) { + BlockContent( + block = block, + blockActions = blockActions, + focusRequester = focusRequester, + state = BlockContentState( + isLocked = isLocked, + isEditingLink = isEditingLink, + showPlaceholder = !isLocked && (hasFocus || isOnlyBlock), + onTableChange = callbacks.onTableChange, + ), + onOpenSheet = callbacks.onOpenSheet, + modifier = if (isLocked) Modifier else blockAccessibilityActions(commands), + ) + } + } +} + +/** A list has a field per item and a table one per cell: they register their own focus target. */ +private fun Block.hasItemFields() = this is Block.ListBlock || this is Block.TableBlock + +/** A separator and a link card have no text field of their own to keep a right click for. */ +private fun Block.hasNoText() = this is Block.SeparatorBlock || this is Block.LinkBlock + +/** Moves the focused block when [event] is the move chord and the block can go that way; true when it did. */ +private fun moveBlockByKey( + event: KeyEvent, + callbacks: EditorBlockCallbacks, + position: BlockPosition, + isLocked: Boolean, +): Boolean { + val up = blockMoveDirection(event)?.takeIf { !isLocked } ?: return false + val canMove = if (up) !position.first else !position.last + if (canMove) callbacks.onMove(up) + return canMove +} + +/** One thing to do with a block: what TalkBack offers and the context menu lists. */ +private class BlockCommand(val label: String, val icon: ImageVector, val destructive: Boolean, val run: () -> Unit) + +/** + * The block's commands: move it up or down (only where it can go), duplicate it, delete it (not the only + * block) and, for a checklist, lock or unlock it. Without them a TalkBack user could only reach these + * through the toolbar's "Add block" row. + */ +@Composable +private fun blockCommands( + callbacks: EditorBlockCallbacks, + position: BlockPosition, + isOnlyBlock: Boolean, + checklistLocked: Boolean?, +): List { + val strings = LocalEditorStrings.current + val icons = LocalEditorTheme.current.icons + return buildList { + if (!position.first) { + add(BlockCommand(strings.text(EditorString.BLOCK_MOVE_UP), icons.arrowUp, false) { callbacks.onMove(true) }) + } + if (!position.last) { + add( + BlockCommand(strings.text(EditorString.BLOCK_MOVE_DOWN), icons.arrowDown, false) { + callbacks.onMove(false) + }, + ) + } + add( + BlockCommand(strings.text(EditorString.BLOCK_DUPLICATE), icons.duplicate, false) { + callbacks.onDuplicate() + }, + ) + add(BlockCommand(strings.text(EditorString.BLOCK_SELECT), icons.grip, false) { callbacks.onSelect() }) + if (!isOnlyBlock) { + add(BlockCommand(strings.text(EditorString.BLOCK_DELETE), icons.trash, true) { callbacks.onDelete() }) + } + if (checklistLocked != null) { + val lock = if (checklistLocked) EditorString.CHECKLIST_UNLOCK else EditorString.CHECKLIST_LOCK + add(BlockCommand(strings.text(lock), icons.checklist, false) { callbacks.onToggleLock() }) + } + } +} + +/** TalkBack's actions on a block's field: its [commands]. */ +private fun blockAccessibilityActions(commands: List): Modifier = Modifier.semantics { + customActions = commands.map { command -> CustomAccessibilityAction(command.label) { true.also { command.run() } } } +} + +/** How a block shows (read-only, its link being edited, its placeholder) and where a table reports its edits. */ +private class BlockContentState( + val isLocked: Boolean, + val isEditingLink: Boolean, + val showPlaceholder: Boolean, + val onTableChange: (TableData, Boolean) -> Unit, +) + +@Composable +private fun BlockContent( + block: Block, + blockActions: BlockActions, + focusRequester: FocusRequester, + state: BlockContentState, + onOpenSheet: (BlockSheetKind) -> Unit, + modifier: Modifier = Modifier, +) { + // [modifier] reaches the block's field (its TalkBack actions); the focus target joins it. + val fieldModifier = modifier.focusRequester(focusRequester) + val isLocked = state.isLocked + when (block) { + // Each paragraph reads in the direction of its own text (see ReadingDirection). + is Block.TextBlock -> ReadingDirection(block.text) { + TextBlock( + block, + blockActions, + modifier = fieldModifier, + isLocked = isLocked, + showPlaceholder = state.showPlaceholder, + ) + } + + is Block.QuoteBlock -> ReadingDirection(block.text) { + QuoteBlock( + block, + blockActions, + focusRequester, + fieldModifier = modifier, + isLocked = isLocked, + ) + } + + is Block.HBlock -> ReadingDirection(block.text) { + HBlock(block, blockActions, modifier = fieldModifier, isLocked = isLocked) + } + + is Block.ListBlock -> ListBlock(block, blockActions, fieldModifier = modifier, isLocked = isLocked) + + // Code reads left to right, as written, whatever its text. + is Block.CodeBlock -> CodeBlock( + block, + blockActions, + modifier = fieldModifier, + isLocked = isLocked, + showPlaceholder = state.showPlaceholder, + ) + + is Block.TableBlock -> TableBlock( + block, + blockActions, + onTableChange = state.onTableChange, + modifier = modifier, + isLocked = isLocked, + ) + + is Block.LinkBlock -> LinkBlock( + block, + blockActions = blockActions, + onClick = { onOpenSheet(BlockSheetKind.LINK) }, + fieldModifier = fieldModifier, + isLocked = isLocked, + isEditing = state.isEditingLink, + ) + + // The only kind left (an else keeps Compose from adding a branch that can never run). + else -> SeparatorBlock( + onClick = { onOpenSheet(BlockSheetKind.OTHER) }, + modifier = fieldModifier, + isLocked = isLocked, + ) + } +} + +/** + * Calls [onTap] when a touch lands in the block and lifts again without moving past the touch slop, + * whether or not a child handles it. A touch that scrolls the note (a finger gliding over the list) + * is not an interaction: it must not make the list the block the toolbar shows active. + */ +private fun Modifier.markInteractionOn(onTap: () -> Unit): Modifier = pointerInput(onTap) { + val slop = viewConfiguration.touchSlop + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + if (awaitTapUp(down, slop)) onTap() + } +} + +/** Whether [down] lifts without moving past [slop]; false once it moves farther, or is lost. */ +private suspend fun AwaitPointerEventScope.awaitTapUp(down: PointerInputChange, slop: Float): Boolean { + while (true) { + val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id } + if (change == null || (change.position - down.position).getDistance() > slop) return false + if (change.changedToUp()) return true + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt new file mode 100644 index 00000000..bdb7add8 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt @@ -0,0 +1,148 @@ +package com.pasich.encly.editor.ui + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.SelectedBlocks +import com.pasich.encly.editor.state.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.SlashPicker +import com.pasich.encly.editor.state.TextRangeSelection +import kotlinx.coroutines.flow.StateFlow + +/** + * What the editor's blocks ask of whoever owns the note: the host's view model, which decides + * whether an edit is allowed at all (a locked or read-only note changes nothing) and applies it to + * a `BlockEditorState`. Every call names the block it is about, never a position, so one that + * arrives after the list changed cannot hit another block. + */ +@Suppress("TooManyFunctions") // Everything the blocks can ask of the editor, in one place. +public interface EditorController { + /** The note's blocks, live. */ + public val blocks: List + + /** The slash picker the blocks show. */ + public val slashPicker: SlashPicker + + /** The blocks picked as whole blocks (block selection mode). Picking changes no content, so any note may. */ + public val selectedBlocks: SelectedBlocks + + /** The id of the block the user last worked on; null before any. */ + public val interactedBlockId: StateFlow + + /** The text selection that may run across blocks; picking changes no content, so any note may. */ + public val textRange: TextRangeSelection + + /** Select all pressed twice or asked of the keyboard: selects the whole note's text; false where not offered. */ + public fun selectAllText(): Boolean + + /** Replaces the text selection across blocks with [replacement] as one undo step; false if it cannot. */ + public fun replaceTextRange(replacement: String): Boolean + + /** Copies the text selection across blocks, as Markdown or plain text, to the sensitive clipboard. */ + public fun copyTextRange(markdown: Boolean): Boolean + + /** Copies the text selection across blocks as Markdown, then removes it; false if either cannot be done. */ + public fun cutTextRange(): Boolean + + /** Position of [block]; -1 when it is not in the note (any more). */ + public fun indexOfBlock(block: Block): Int + + /** [block] got focus. */ + public fun onBlockFocused(block: Block) + + /** [block] lost focus. */ + public fun onBlockFocusLost(block: Block) + + /** The user works on [block] without its field taking focus (a touch, its sheet). */ + public fun onBlockInteraction(block: Block) + + /** Adds a block of [blockType] after position [afterIndex] and focuses it. */ + public fun addBlockAfter(afterIndex: Int, blockType: BlockType) + + /** Focuses the trailing paragraph, adding one first unless the note already ends in one. */ + public fun addBlockToEnd() + + /** Removes [block] if [action] allows it. */ + public fun removeBlock(block: Block, action: BlockRemoveAction) + + /** Replaces the block at [index] with [newBlock]; false when there is none there. */ + public fun replaceBlock(index: Int, newBlock: Block): Boolean + + /** Moves the block the user works on one place [up] (or down). */ + public fun moveBlock(up: Boolean) + + /** Moves [block] one place [up] (or down). */ + public fun moveBlockOf(block: Block, up: Boolean) + + /** Which end of the note the block the user works on is at: see [com.pasich.encly.editor.state.BLOCK_AT_TOP]. */ + public fun interactedBlockPosition(): Int + + /** Duplicates [block] right after itself. */ + public fun duplicateBlock(block: Block) + + /** Locks or unlocks the checklist [block]. */ + public fun toggleChecklistLock(block: Block) + + /** Reopens the address of the saved link [block] for editing. */ + public fun editLink(block: Block.LinkBlock) + + /** A paragraph, heading or quote [block] now reads [newText]. */ + public fun onBlockTextChanged(block: Block, newText: String) + + /** The items of the list [block] changed; typing is [mergeable] into one undo step. */ + public fun onListItemsChanged(block: Block.ListBlock, newItems: List, mergeable: Boolean) + + /** The link [block] now holds [newLink]. */ + public fun onLinkChanged(block: Block.LinkBlock, newLink: LinkDataBlock) + + /** A line break typed or pasted into [block]'s field; the text the field keeps, null to keep the break. */ + public fun onLineBreak(block: Block, lineBreak: LineBreak): String? + + /** + * [pasted] went into [block]'s field (item [itemId] of a list) between [head] and [tail]. When it is exactly what + * the last copy or cut put on the clipboard, the blocks it was made of go in; returns the text the field keeps. + * Null when it is not that text, or the editor takes no paste (simple editing, a locked note). + */ + public fun onPasteCopied(block: Block, itemId: String?, head: String, pasted: String, tail: String): String? + + /** Backspace at the start of [block]'s field; false when it does nothing. */ + public fun onBackspaceAtStart(block: Block): Boolean + + /** A mark [typed] at the start of [block]'s paragraph; true when applied. */ + public fun onShortcut(block: Block, typed: String, shortcut: Shortcut): Boolean + + /** A lone web address [pasted] into the empty paragraph [block]; true when it became [link]. */ + public fun onLinkPasted(block: Block, pasted: String, link: LinkDataBlock): Boolean + + /** A line break in item [itemId] of [block]. */ + public fun onListLineBreak(block: Block.ListBlock, itemId: String, lineBreak: LineBreak): String? + + /** Backspace at the start of item [itemId] of [block]. */ + public fun onListBackspaceAtStart(block: Block.ListBlock, itemId: String): Boolean + + /** The cursor in [block]'s field moved to [offset]. */ + public fun onCaretMoved(block: Block, offset: Int) + + /** The cursor in item [itemId] of [block] moved to [offset]. */ + public fun onListItemCaret(block: Block, itemId: String, offset: Int) + + /** The paragraph [block] reads [text]; [started] when a "/" was just typed into the empty paragraph. */ + public fun onSlashTyped(block: Block, text: String, started: Boolean) + + /** Turns the paragraph [block] into [command]'s block. */ + public fun applySlash(block: Block, command: SlashCommand) + + /** Indents ([deeper]) or outdents item [itemId] of [block] one level, with its sub-items; false when it cannot. */ + public fun onListItemShift(block: Block.ListBlock, itemId: String, deeper: Boolean): Boolean + + /** Moves item [itemId] of [block], with its sub-items, [by] places among its siblings (up is negative). */ + public fun onListItemMove(block: Block.ListBlock, itemId: String, by: Int): Boolean + + /** The table [block] now holds [newTable]; typing is [mergeable] into one undo step. */ + public fun onTableChanged(block: Block.TableBlock, newTable: TableData, mergeable: Boolean) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/FindHighlights.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/FindHighlights.kt new file mode 100644 index 00000000..a3b48af5 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/FindHighlights.kt @@ -0,0 +1,93 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import com.pasich.encly.editor.state.FindMatch +import com.pasich.encly.editor.state.FindState +import com.pasich.encly.editor.state.TextPoint +import com.pasich.encly.editor.state.TextRangeSelection +import com.pasich.encly.editor.state.fieldKey +import com.pasich.encly.editor.ui.blocks.FieldHighlight + +/** + * 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. + */ +public class FindHighlights(private val state: State) { + /** The matches in the field of block [blockId] (or of its list item [itemId]). */ + internal fun matchesOf(blockId: String, itemId: String?): List = + state.value.byField[fieldKey(blockId, itemId)].orEmpty() + + /** The match "n of m" refers to, if any. */ + internal val current: FindMatch? get() = state.value.currentMatch + + /** The text selection across blocks that the fields tint; set by [editorBlocks] for the editor it shows. */ + internal var textRange: TextRangeSelection? = null + + /** Where the cursor of the field that has focus stands, when it selects nothing; Shift+click extends from it. */ + internal var caret: TextPoint? = null + + /** A drag that extends the selection: where the pointer is, and what it does with a new pointer position. */ + internal val drag = RangeDrag() + + /** + * The place in the text at [root], a position in the window: in the field whose lines are nearest to it + * vertically, at the character the position points at. Null when no field has been measured. + */ + internal fun pointAt(root: Offset): TextPoint? { + val measured = fields.values.mapNotNull { field -> field.rootBounds()?.let { field to it } } + val nearest = measured.minByOrNull { (_, bounds) -> bounds.verticalDistance(root.y) } ?: return null + return nearest.first.pointAt(root - nearest.second.topLeft) + } + + /** Where, in the window, the cursor of [point] is drawn; null while its field is not on screen. */ + internal fun cursorRoot(point: TextPoint): Offset? = + fields[fieldKey(point.blockId, point.itemId)]?.cursorRoot(point.offset) + + // The fields on screen by field key; a field is here from its composition until it leaves it. + private val fields = mutableMapOf() + + internal fun register(blockId: String, itemId: String?, field: FieldHighlight) { + fields[fieldKey(blockId, itemId)] = field + } + + internal fun unregister(blockId: String, itemId: String?, field: FieldHighlight) { + val key = fieldKey(blockId, itemId) + if (fields[key] === field) fields.remove(key) + } + + /** + * Scrolls [match] itself into view, not only its block: a paragraph or a list can be taller than the + * screen, and then part of it is on screen while the match is not. The match's field may still be + * being composed (the list was just scrolled to its block), so this waits a few frames for it. False + * when the field never appears or has no text layout to place the match with. + */ + public suspend fun reveal(match: FindMatch): Boolean { + repeat(REVEAL_FRAMES) { + if (fields[match.field]?.reveal(match.start, match.end) == true) return true + withFrameNanos { } + } + return false + } +} + +/** How many frames [FindHighlights.reveal] waits for the field of a match to be composed and measured. */ +private const val REVEAL_FRAMES = 5 + +/** How far [y] is above or below these bounds; 0 inside them. */ +private fun Rect.verticalDistance(y: Float): Float = maxOf(top - y, y - bottom, 0f) + +/** The drag that extends a text selection: the pointer's window position (null when none) and what it moves. */ +internal class RangeDrag { + /** The pointer's position in the window while it drags; null otherwise. */ + var pointer by mutableStateOf(null) + + /** Applies a new pointer position to the selection; set for the drag that is going on. */ + var update: (Offset) -> Unit = {} +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectedBlocksActions.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectedBlocksActions.kt new file mode 100644 index 00000000..571994d8 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectedBlocksActions.kt @@ -0,0 +1,19 @@ +package com.pasich.encly.editor.ui + +/** + * What the bar over selected blocks does. The host owns the clipboard and the note, so it carries out + * copy, cut and delete; [canEdit] is false where the note cannot change (locked, in the trash), and cut + * and delete are then disabled while copy still works. + */ +public class SelectedBlocksActions( + /** Whether cut and delete are offered. */ + public val canEdit: Boolean, + /** Copy the selected blocks as plain text. */ + public val onCopy: () -> Unit, + /** Copy the selected blocks as Markdown. */ + public val onCopyMarkdown: () -> Unit, + /** Copy the selected blocks, then remove them. */ + public val onCut: () -> Unit, + /** Remove the selected blocks. */ + public val onDelete: () -> Unit, +) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectedBlocksBar.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectedBlocksBar.kt new file mode 100644 index 00000000..0812cc66 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectedBlocksBar.kt @@ -0,0 +1,161 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.foundation.focusable +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.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.state.SelectedBlocks +import com.pasich.encly.editor.state.TextRangeSelection +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.components.EditorTooltip +import com.pasich.encly.editor.ui.components.editorInteractive + +private val ButtonSize = 44.dp +private val ButtonShape = RoundedCornerShape(14.dp) +private const val DISABLED_ALPHA = 0.38f + +/** + * The bar shown over the note while whole blocks are selected: leave the mode, how many blocks are picked (read + * out by TalkBack as it changes), then Copy, Copy as Markdown, Cut and Delete. It holds the keyboard focus while + * it shows, so a hardware keyboard works on the blocks: Shift+Up and Down extend the selection, Escape leaves, + * Ctrl or Cmd+A selects everything, +C copies (+Shift as Markdown), +X cuts, and Delete removes. + */ +@Composable +public fun SelectedBlocksBar(selected: SelectedBlocks, actions: SelectedBlocksActions, modifier: Modifier = Modifier) { + val count = selected.ids.collectAsState().value.size + val focus = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + // Fields cannot type into picked blocks: the keyboard goes, and the bar takes the keys. + LaunchedEffect(focus) { + keyboard?.hide() + runCatching { focus.requestFocus() } + } + SelectionBar( + count = count, + onClose = selected::clear, + actions = actions, + modifier = modifier + .onKeyEvent { event -> event.selectionKey()?.perform(selected, actions) != null } + .focusRequester(focus) + .focusable(), + ) +} + +/** + * The bar shown over the note while a text selection runs across blocks: leave it, how many blocks it touches (read + * out by TalkBack as it changes), then Copy, Copy as Markdown, Cut and Delete. The fields keep the keyboard focus, so + * typing, Delete, Ctrl+C and the other keys act on the selection from where they were pressed. + */ +@Composable +public fun TextRangeBar(range: TextRangeSelection, actions: SelectedBlocksActions, modifier: Modifier = Modifier) { + val active = range.ends.collectAsState().value != null + if (active) SelectionBar(range.blockCount(), range::clear, actions, modifier) +} + +@Composable +private fun SelectionBar( + count: Int, + onClose: () -> Unit, + actions: SelectedBlocksActions, + modifier: Modifier = Modifier, +) { + val strings = LocalEditorStrings.current + val icons = LocalEditorTheme.current.icons + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth()) { + Column { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalEditorTheme.current.spacing.xxs), + modifier = Modifier.navigationBarsPadding().padding(horizontal = 10.dp, vertical = 8.dp), + ) { + BarButton(icons.close, strings.text(EditorString.SELECTION_CLOSE), onClose) + Text( + text = strings.selectionCount(count), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = LocalEditorTheme.current.spacing.xs) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + BarButton(icons.copy, strings.text(EditorString.SELECTION_COPY), actions.onCopy) + BarButton(null, strings.text(EditorString.SELECTION_COPY_MARKDOWN), actions.onCopyMarkdown, "MD") + BarButton(icons.cut, strings.text(EditorString.SELECTION_CUT), actions.onCut.takeIf { actions.canEdit }) + BarButton( + icons.trash, + strings.text(EditorString.SELECTION_DELETE), + actions.onDelete.takeIf { actions.canEdit }, + ) + } + } + } +} + +/** A 44 dp button of the bar: [icon], or the short [text] when it has none; a null [onClick] shows it disabled. */ +@Composable +private fun BarButton(icon: ImageVector?, label: String, onClick: (() -> Unit)?, text: String? = null) { + val enabled = onClick != null + val content = MaterialTheme.colorScheme.onSurface + EditorTooltip(label) { + Surface( + onClick = { onClick?.invoke() }, + enabled = enabled, + shape = ButtonShape, + color = Color.Transparent, + contentColor = if (enabled) content else content.copy(alpha = DISABLED_ALPHA), + modifier = Modifier + .editorInteractive(ButtonShape, enabled) + .size(ButtonSize) + .semantics { + role = Role.Button + contentDescription = label + }, + ) { + Box(contentAlignment = Alignment.Center) { + if (icon != null) { + val size = LocalEditorTheme.current.spacing.iconMedium + Icon(icon, contentDescription = null, modifier = Modifier.size(size)) + } else { + Text(text.orEmpty(), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + } + } + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectionKey.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectionKey.kt new file mode 100644 index 00000000..c63e4df1 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectionKey.kt @@ -0,0 +1,50 @@ +package com.pasich.encly.editor.ui + +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.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import com.pasich.encly.editor.state.SelectedBlocks + +/** What a hardware key does while whole blocks are selected. */ +internal enum class SelectionKey { LEAVE, EXTEND_UP, EXTEND_DOWN, SELECT_ALL, COPY, COPY_MARKDOWN, CUT, DELETE } + +/** The selection key [this] is going down, null for any other key (and for a key coming up). */ +internal fun KeyEvent.selectionKey(): SelectionKey? = + selectionKeyOf(key, type, command = isCtrlPressed || isMetaPressed, shift = isShiftPressed) + +/** The selection key for [key] going [type], with Ctrl or Cmd held as [command] and Shift as [shift]; null for none. */ +internal fun selectionKeyOf(key: Key, type: KeyEventType, command: Boolean, shift: Boolean): SelectionKey? { + if (type != KeyEventType.KeyDown) return null + return when (key) { + Key.Escape -> SelectionKey.LEAVE + Key.Delete, Key.Backspace -> SelectionKey.DELETE + Key.DirectionUp -> SelectionKey.EXTEND_UP.takeIf { shift } + Key.DirectionDown -> SelectionKey.EXTEND_DOWN.takeIf { shift } + Key.A -> SelectionKey.SELECT_ALL.takeIf { command } + Key.X -> SelectionKey.CUT.takeIf { command } + Key.C -> (if (shift) SelectionKey.COPY_MARKDOWN else SelectionKey.COPY).takeIf { command } + else -> null + } +} + +/** + * Carries out [key] on [selected]: Escape leaves, Shift+Up and Down extend the range, Ctrl or Cmd+A picks every + * block, and copy, cut and delete go to [actions] (cut and delete only when the note can be changed). + */ +internal fun SelectionKey.perform(selected: SelectedBlocks, actions: SelectedBlocksActions) { + when (this) { + SelectionKey.LEAVE -> selected.clear() + SelectionKey.EXTEND_UP -> selected.extend(up = true) + SelectionKey.EXTEND_DOWN -> selected.extend(up = false) + SelectionKey.SELECT_ALL -> selected.selectAll() + SelectionKey.COPY -> actions.onCopy() + SelectionKey.COPY_MARKDOWN -> actions.onCopyMarkdown() + SelectionKey.CUT -> if (actions.canEdit) actions.onCut() + SelectionKey.DELETE -> if (actions.canEdit) actions.onDelete() + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TapBelowBlocks.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/TapBelowBlocks.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/presentation/screen/editnote/TapBelowBlocks.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/TapBelowBlocks.kt index d58e5ccb..820e75df 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TapBelowBlocks.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/TapBelowBlocks.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.screen.editnote +package com.pasich.encly.editor.ui import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.lazy.LazyListState @@ -6,14 +6,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import com.pasich.encly.presentation.viewmodel.EditNoteViewModel /** * Calls [onTap] for a tap that no block took and that lands below the last item of [listState]'s * list: the empty page under a short note, which would otherwise refuse the tap while only a fixed * strip right under the last line took it. */ -internal fun Modifier.tapBelowBlocks(listState: LazyListState, enabled: Boolean, onTap: () -> Unit): Modifier = +public fun Modifier.tapBelowBlocks(listState: LazyListState, enabled: Boolean, onTap: () -> Unit): Modifier = if (!enabled) { this } else { @@ -32,10 +31,10 @@ internal fun Modifier.tapBelowBlocks(listState: LazyListState, enabled: Boolean, * the last field kept its focus after the keyboard was hidden (focusing it again shows nothing). */ @Composable -internal fun rememberWriteAtEnd(viewModel: EditNoteViewModel): () -> Unit { +public fun rememberWriteAtEnd(controller: EditorController): () -> Unit { val keyboardController = LocalSoftwareKeyboardController.current return { - viewModel.addBlockToEnd() + controller.addBlockToEnd() keyboardController?.show() } } diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/TextRangeOverlay.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/TextRangeOverlay.kt new file mode 100644 index 00000000..a4fd4cb1 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/TextRangeOverlay.kt @@ -0,0 +1,141 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.state.TextPoint +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.roundToInt + +/** The test tag of the handle at the start of a text selection. */ +internal const val RANGE_START_TAG = "text-range-start" + +/** The test tag of the handle at the end of a text selection. */ +internal const val RANGE_END_TAG = "text-range-end" + +private val HandleSize = 22.dp +private val EdgeZone = 56.dp +private const val SCROLL_PER_FRAME = 24f + +/** + * What goes over the list of blocks while a text selection exists: a handle at each end of it, which a finger drags + * into other blocks, and the list scrolling by itself while a drag (a handle's or the mouse's) is held near its top + * or bottom edge. Place it in a box over [listState]'s list, filling the same area. It takes no touch except on its + * handles. + */ +@Composable +public fun TextRangeOverlay(host: EditorBlocksHost, listState: LazyListState, modifier: Modifier = Modifier) { + val highlights = host.highlights + val range = host.controller.textRange + val ends by range.ends.collectAsState() + var origin by remember { mutableStateOf(Offset.Zero) } + var size by remember { mutableStateOf(IntSize.Zero) } + val zone = with(LocalDensity.current) { EdgeZone.toPx() } + var handles by remember { mutableStateOf>(null to null) } + // The fields move when the list scrolls: the handles are placed again once the list has settled in its new place. + LaunchedEffect(ends, listState) { + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }.collectLatest { + withFrameNanos { } + handles = + range.orderedPoints()?.let { (from, to) -> highlights.cursorRoot(from) to highlights.cursorRoot(to) } + ?: (null to null) + } + } + // A drag held in the top or bottom strip of the list scrolls it, and the selection follows the text under it. + LaunchedEffect(highlights, listState) { + snapshotFlow { highlights.drag.pointer }.collectLatest { pointer -> + while (pointer != null) { + val speed = autoScrollSpeed(pointer.y - origin.y, size.height.toFloat(), zone) + if (speed != 0f) listState.scrollBy(speed) + highlights.drag.update(pointer) + withFrameNanos { } + } + } + } + Box( + modifier + .fillMaxSize() + .onGloballyPositioned { + origin = it.positionInRoot() + size = it.size + }, + ) { + RangeHandle(RANGE_START_TAG, handles.first, origin, isStart = true, host) + RangeHandle(RANGE_END_TAG, handles.second, origin, isStart = false, host) + } +} + +/** How far to scroll per frame for a pointer at [y] in a list [height] tall: up in the top or bottom [zone]. */ +internal fun autoScrollSpeed(y: Float, height: Float, zone: Float): Float = when { + y < zone -> -SCROLL_PER_FRAME + y > height - zone -> SCROLL_PER_FRAME + else -> 0f +} + +/** A handle at [position] (in the window) on one end of the selection; dragging it moves that end. */ +@Composable +private fun RangeHandle(tag: String, position: Offset?, origin: Offset, isStart: Boolean, host: EditorBlocksHost) { + if (position == null) return + val range = host.controller.textRange + val highlights = host.highlights + val radius = with(LocalDensity.current) { HandleSize.toPx() / 2 } + var handle by remember { mutableStateOf(null) } + Box( + Modifier + .offset { IntOffset((position.x - origin.x - radius).roundToInt(), (position.y - origin.y).roundToInt()) } + .size(HandleSize) + .testTag(tag) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .onGloballyPositioned { handle = it } + .pointerInput(isStart) { + var held: TextPoint? = null + detectDragGestures( + onDragStart = { + // The other end stays where it is whichever way this one is dragged. + held = range.orderedPoints()?.let { if (isStart) it.second else it.first } + highlights.drag.update = { root -> dragEnd(host, held, root) } + }, + onDragEnd = { highlights.drag.pointer = null }, + onDragCancel = { highlights.drag.pointer = null }, + ) { change, _ -> + change.consume() + // Where the finger is in the window, wherever the handle has got to under it. + val pointer = handle?.localToRoot(change.position) ?: return@detectDragGestures + highlights.drag.pointer = pointer + highlights.drag.update(pointer) + } + }, + ) +} + +private fun dragEnd(host: EditorBlocksHost, held: TextPoint?, root: Offset) { + val moved = host.highlights.pointAt(root) + if (held != null && moved != null) host.controller.textRange.select(held, moved) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.kt new file mode 100644 index 00000000..9c6cbb94 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.editor.ui.blocks + +/* + * The one platform-specific call left in the editor: converting an internationalized host name to + * its ASCII (punycode) form. Browsers show a name in other scripts this way because it can imitate + * another site ("аpple.com" with a Cyrillic "а"), and the link card does the same, so the card can + * never name a different site than the one the link opens. + * + * Android and the desktop JVM use java.net.IDN, which also normalizes and rejects invalid names. + * iOS has no such call in Kotlin/Native's Foundation, so it prepares the name the way Nameprep does (NFKC from + * Foundation, the mapping and the prohibited characters of IDNA 2003) and encodes it with the pure-Kotlin [Punycode] + * encoder; AsciiHostTest pins both to the JVM answers. + */ + +/** [host] (lower case, with a non-ASCII character) in its ASCII form; null when it is not a valid name. */ +internal expect fun toAsciiHost(host: String): String? diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/BlockInput.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/BlockInput.kt new file mode 100644 index 00000000..b145d67a --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/BlockInput.kt @@ -0,0 +1,168 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.placeCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.withFrameNanos +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.TextDiff +import com.pasich.encly.editor.state.isLoneWebAddress +import com.pasich.encly.editor.state.normalizeLineBreaks +import com.pasich.encly.editor.state.textDiff +import com.pasich.encly.editor.state.typedShortcut +import com.pasich.encly.editor.ui.BlockActions +import kotlinx.coroutines.launch + +/** Which field of a block a [BlockInput] watches: a paragraph, another text block, or a list item. */ +internal sealed interface BlockField { + /** A paragraph: its marks ("# ", "- ", ...) and a pasted lone web address convert it. */ + data object Paragraph : BlockField + + /** A heading or quote. */ + data object Styled : BlockField + + /** Item [itemId] of a list. */ + data class ListItem(val itemId: String) : BlockField + + /** Code: everything stays as typed or pasted, line breaks included. */ + data object Code : BlockField +} + +/** + * The writing rules of a block's field, applied to every edit the user makes: typing, the + * keyboard's Enter, a paste, autocorrect. Enter arrives as a line break from every keyboard + * (some send a key press, some commit "\n"), so this is the one place it is handled: a line + * break typed or pasted splits the block ([BlockActions.onLineBreak]), and the field keeps only + * 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. + */ +internal class BlockInput( + private val field: BlockField, + private val defer: (() -> Unit) -> Unit = { it() }, + private val actions: () -> BlockActions, +) : InputTransformation { + override fun TextFieldBuffer.transformInput() { + val old = originalText.toString() + val new = asCharSequence().toString() + if (old == new) return + val cursor = if (selection.collapsed) selection.start else -1 + val diff = textDiff(old, new, cursor) + when { + actions().textRange.isCrossField -> replaceRange(diff.inserted) + field == BlockField.Code -> keepVerbatim(diff) + else -> applyRules(old, new, cursor, diff) + } + } + + private fun TextFieldBuffer.applyRules(old: String, new: String, cursor: Int, diff: TextDiff) { + val lineBreak = LineBreak.of(old, diff) + when { + pastedCopy(old, diff) -> Unit + + lineBreak != null -> { + val kept = when (field) { + is BlockField.ListItem -> actions().onListLineBreak(field.itemId, lineBreak) + else -> actions().onLineBreak(lineBreak) + } + if (kept != null) { + replace(0, length, kept) + placeCursorAtEnd() + } else if (diff.inserted.any { it == '\r' }) { + // Kept as text: one kind of line break only. + replace(diff.start, diff.end, normalizeLineBreaks(diff.inserted)) + } + } + + field != BlockField.Paragraph -> Unit + + 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) + } + } + } + + // A paste of exactly what the last copy or cut in the editor put on the clipboard inserts the blocks it was of. + private fun TextFieldBuffer.pastedCopy(old: String, diff: TextDiff): Boolean { + val itemId = (field as? BlockField.ListItem)?.itemId + val kept = if (diff.inserted.length < 2) { + null + } else { + actions().onPasteCopied( + itemId, + head = old.substring(0, diff.start), + pasted = diff.inserted, + tail = old.substring(diff.start + diff.removed.length), + ) + } + kept?.let { + replace(0, length, it) + placeCursorAtEnd() + } + return kept != null + } + + // Code is never split, converted or rewritten; only a carriage return is made a line feed, as everywhere. + private fun TextFieldBuffer.keepVerbatim(diff: TextDiff) { + if ('\r' in diff.inserted) replace(diff.start, diff.end, normalizeLineBreaks(diff.inserted)) + } + + private fun TextFieldBuffer.replaceRange(inserted: String) = replaceSelectedRange(inserted, defer, actions()) + + private fun onParagraphInsert(old: String, new: String, cursor: Int, inserted: String) { + val shortcut = typedShortcut(new, cursor) + when { + shortcut != null -> actions().onShortcut(new, shortcut) + + // A paste (not typing, which inserts one character at a time) of an address alone. + old.isEmpty() && inserted.length > 1 && isLoneWebAddress(new) -> + buildLinkData(new)?.let { actions().onLinkPasted(new, it) } + } + } +} + +/** + * An edit of a field while a selection runs across blocks: the selection is replaced once this edit is over (it moves + * the cursor and the text of the fields, which must not happen under an edit of one of them) and the field itself + * keeps its text. Enter only deletes. + */ +internal fun TextFieldBuffer.replaceSelectedRange( + inserted: String, + defer: (() -> Unit) -> Unit, + actions: BlockActions, +) { + revertAllChanges() + val typed = inserted.takeUnless { it == "\n" || it == "\r" }.orEmpty() + defer { actions.replaceTextRange(typed) } +} + +/** The character that opens the slash picker in an empty paragraph. */ +private const val SLASH = "/" + +/** Runs a task a moment later, on the composition's own dispatcher: after the edit that asked for it is over. */ +@Composable +internal fun rememberDefer(): (() -> Unit) -> Unit { + val scope = rememberCoroutineScope() + return remember(scope) { + { task -> + scope.launch { withFrameNanos { } }.invokeOnCompletion { cause -> + if (cause == + null + ) { + task() + } + } + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/CodeBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/CodeBlock.kt new file mode 100644 index 00000000..65d7a43b --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/CodeBlock.kt @@ -0,0 +1,133 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.theme.EDITOR_PLACEHOLDER_ALPHA +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.theme.lineHeight +import com.pasich.encly.editor.ui.BlockActions + +/** What the Tab key types into code: four spaces (code never holds a tab by accident; a pasted one is kept). */ +internal const val CODE_TAB = " " + +/** + * Keyboard of a code field: no capital letters, no autocorrect. Suggestions are the keyboard's own to + * offer; the app already asks keyboards not to learn from what is typed (SecureTextInputBoundary). + */ +internal val CodeKeyboard: KeyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, +) + +/** + * Code, in a monospace face on a tonal card. It reads left to right whatever the app's language is, keeps + * every space, tab and line break as typed (Enter adds a line, never a block), and Tab types [CODE_TAB]. + * Its placeholder shows only while it is [showPlaceholder] (focused) and empty. + */ +@Composable +internal fun CodeBlock( + block: Block.CodeBlock, + blockActions: BlockActions, + modifier: Modifier = Modifier, + isLocked: Boolean = false, + showPlaceholder: Boolean = true, +) { + val theme = LocalEditorTheme.current + val state = rememberBlockTextFieldState(block.text, blockActions) + val style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + fontSize = theme.fontSizes.list, + fontFamily = FontFamily.Monospace, + lineHeight = theme.lineSpacing.lineHeight(theme.fontSizes.list, MaterialTheme.typography.bodyLarge.lineHeight), + ) + val actions by rememberUpdatedState(blockActions) + val defer = rememberDefer() + val input = remember { BlockInput(BlockField.Code, defer) { actions } } + val highlight = rememberFieldHighlight(blockActions.highlights, block.id) + RegisterCaretAtX(blockActions, state, highlight) + + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + BasicTextField( + state = state, + readOnly = isLocked, + inputTransformation = input, + textStyle = style, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = CodeKeyboard, + onTextLayout = highlight.onTextLayout, + modifier = modifier + .fillMaxWidth() + .padding(vertical = theme.spacing.xs) + .background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.medium) + .padding(horizontal = theme.spacing.fieldGap, vertical = theme.spacing.m) + .then(highlight.modifier) + .codeTabKey(state, enabled = !isLocked) + .textBlockKeys(state, blockActions, highlight), + decorator = { innerTextField -> + Box(modifier = Modifier.fillMaxWidth()) { + if (showPlaceholder && state.text.isEmpty()) { + Text( + text = LocalEditorStrings.current.text(EditorString.BLOCK_CODE), + style = style.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = EDITOR_PLACEHOLDER_ALPHA, + ), + ), + ) + } + innerTextField() + } + }, + ) + } +} + +/** + * Tab in a code field types [CODE_TAB] over the selection instead of moving the focus to the next control. + * Tab with Shift or any other modifier is left alone. + */ +internal fun Modifier.codeTabKey(state: TextFieldState, enabled: Boolean): Modifier = onPreviewKeyEvent { event -> + val plain = !(event.isShiftPressed || event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) + val tab = event.key == Key.Tab && plain + if (tab && event.type == KeyEventType.KeyDown && enabled) { + state.edit { + val at = selection.min + replace(at, selection.max, CODE_TAB) + selection = TextRange(at + CODE_TAB.length) + } + } + tab +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/FieldHighlight.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/FieldHighlight.kt new file mode 100644 index 00000000..9775e995 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/FieldHighlight.kt @@ -0,0 +1,198 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.Density +import com.pasich.encly.editor.focus.CaretGeometry +import com.pasich.encly.editor.focus.geometry +import com.pasich.encly.editor.state.TextPoint +import com.pasich.encly.editor.state.TextRangeSelection +import com.pasich.encly.editor.ui.FindHighlights + +private const val MATCH_ALPHA = 0.4f +private const val CURRENT_MATCH_ALPHA = 0.65f + +/** + * What one text field shows and tells beyond its text: the Find matches and the part a text selection across + * blocks covers, tinted behind the text; where it is in the window and what its lines are, so a pointer position + * can be turned into a place in the text; and the mouse gestures that start such a selection. Pass [onTextLayout] to + * the field and add [modifier] to it; the tint is inert while there is nothing to show in this field. + */ +@Suppress("TooManyFunctions") // What a field tells about itself, and the mouse gestures that start a selection. +internal class FieldHighlight( + private val highlights: FindHighlights, + val blockId: String, + val itemId: String?, + private val match: Color, + private val currentMatch: Color, + private val selected: Color, +) { + private var layout by mutableStateOf<(() -> TextLayoutResult?)?>(null) + + private var coordinates: LayoutCoordinates? = null + + /** The field's own text state, once it is composed: a selection that ends inside one field is also the field's. */ + var state: TextFieldState? = null + + // Where a mouse drag began, in this field or as the anchor a Shift+click extends; null while there is none. + private var mouseAnchor: TextPoint? = null + + /** The field's lines as the cursor moves between fields need them; null until it is measured. */ + fun geometry(): CaretGeometry? = layout?.invoke()?.geometry() + + /** The field's measured text; null until it has been measured. */ + fun layoutResult(): TextLayoutResult? = layout?.invoke() + + val onTextLayout: Density.(() -> TextLayoutResult?) -> Unit = { getResult -> + if (layout == null) layout = getResult + } + + private val bringIntoView = BringIntoViewRequester() + + /** + * Scrolls the text from [start] to [end] into view; false while the field has no text layout (or no + * text that long) to place it with. + */ + suspend fun reveal(start: Int, end: Int): Boolean { + val result = layout?.invoke()?.takeIf { start < it.layoutInput.text.length } ?: return false + // From the first character's box to the last one's; a match that wraps spans both lines. + val from = result.getBoundingBox(start) + val to = result.getBoundingBox(minOf(end, result.layoutInput.text.length) - 1) + bringIntoView.bringIntoView(spanning(from, to)) + return true + } + + /** Where the field is in the window; null while it is not placed. */ + fun rootBounds(): Rect? = coordinates?.takeIf { it.isAttached }?.boundsInRoot() + + /** The place in the text that [local], a position in the field, points at; null before the text is measured. */ + fun pointAt(local: Offset): TextPoint? = + layout?.invoke()?.let { TextPoint(blockId, it.getOffsetForPosition(local), itemId) } + + /** Where the cursor at [offset] stands in the window (the foot of the cursor); null before it is measured. */ + fun cursorRoot(offset: Int): Offset? { + val result = layout?.invoke() + return rootBounds()?.let { bounds -> + result?.let { + bounds.topLeft + it.getCursorRect(offset.coerceIn(0, it.layoutInput.text.length)).bottomCenter + } + } + } + + /** + * The field's own selection changed to [selection]: while no selection runs across fields it is the whole + * selection, and a cursor alone ends it and is where Shift+click extends from. + */ + fun onSelection(selection: TextRange, range: TextRangeSelection) { + if (range.isCrossField) return + if (selection.collapsed) { + range.clear() + highlights.caret = TextPoint(blockId, selection.start, itemId) + } else { + range.select(TextPoint(blockId, selection.start, itemId), TextPoint(blockId, selection.end, itemId)) + } + } + + /** A mouse button went down at [local] in this field; with [shift] the press extends the selection instead. */ + fun beginMouse(local: Offset, shift: Boolean) { + val range = highlights.textRange ?: return + val here = pointAt(local) + val kept = range.ends.value?.anchor ?: highlights.caret + mouseAnchor = if (shift) kept else here + if (shift) extend(range, mouseAnchor, here, always = false) + } + + /** The mouse moved to [local] with a button down: past this field's edge the selection runs on into the others. */ + fun dragMouse(local: Offset) { + val root = coordinates?.localToRoot(local) ?: return + highlights.drag.update = ::dragTo + highlights.drag.pointer = root + dragTo(root) + } + + private fun dragTo(root: Offset) { + val range = highlights.textRange ?: return + extend(range, mouseAnchor, highlights.pointAt(root), always = range.isCrossField) + } + + // Selects from [anchor] to [point] when they are in different fields (or [always]): the selection has left a field. + private fun extend(range: TextRangeSelection, anchor: TextPoint?, point: TextPoint?, always: Boolean) { + if (anchor == null || point == null) return + if (always || !anchor.sameField(point)) range.select(anchor, point) + } + + /** The mouse button went up. */ + fun endMouse() { + highlights.drag.pointer = null + mouseAnchor = null + } + + val modifier: Modifier = Modifier + .bringIntoViewRequester(bringIntoView) + .onGloballyPositioned { coordinates = it } + .drawBehind { + val result = layout?.invoke() ?: return@drawBehind + val length = result.layoutInput.text.length + highlights.textRange?.coverOf(blockId, itemId, length)?.let { (from, to) -> + if (to > from) drawPath(result.getPathForRange(from, to), selected) + } + val current = highlights.current + for (found in highlights.matchesOf(blockId, itemId)) { + if (found.start >= length) continue + val path = result.getPathForRange(found.start, minOf(found.end, length)) + drawPath(path, if (found == current) currentMatch else match) + } + } + .rangeMouse(this) +} + +@Composable +internal fun rememberFieldHighlight( + highlights: FindHighlights, + blockId: String, + itemId: String? = null, +): FieldHighlight { + val primary = MaterialTheme.colorScheme.primary + val selected = LocalTextSelectionColors.current.backgroundColor + val field = remember(highlights, blockId, itemId, primary, selected) { + FieldHighlight( + highlights = highlights, + blockId = blockId, + itemId = itemId, + match = primary.copy(alpha = MATCH_ALPHA), + currentMatch = primary.copy(alpha = CURRENT_MATCH_ALPHA), + selected = selected, + ) + } + DisposableEffect(field) { + highlights.register(blockId, itemId, field) + onDispose { highlights.unregister(blockId, itemId, field) } + } + return field +} + +/** Whether [this] and [other] are in the same field. */ +internal fun TextPoint.sameField(other: TextPoint): Boolean = blockId == other.blockId && itemId == other.itemId + +/** The rectangle holding [from] and [to]; for a wrapped or right-to-left match, [to] is left of [from]. */ +internal fun spanning(from: Rect, to: Rect): Rect = + Rect(minOf(from.left, to.left), minOf(from.top, to.top), maxOf(from.right, to.right), maxOf(from.bottom, to.bottom)) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/HBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/HBlock.kt new file mode 100644 index 00000000..391c3f4c --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/HBlock.kt @@ -0,0 +1,94 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.theme.EDITOR_PLACEHOLDER_ALPHA +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.BlockActions + +@Composable +internal fun HBlock( + block: Block.HBlock, + blockActions: BlockActions, + modifier: Modifier = Modifier, + isLocked: Boolean = false, +) { + val textStyle = headingTextStyle(block.blockType) + val state = rememberBlockTextFieldState(block.text, blockActions) + val actions by rememberUpdatedState(blockActions) + // Enter splits the heading; the text after the cursor goes on as a paragraph (BlockInput). + val defer = rememberDefer() + val input = remember { BlockInput(BlockField.Styled, defer) { actions } } + val highlight = rememberFieldHighlight(blockActions.highlights, block.id) + RegisterCaretAtX(blockActions, state, highlight) + var focused by remember { mutableStateOf(false) } + + BasicTextField( + state = state, + readOnly = isLocked, + inputTransformation = input, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + textStyle = textStyle, + keyboardOptions = WritingKeyboard, + onTextLayout = highlight.onTextLayout, + modifier = modifier + .onFocusChanged { focused = it.isFocused } + .fillMaxWidth() + .padding(top = LocalEditorTheme.current.spacing.labelGap) + .then(highlight.modifier) + .textBlockKeys(state, blockActions, highlight), + decorator = { innerTextField -> + Box(modifier = Modifier.fillMaxWidth()) { + if (state.text.isEmpty() && focused) { + Text( + text = LocalEditorStrings.current.heading(block.blockType.headingLevel()), + style = textStyle.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), + ), + ) + } + innerTextField() + } + }, + ) +} + +private val HeadingTypes = listOf(BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4) + +/** 1 for H1 up to 4 for H4. */ +private fun BlockType.headingLevel(): Int = HeadingTypes.indexOf(this) + 1 + +/** The text style of a heading of [blockType] (H1-H4). */ +@Composable +private fun headingTextStyle(blockType: BlockType): TextStyle { + val theme = LocalEditorTheme.current + val (base, size) = when (blockType) { + BlockType.H1 -> MaterialTheme.typography.headlineSmall to theme.fontSizes.h1 + BlockType.H2 -> MaterialTheme.typography.titleLarge to theme.fontSizes.h2 + BlockType.H3 -> MaterialTheme.typography.titleMedium to theme.fontSizes.h3 + BlockType.H4 -> MaterialTheme.typography.titleSmall to theme.fontSizes.h4 + else -> return MaterialTheme.typography.titleMedium + } + return base.copy( + color = MaterialTheme.colorScheme.onSurface, + fontSize = size, + fontFamily = theme.fontFamilies.heading, + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/LineMetrics.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/LineMetrics.kt similarity index 81% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/LineMetrics.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/LineMetrics.kt index 34bda092..c7b6cbb7 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/LineMetrics.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/LineMetrics.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -38,17 +38,21 @@ internal fun rememberLineMetrics(): LineMetrics { val fontPx = with(density) { style.fontSize.toPx() } val top = typed.getLineTop(0) val bottom = typed.getLineBottom(0) - val centre = if (bottom - top >= natural) { - (top + bottom) / 2f - } else { - typed.getLineBaseline(0) - capHeightPx(typed, fontPx) / 2f - } + val centre = firstLineCentre(top, bottom, natural, typed.getLineBaseline(0), capHeightPx(typed, fontPx)) val emptyCentre = (empty.getLineTop(0) + empty.getLineBottom(0)) / 2f val lineHeight = bottom - top LineMetrics(centre = centre, emptyShift = (centre - emptyCentre), lineHeight = lineHeight) } } +/** + * The middle of a first line whose box runs from [top] to [bottom]: the box's own middle when it is + * at least as tall as the font's [natural] line, else (compact spacing, where the glyphs overflow + * it) the middle of the capitals, [capHeight] above the [baseline]. + */ +internal fun firstLineCentre(top: Float, bottom: Float, natural: Float, baseline: Float, capHeight: Float): Float = + if (bottom - top >= natural) (top + bottom) / 2f else baseline - capHeight / 2f + /** The height of the capital in [layout] (an "H"), or a typical share of [fontSizePx] where no outline exists. */ internal fun capHeightPx(layout: TextLayoutResult, fontSizePx: Float): Float { val bounds = layout.getPathForRange(0, 1).getBounds() diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/LinkBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/LinkBlock.kt new file mode 100644 index 00000000..5009d64c --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/LinkBlock.kt @@ -0,0 +1,236 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.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 androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.BlockActions +import com.pasich.encly.editor.ui.components.EditorFilledToolButton +import com.pasich.encly.editor.ui.components.EditorTextField +import com.pasich.encly.editor.ui.components.editorInteractive + +/** + * A link: its address entry until one is saved (or while [isEditing] it), then the link as + * underlined `primary` text; [onClick] opens the link's sheet. The editor focuses a new link's + * entry through [modifier]; the entry focuses itself only when "Edit link" reopened it. + */ +@Composable +internal fun LinkBlock( + block: Block.LinkBlock, + blockActions: BlockActions?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, + isLocked: Boolean = false, + isEditing: Boolean = false, +) { + val urlModel by block.block.collectAsState() + // Outlives the saved link, so "Edit link" reopens the entry with the current address. + var inputText by remember { mutableStateOf(urlModel.url) } + // Set when the entered address may not be saved (not a web or mail link). + var rejected by remember { mutableStateOf(false) } + LaunchedEffect(urlModel.url, isEditing) { + if (urlModel.url.isNotBlank()) inputText = urlModel.url + } + + // [isEditing]: the saved link stays in the block while its address is edited here, so it + // is still stored if the user leaves without committing a new one. + if ((urlModel.url.isBlank() || isEditing) && !isLocked) { + LinkUrlField( + inputText = inputText, + rejected = rejected, + onInputChange = { + inputText = it + rejected = false + }, + onCommit = { + if (inputText.isNotBlank()) { + val link = buildLinkData(inputText) + rejected = link == null + if (link != null) { + if (blockActions != null) blockActions.onLinkChanged(link) else block.block.value = link + } + } + }, + onRemoveBlock = { blockActions?.onRemoveBlock(BlockRemoveAction.REMOVE_BACKSPACE) }, + modifier = modifier, + fieldModifier = fieldModifier, + autoFocus = isEditing, + ) + } else if (urlModel.url.isNotBlank()) { + LinkText(urlModel = urlModel, onClick = onClick, modifier = modifier) + } +} + +/** Backspace in an empty address field removes the block; in any other case it edits the address. */ +internal fun removesEmptyAddress(event: KeyEvent, address: String): Boolean = + event.key == Key.Backspace && event.type == KeyEventType.KeyDown && address.isEmpty() + +@Composable +private fun LinkUrlField( + inputText: String, + rejected: Boolean, + onInputChange: (String) -> Unit, + onCommit: () -> Unit, + onRemoveBlock: () -> Unit, + modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, + autoFocus: Boolean = false, +) { + val focusRequester = remember { FocusRequester() } + val strings = LocalEditorStrings.current + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(LocalEditorTheme.current.spacing.xs), + modifier = modifier.fillMaxWidth(), + ) { + EditorTextField( + value = inputText, + onValueChange = onInputChange, + label = strings.text(EditorString.ENTER_URL), + errorMessage = if (rejected) strings.text(EditorString.LINK_BLOCKED) else null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onCommit() }), + modifier = Modifier.weight(1f), + fieldModifier = fieldModifier + .focusRequester(focusRequester) + // Previewed: the field's own key handling would take Backspace first. + .onPreviewKeyEvent { event -> + removesEmptyAddress(event, inputText).also { if (it) onRemoveBlock() } + }, + ) + EditorFilledToolButton( + icon = LocalEditorTheme.current.icons.plus, + contentDescription = strings.text(EditorString.LINK_ADD), + onClick = if (inputText.isNotBlank()) onCommit else null, + ) + } + if (autoFocus) { + LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + } +} + +/** + * A saved link as a card: a globe tile, the site's host as its title and the rest of the address + * under it (the app is offline, so there is no fetched preview). A tap opens the link's sheet. + * A link Encly will not open ([NoteLink.Blocked]) shows its whole address and says so in `error`. + */ +@Composable +private fun LinkText(urlModel: LinkDataBlock, onClick: () -> Unit, modifier: Modifier = Modifier) { + val link = remember(urlModel.url) { parseNoteLink(urlModel.url) } + val (title, detail) = remember(link) { link.cardLines() } + val strings = LocalEditorStrings.current + val spacing = LocalEditorTheme.current.spacing + val error = when { + link is NoteLink.Blocked -> strings.text(EditorString.LINK_BLOCKED) + urlModel.isError -> strings.text(EditorString.LINK_CONTENT_FAILED) + else -> null + } + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = modifier + .padding(vertical = spacing.xxs) + .fillMaxWidth(), + ) { + // Clickable inside the Surface, so the ripple keeps to the card's corners. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.s), + modifier = Modifier + .editorInteractive(MaterialTheme.shapes.medium).clickable( + role = Role.Button, + onClickLabel = strings.text(EditorString.MORE_OPTIONS), + onClick = onClick, + ) + .padding(spacing.s), + ) { + LinkTile() + Column( + verticalArrangement = Arrangement.spacedBy(spacing.textGap), + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = if (link is NoteLink.Blocked) TextOverflow.MiddleEllipsis else TextOverflow.Ellipsis, + ) + if (error != null) { + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else if (detail.isNotEmpty()) { + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + } + } + } +} + +/** The globe tile at the start of a link card. */ +@Composable +private fun LinkTile(modifier: Modifier = Modifier) { + val theme = LocalEditorTheme.current + val spacing = theme.spacing + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = modifier, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(spacing.tileSmall)) { + Icon( + theme.icons.globe, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(spacing.iconSmall), + ) + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ListBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ListBlock.kt new file mode 100644 index 00000000..67434724 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ListBlock.kt @@ -0,0 +1,654 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +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.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.focus.KeyboardUtils +import com.pasich.encly.editor.focus.caretLine +import com.pasich.encly.editor.focus.caretOffsetFor +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.kindIn +import com.pasich.encly.editor.model.normalizedLevels +import com.pasich.encly.editor.model.numbers +import com.pasich.encly.editor.state.CaretMove +import com.pasich.encly.editor.state.goesUp +import com.pasich.encly.editor.theme.EDITOR_PLACEHOLDER_ALPHA +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.theme.lineHeight +import com.pasich.encly.editor.theme.listIndent +import com.pasich.encly.editor.theme.listItemGap +import com.pasich.encly.editor.theme.markerBoxSize +import com.pasich.encly.editor.theme.markerColumn +import com.pasich.encly.editor.theme.markerGap +import com.pasich.encly.editor.ui.BlockActions +import com.pasich.encly.editor.ui.components.EditorCheckbox +import com.pasich.encly.editor.ui.components.editorInteractive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlin.math.roundToInt + +/** From this many items numbers take two digits, and a wider hanging indent. */ +private const val WIDE_MARKER_FROM = 10 + +private const val FOCUS_FRAMES = 5 + +private const val DROP_LINE_ALPHA = 0.8f + +/** A two-digit number's extra width, in text sizes. */ +private const val WIDE_MARKER_EXTRA = 0.6f + +/** Which item takes focus next, and where its cursor goes (null: where it is; [Int.MAX_VALUE]: the end). */ +private data class ItemFocus( + val itemId: String, + val caret: Int?, + /** A horizontal position to put the cursor at, on the item's last line when [fromBelow], else its first. */ + val caretX: Float? = null, + val fromBelow: Boolean = false, +) + +/** The list's item fields by item id, and the item to focus once it is composed. */ +private class ListFocus { + val requesters = mutableMapOf() + val fieldStates = mutableMapOf() + val highlights = mutableMapOf() + var pending by mutableStateOf(null) + + /** The item whose field has focus; its cursor is reported so undo can put it back. */ + var focusedItem: String? = null +} + +/** + * The list's focus: the target the editor focuses the list through (the item a request names, + * else its first item, or its last with the cursor at the end), and focus moves between items + * once the item is composed. + */ +@Composable +private fun rememberListFocus(block: Block.ListBlock, blockActions: BlockActions): ListFocus { + val focus = remember { ListFocus() } + DisposableEffect(block, blockActions) { + val unregister = blockActions.registerFieldsFocusTarget { request -> + val items = block.items.value + val item = request.itemId?.let { id -> items.firstOrNull { it.id == id } } + // An item that is not there (undone away) is never "the first one": the nearest end is. + ?: if (request.itemId != null || request.cursorToEnd || request.fromBelow) { + items.lastOrNull() + } else { + items.firstOrNull() + } + item?.let { focus.pending = ItemFocus(it.id, request.caretOffset, request.caretX, request.fromBelow) } + } + onDispose { unregister() } + } + // A new item is composed a frame or two after the edit that added it. + LaunchedEffect(focus.pending) { + val request = focus.pending ?: return@LaunchedEffect + repeat(FOCUS_FRAMES) { + val requester = focus.requesters[request.itemId] + if (requester != null) { + runCatching { requester.requestFocus() } + val stored = block.items.value.firstOrNull { it.id == request.itemId }?.value + val state = focus.fieldStates[request.itemId] + if (stored != null && state != null) { + if (request.caretX != null) { + val geometry = focus.highlights[request.itemId]?.geometry() + state.placeCaret( + stored, + caretOffsetFor(geometry, request.caretX, request.fromBelow, stored.length), + ) + } else if (request.caret != null) { + state.placeCaret(stored, request.caret) + } + } + focus.pending = null + return@LaunchedEffect + } + withFrameNanos { } + } + focus.pending = null + } + return focus +} + +/** + * The stored text of a list's items by id. The map is built once per list the model holds, so an + * item field reads its text in constant time: with a lookup by scan, every keystroke cost every + * item field a pass over the whole list. + */ +internal class ItemValues(private val items: StateFlow>) { + private var source: List? = null + private var byId = emptyMap() + + /** How many times the map was built; a test reads it to see that lookups do not scan. */ + var builds = 0 + private set + + /** The text of item [id] in the list the model holds right now; null when it is not in it. */ + fun valueOf(id: String): String? { + val current = items.value + if (current !== source) { + source = current + byId = current.associate { it.id to it.value } + builds++ + } + return byId[id] + } + + /** Emits an item's text when it changes, and only then: the other items' fields do not wake. */ + fun changes(id: String): Flow = items.map { valueOf(id) }.distinctUntilChanged() +} + +/** + * A checklist, bulleted or numbered list: one field per item. Every change goes through + * [blockActions], so it is autosaved and can be undone. The editor focuses the list through the + * target it registers ([BlockActions.registerFieldsFocusTarget]). + */ +@Suppress("LongMethod") // The list's state, then a row for each item. +@Composable +internal fun ListBlock( + block: Block.ListBlock, + blockActions: BlockActions, + modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, + isLocked: Boolean = false, +) { + val items by block.items.collectAsState() + val textLocked by block.locked.collectAsState() + // The rows as they are shown: levels made valid, so a list cut or damaged by an edit still nests sensibly. + val rows = remember(items) { items.normalizedLevels(block.blockType) } + val numbers = remember(rows) { rows.numbers(block.blockType) } + val moves = remember(rows) { movesOf(rows) } + val latestRows by rememberUpdatedState(rows) + val drag = remember(block) { ListDrag { latestRows } } + val actions by rememberUpdatedState(blockActions) + val focus = rememberListFocus(block, blockActions) + val defer = rememberDefer() + val editor = remember(block) { ListEditor(block, { actions }, defer) { focus.pending = it } } + val values = remember(block) { ItemValues(block.items) } + val checklist = block.blockType == BlockType.LIST_CHECK + val theme = LocalEditorTheme.current + val marker = rememberListMarker(block.blockType, items.size) + val locks = remember(isLocked, textLocked) { ItemLocks(editorLocked = isLocked, textLocked = textLocked) } + // One pair of edits for the whole list, and one context and drag modifier per row that changes only with the row's + // own inputs: a new object each time would make every row recompose on every keystroke. + val onMove = remember<(String, Int) -> Unit> { { id, by -> actions.onListItemMove(id, by) } } + val onShift = remember<(String, Boolean) -> Unit> { { id, deeper -> actions.onListItemShift(id, deeper) } } + val editable = !isLocked && !textLocked + val reorderable = items.size > 1 + val dropColor = MaterialTheme.colorScheme.primary.copy(alpha = DROP_LINE_ALPHA) + Column( + // The whole list, markers and text, sits in from the text column: under the heading above it. + modifier = modifier + .fillMaxWidth() + .padding(start = listIndent(listFontDp())) + .dropLine(dropColor) { drag.dropLineAt() }, + verticalArrangement = Arrangement.spacedBy( + theme.lineSpacing.listItemGap(checklist, theme.spacing.xxs), + ), + ) { + rows.forEachIndexed { itemIndex, item -> + key(item.id) { + val state = rememberSyncedTextFieldState( + model = { values.valueOf(item.id) }, + modelChanges = remember(values) { values.changes(item.id) }, + onEdit = { text -> editor.setText(item.id, text) }, + ) + val requester = remember { FocusRequester() } + val highlight = rememberFieldHighlight(blockActions.highlights, block.id, item.id) + LaunchedEffect(item.id, state) { + snapshotFlow { state.selection.start }.collect { offset -> + if (focus.focusedItem == item.id) actions.onListItemCaret(item.id, offset) + } + } + RegisterItem(focus, item.id, requester, state, highlight) + BindFieldRange(state, blockActions, highlight) + // Remembered, so a row whose item did not change is skipped: a new object each time + // would make every row recompose on every keystroke. + val field = remember(state, highlight, marker.metrics, locks) { + ItemField(state, highlight, marker.metrics, locks) + } + val itemModifier = remember(fieldModifier, requester, state) { + fieldModifier.focusRequester(requester).onFocusChanged { focusState -> + if (focusState.isFocused) { + focus.focusedItem = item.id + actions.onListItemCaret(item.id, state.selection.start) + } + } + } + val itemMarker = remember(marker, numbers[itemIndex], item.level, item.kind) { + marker.forItem(numbers[itemIndex], item.kindIn(block.blockType)) + } + val itemMoves = moves[itemIndex] + val context = remember(drag, itemMoves, editable, reorderable) { + ItemContext(drag, itemMoves, editable, reorderable, onMove, onShift) + } + val rowModifier = remember(drag, item.id) { Modifier.dragged(drag, item.id) } + // Each item reads in its own direction: its marker and indent sit on the side it starts. + ReadingDirection(text = { state.text }) { + ListItemRow( + item = item, + marker = itemMarker, + field = field, + editor = editor, + context = context, + modifier = rowModifier, + fieldModifier = itemModifier, + ) + } + } + } + } +} + +/** Lets the list find item [id]'s focus requester, text state and highlight while the item is composed. */ +@Composable +private fun RegisterItem( + focus: ListFocus, + id: String, + requester: FocusRequester, + state: TextFieldState, + highlight: FieldHighlight, +) { + DisposableEffect(id) { + focus.requesters[id] = requester + focus.fieldStates[id] = state + focus.highlights[id] = highlight + onDispose { + focus.highlights.remove(id) + focus.requesters.remove(id) + focus.fieldStates.remove(id) + } + } +} + +/** The marker of a list of [count] items: the checkbox's column, grown with the text (more for two digits). */ +@Composable +private fun rememberListMarker(type: BlockType, count: Int): ListMarker { + val font = listFontDp() + val wide = font * (if (count >= WIDE_MARKER_FROM) WIDE_MARKER_EXTRA else 0f) + val metrics = rememberLineMetrics() + return remember(type, font, wide, metrics) { + ListMarker( + type = type, + width = markerColumn(font) + wide, + box = markerBoxSize(font), + metrics = metrics, + levelIndent = markerColumn(font), + ) + } +} + +/** What keeps a list item from being edited: the whole editor ([editorLocked]) or this checklist's text. */ +private class ItemLocks(val editorLocked: Boolean, val textLocked: Boolean) + +/** A list item's text field state, what Find tints in it, its list's [metrics] and what keeps it from being edited. */ +private class ItemField( + val state: TextFieldState, + val highlight: FieldHighlight, + val metrics: LineMetrics, + val locks: ItemLocks, +) + +/** + * How a list marks its items: checkboxes, bullets or numbers in a hanging-indent column of + * [width]; [number] is the item's own. [metrics] are measured once per list, not per item. + */ +private data class ListMarker( + val type: BlockType, + val width: Dp, + val box: Dp, + val metrics: LineMetrics, + /** How far a nested item sits in for each level: one marker column. */ + val levelIndent: Dp, + val number: Int = 0, +) { + /** The marker of an item of [kind] numbered [number] in its run. */ + fun forItem(number: Int, kind: BlockType = type) = copy(type = kind, number = number) +} + +@Composable +private fun ListItemRow( + item: ItemListBlock, + marker: ListMarker, + field: ItemField, + editor: ListEditor, + context: ItemContext, + modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, +) { + val state = field.state + val locks = field.locks + val checklist = marker.type == BlockType.LIST_CHECK + val placeholder = LocalEditorStrings.current.text(EditorString.LIST_ITEM_PLACEHOLDER) + val hover = remember { MutableInteractionSource() } + val hovered by hover.collectIsHoveredAsState() + var focused by remember { mutableStateOf(false) } + Row( + // A nested item sits in by one marker column a level, on the side its own text starts. + modifier = modifier.fillMaxWidth().padding(start = marker.levelIndent * item.level).hoverable(hover), + verticalAlignment = if (checklist) Alignment.Top else Alignment.CenterVertically, + ) { + when (marker.type) { + BlockType.LIST_CHECK -> RowCheckMark( + state = checkState(item, state.text.toString().ifBlank { placeholder }, locks, editor), + marker = marker, + ) + + BlockType.LIST_NUMBER -> Marker("${marker.number}.", marker.width, LocalEditorTheme.current.dataStyle) + + // A bullet says nothing TalkBack should read out before every item. + else -> Marker("•", marker.width, listTextStyle(), Modifier.clearAndSetSemantics { }) + } + ListItemField( + field = field, + input = editor.inputFor(item.id), + checked = checklist && item.isCheck, + placeholder = placeholder, + modifier = Modifier + .weight(1f) + .then( + if (checklist) { + Modifier.padding(vertical = LocalEditorTheme.current.spacing.textGap) + } else { + Modifier.alignByBaseline() + }, + ) + .then(fieldModifier) + .onFocusChanged { focused = it.isFocused } + .onPreviewKeyEvent { event -> editor.keyEvent(event, item.id, field, context) }, + ) + if (context.editable && context.reorderable) { + val lineHeight = with(LocalDensity.current) { field.metrics.lineHeight.toDp() } + ItemHandle(item.id, context, active = focused || hovered, checklist = checklist, lineHeight = lineHeight) + } + } +} + +/** The list's edits, all by item id, so a pending edit can never land on the wrong item. */ +private class ListEditor( + private val block: Block.ListBlock, + private val actions: () -> BlockActions, + private val defer: (() -> Unit) -> Unit, + private val focusItem: (ItemFocus) -> Unit, +) { + private val items: List get() = block.items.value + + /** The id of the list. */ + val blockId: String get() = block.id + + /** Select all pressed twice in an item's field (see selectAllTextKey). */ + fun selectAllText(event: KeyEvent, selection: TextRange, length: Int): Boolean = + selectAllTextKey(event, selection, length, actions()) + + /** Keys that start, move or act on a text selection across blocks (see rangeKey). */ + fun rangeKey(event: KeyEvent, field: RangeField): Boolean = rangeKey(event, field, actions()) + + private fun update(mergeable: Boolean, change: MutableList.() -> Unit) { + actions().onListItemsChanged(items.toMutableList().apply(change), mergeable) + } + + fun setText(itemId: String, text: String) = update(mergeable = true) { + val at = indexOfFirst { it.id == itemId } + if (at >= 0) this[at] = this[at].copy(value = text) + } + + fun setChecked(itemId: String, checked: Boolean) = update(mergeable = false) { + val at = indexOfFirst { it.id == itemId } + if (at >= 0) this[at] = this[at].copy(isCheck = checked) + } + + private val inputs = mutableMapOf() + + /** The writing rules of item [itemId]'s field: Enter and pasted lines go to the editor. */ + fun inputFor(itemId: String): BlockInput = + inputs.getOrPut(itemId) { BlockInput(BlockField.ListItem(itemId), defer, actions) } + + /** + * The keys of item [itemId]'s field: those that indent, outdent or move the item (see handleKey), start or move a + * selection across blocks, select all, or move the cursor to another item or block. + */ + fun keyEvent(event: KeyEvent, itemId: String, field: ItemField, context: ItemContext): Boolean { + val state = field.state + val selection = state.selection + val line = if (event.isArrow()) { + caretLine(field.highlight.geometry(), state.text.length, selection.end) + } else { + null + } + return context.handleKey(event, itemId) || + rangeKey(event, rangeField(blockId, itemId, selection, line)) || + selectAllText(event, selection, state.text.length) || + KeyboardUtils.handleKeyEvent( + event = event, + text = state.text, + cursorPosition = if (selection.collapsed) selection.start else -1, + onBackspaceAtStart = { backspaceAtStart(itemId) }, + onNavigateUp = { moveFocus(itemId, CaretMove.Up(line?.x)) }, + onNavigateDown = { moveFocus(itemId, CaretMove.Down(line?.x)) }, + onNavigateLeft = { moveFocus(itemId, CaretMove.Left) }, + onNavigateRight = { moveFocus(itemId, CaretMove.Right) }, + line = line, + ) + } + + /** Backspace at the start of an item (see listBackspaceAtStart). */ + fun backspaceAtStart(itemId: String): Boolean = actions().onListBackspaceAtStart(itemId) + + /** + * A cursor key that leaves item [itemId]'s field: the item above or below takes the cursor (at the + * same column for Up and Down), and from the first or last item the block above or below does. + * False when there is nowhere to go; nothing is added. + */ + fun moveFocus(itemId: String, move: CaretMove): Boolean { + val at = items.indexOfFirst { it.id == itemId } + val neighbour = items.getOrNull(if (move.goesUp) at - 1 else at + 1) + if (neighbour == null) return actions().navigate(move) + val focus = when (move) { + is CaretMove.Up -> ItemFocus(neighbour.id, null, move.x, fromBelow = true) + is CaretMove.Down -> ItemFocus(neighbour.id, null, move.x) + CaretMove.Left -> ItemFocus(neighbour.id, Int.MAX_VALUE) + CaretMove.Right -> ItemFocus(neighbour.id, 0) + } + focusItem( + when { + // Without a known column, Up lands at the end of the item above and Down at the start of the one below. + focus.caretX == null && move is CaretMove.Up -> ItemFocus(neighbour.id, Int.MAX_VALUE) + + focus.caretX == null && move is CaretMove.Down -> ItemFocus(neighbour.id, 0) + + else -> focus + }, + ) + return true + } +} + +@Composable +internal fun listTextStyle(): TextStyle { + val theme = LocalEditorTheme.current + val spacing = theme.lineSpacing + return MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + fontFamily = theme.fontFamilies.body, + fontSize = theme.fontSizes.list, + lineHeight = spacing.lineHeight(theme.fontSizes.list, MaterialTheme.typography.bodyLarge.lineHeight), + // The extra (or missing) line height is shared above and below the glyphs, so a line's text + // is centred in its line box at every spacing and size: the marker centres on that box. + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None), + ) +} + +/** A bullet or number in its hanging-indent column, on the item's first baseline. */ +@Composable +private fun RowScope.Marker(text: String, width: Dp, style: TextStyle, modifier: Modifier = Modifier) { + val theme = LocalEditorTheme.current + Text( + text = text, + style = style.copy(fontSize = theme.fontSizes.list), + color = MaterialTheme.colorScheme.onSurfaceVariant, + // The glyph hangs against the text: its right edge is one gap short of the text, as the checkbox's is. + textAlign = TextAlign.End, + modifier = modifier + .width(width) + .padding(end = markerGap(listFontDp())) + .alignByBaseline(), + ) +} + +/** + * The checklist box, flush with the paragraphs above, in the marker column. Its middle sits on the + * middle of the item's first line at every line spacing and size ([LineMetrics], taken from the + * text style, not from any item's layout, so it cannot lag or drift between rows); its side and the + * gap after it grow with the text ([ListMarker]). The tap target is the column, widened to 48dp by + * Compose's minimum touch size. Announced with the item's text, so TalkBack says what is checked. + */ +@Composable +private fun RowScope.RowCheckMark(state: CheckState, marker: ListMarker) { + val metrics = marker.metrics + val pad = with(LocalDensity.current) { LocalEditorTheme.current.spacing.textGap.toPx() } + Layout( + content = { + EditorCheckbox( + checked = state.checked, + boxSize = marker.box, + modifier = Modifier.testTag(CHECK_BOX_TAG), + ) + }, + modifier = Modifier + .editorInteractive(enabled = state.enabled) + .toggleable( + value = state.checked, + enabled = state.enabled, + role = Role.Checkbox, + onValueChange = state.onToggle, + ) + .semantics { contentDescription = state.label }, + ) { measurables, constraints -> + val box = measurables.single().measure(constraints.copy(minWidth = 0, minHeight = 0)) + val centre = pad + metrics.centre + val top = (centre - box.height / 2f).roundToInt().coerceAtLeast(0) + layout(marker.width.roundToPx(), top + box.height) { box.placeRelative(0, top) } + } +} + +private fun checkState(item: ItemListBlock, label: String, locks: ItemLocks, editor: ListEditor) = + CheckState(item.isCheck, label, enabled = !locks.editorLocked, onToggle = { editor.setChecked(item.id, it) }) + +/** What a checklist box shows and does: ticked or not, the item's text for TalkBack, and its toggle. */ +private class CheckState( + val checked: Boolean, + val label: String, + val enabled: Boolean, + val onToggle: (Boolean) -> Unit, +) + +/** The test tag of the drawn checkbox square. */ +internal const val CHECK_BOX_TAG = "checklist-box" + +@Composable +private fun ListItemField( + field: ItemField, + input: BlockInput, + checked: Boolean, + placeholder: String, + modifier: Modifier = Modifier, +) { + val state = field.state + val locks = field.locks + val highlight = field.highlight + val metrics = field.metrics + val base = listTextStyle() + val emptyShift = metrics.emptyShift.roundToInt() + val lineHeightDp = with(LocalDensity.current) { metrics.lineHeight.toDp() } + var focused by remember { mutableStateOf(false) } + // Checked items stay editable; they are only muted and struck through. + val mutedColor = MaterialTheme.colorScheme.onSurfaceVariant + val style = if (checked) base.copy(color = mutedColor) else base + val fontPx = with(LocalDensity.current) { base.fontSize.toPx() } + BasicTextField( + state = state, + // A locked checklist's text cannot take focus at all, so no cursor and no keyboard. + enabled = !locks.textLocked, + readOnly = locks.editorLocked, + inputTransformation = input, + textStyle = style, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = WritingKeyboard, + onTextLayout = highlight.onTextLayout, + modifier = modifier + .onFocusChanged { focused = it.isFocused } + .then(highlight.modifier) + .strikeThrough(highlight, checked, mutedColor, fontPx), + decorator = { innerTextField -> + // An empty field is laid out without the line height; it is lowered and given the height of + // a typed line so a new item looks, and has its caret, where it will once typing starts. + Box(modifier = Modifier.fillMaxWidth().heightIn(min = lineHeightDp)) { + if (state.text.isEmpty() && !locks.editorLocked && focused) { + Text( + text = placeholder, + style = base.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), + ), + ) + } + Box(modifier = Modifier.offset { IntOffset(0, if (state.text.isEmpty()) emptyShift else 0) }) { + innerTextField() + } + } + }, + ) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ListReorder.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ListReorder.kt new file mode 100644 index 00000000..940187af --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ListReorder.kt @@ -0,0 +1,361 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +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.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.MAX_LIST_LEVEL +import com.pasich.encly.editor.model.siblingOffsetAt +import com.pasich.encly.editor.model.siblingStarts +import com.pasich.encly.editor.model.subtreeEnd +import com.pasich.encly.editor.theme.LocalEditorTheme + +// Reordering and nesting list items by hand: the drag handle at the end of an item, TalkBack's actions on it, and the +// keys that indent, outdent and move the item the cursor is in. + +/** The width of an item's drag handle, which is also the strip a drag or long press starts in. */ +internal val ReorderHandleWidth = 28.dp +private val GripSize = 18.dp +private const val GRIP_ALPHA = 0.55f +private val DropLineWidth = 2.dp + +/** The test tag of an item's drag handle. */ +internal const val ITEM_HANDLE_TAG = "list-item-handle" + +/** What a key does to the list item the cursor is in. */ +internal enum class ListItemKey { INDENT, OUTDENT, MOVE_UP, MOVE_DOWN } + +/** + * The list-item key for [key] going [type] with the modifiers held ([command] is Ctrl or Cmd), null for any other. Tab + * indents and Shift+Tab outdents; Alt+Shift with Up or Down moves the item. The block move chord (Ctrl or Cmd+Shift+Up + * or Down) is a different one, and so is a plain Shift or Alt+arrow, which the text field keeps. + */ +internal fun listItemKeyOf(key: Key, type: KeyEventType, command: Boolean, alt: Boolean, shift: Boolean): ListItemKey? { + if (type != KeyEventType.KeyDown || command) return null + return when { + key == Key.Tab && !alt -> if (shift) ListItemKey.OUTDENT else ListItemKey.INDENT + key == Key.DirectionUp && alt && shift -> ListItemKey.MOVE_UP + key == Key.DirectionDown && alt && shift -> ListItemKey.MOVE_DOWN + else -> null + } +} + +/** The list-item key [this] event is, if any. */ +internal fun KeyEvent.listItemKey(): ListItemKey? = + listItemKeyOf(key, type, command = isCtrlPressed || isMetaPressed, alt = isAltPressed, shift = isShiftPressed) + +/** What can be done to one item: which ways it can move, and whether it can go a level deeper or out. */ +internal data class ItemMoves(val up: Boolean, val down: Boolean, val indent: Boolean, val outdent: Boolean) + +/** What each of [rows] (items with valid levels) can do, found in one pass over the list. */ +internal fun movesOf(rows: List): List { + val seen = BooleanArray(MAX_LIST_LEVEL + 1) + val hasBefore = rows.map { row -> + val level = row.level + seen[level].also { + seen[level] = true + for (deeper in level + 1..MAX_LIST_LEVEL) seen[deeper] = false + } + } + seen.fill(false) + val hasAfter = rows.asReversed().map { row -> + val level = row.level + seen[level].also { + seen[level] = true + for (deeper in level + 1..MAX_LIST_LEVEL) seen[deeper] = false + } + }.asReversed() + return rows.mapIndexed { at, row -> + val deepest = (at until rows.subtreeEnd(at)).maxOf { rows[it].level } + ItemMoves( + up = hasBefore[at], + down = hasAfter[at], + indent = at > 0 && row.level <= rows[at - 1].level && deepest < MAX_LIST_LEVEL, + outdent = row.level > 0, + ) + } +} + +/** + * What an item of a list can do and be asked to do: the list's [drag], what this item can do ([moves]), whether the + * list may be changed at all ([editable]), whether it has several items to reorder ([reorderable]), and the two + * edits, [onMove] and [onShift]. + */ +internal class ItemContext( + val drag: ListDrag, + val moves: ItemMoves, + val editable: Boolean, + val reorderable: Boolean, + val onMove: (itemId: String, by: Int) -> Unit, + val onShift: (itemId: String, deeper: Boolean) -> Unit, +) + +/** + * Carries out [event] on item [itemId] if it is a list-item key (see [listItemKeyOf]). The key is taken whether or not + * the item can go that way, so Tab never leaves the list for the next field half way through editing. + */ +internal fun ItemContext.handleKey(event: KeyEvent, itemId: String): Boolean { + val key = event.listItemKey() ?: return false + when (key) { + ListItemKey.INDENT -> onShift(itemId, true) + ListItemKey.OUTDENT -> onShift(itemId, false) + ListItemKey.MOVE_UP -> onMove(itemId, -1) + ListItemKey.MOVE_DOWN -> onMove(itemId, 1) + } + return true +} + +/** + * A drag of one item in progress: which item moves (with the ones nested under it), how far the pointer has taken it, + * and where each row sits, so that dropping it can tell how many places it moved. The [rows] of the list are read when + * it matters. + */ +internal class ListDrag(private val rows: () -> List) { + /** The item being dragged; null while none is. */ + var itemId by mutableStateOf(null) + private set + + /** The items that move with it: the item and those nested under it. */ + var moving by mutableStateOf(emptySet()) + private set + + /** How far the pointer has taken the item from where it started, in pixels. */ + var offset by mutableFloatStateOf(0f) + private set + + private val tops = mutableMapOf() + private val bottoms = mutableMapOf() + + /** Records where row [id] sits in the list, from its top to its bottom. */ + fun place(id: String, top: Float, bottom: Float) { + tops[id] = top + bottoms[id] = bottom + } + + fun begin(id: String) { + val rows = rows() + val at = rows.indexOfFirst { it.id == id } + if (at < 0) return + itemId = id + offset = 0f + moving = (at until rows.subtreeEnd(at)).mapTo(HashSet()) { rows[it].id } + } + + fun by(dy: Float) { + offset += dy + } + + /** How many places among its siblings the dragged item is now over: negative is up. */ + @Suppress("ReturnCount") // Whatever is missing, nothing is dragged or its place is not known: no move. + fun placesMoved(): Int { + val rows = rows() + val id = itemId ?: return 0 + val at = rows.indexOfFirst { it.id == id } + if (at < 0) return 0 + val top = tops[id] ?: return 0 + val bottom = bottoms[rows[rows.subtreeEnd(at) - 1].id] ?: return 0 + val centre = (top + bottom) / 2 + offset + return rows.siblingOffsetAt(at, rows.map { tops[it.id] ?: 0f }, rows.map { bottoms[it.id] ?: 0f }, centre) + } + + /** The vertical position of the line that shows where the dragged item would land; null when it would not move. */ + @Suppress("ReturnCount") // No drag, or no move: no line. + fun dropLineAt(): Float? { + val rows = rows() + val id = itemId ?: return null + val at = rows.indexOfFirst { it.id == id } + val places = placesMoved() + if (at < 0 || places == 0) return null + val siblings = rows.siblingStarts(at) + val target = siblings[siblings.indexOf(at) + places] + return if (places < 0) tops[rows[target].id] else bottoms[rows[rows.subtreeEnd(target) - 1].id] + } + + fun end() { + itemId = null + moving = emptySet() + offset = 0f + } +} + +/** + * Draws a line across the list at the height [y] gives, where a dragged item would land; nothing while it gives + * null. [y] is read while drawing, so a drag redraws the line without recomposing the list. + */ +internal fun Modifier.dropLine(color: Color, y: () -> Float?): Modifier = drawWithContent { + drawContent() + y()?.let { line -> drawLine(color, Offset(0f, line), Offset(size.width, line), strokeWidth = DropLineWidth.toPx()) } +} + +/** Reports the place of a row of item [id] to [drag], and moves the row (with those nested under it) while dragged. */ +internal fun Modifier.dragged(drag: ListDrag, id: String): Modifier = this + .onGloballyPositioned { coordinates -> + val top = coordinates.positionInParent().y + drag.place(id, top, top + coordinates.size.height) + } + .zIndex(if (id in drag.moving) 1f else 0f) + .graphicsLayer { if (id in drag.moving) translationY = drag.offset } + +/** + * TalkBack's actions on item [itemId]: move it up or down and indent or outdent it, each only where the item can go + * that way and the list may be changed. + */ +@Composable +private fun itemActions(itemId: String, context: ItemContext): List { + val strings = LocalEditorStrings.current + val moves = context.moves + val movable = context.editable && context.reorderable + return buildList { + fun action(label: EditorString, run: () -> Unit) = + add(CustomAccessibilityAction(strings.text(label)) { run().let { true } }) + if (movable && moves.up) action(EditorString.LIST_ITEM_MOVE_UP) { context.onMove(itemId, -1) } + if (movable && moves.down) action(EditorString.LIST_ITEM_MOVE_DOWN) { context.onMove(itemId, 1) } + if (context.editable && moves.indent) action(EditorString.LIST_ITEM_INDENT) { context.onShift(itemId, true) } + if (context.editable && moves.outdent) action(EditorString.LIST_ITEM_OUTDENT) { context.onShift(itemId, false) } + } +} + +/** + * The handle of item [itemId] at the end of its row, aligned with the first line ([lineHeight] high, below the + * [checklist]'s padding), shown while the row is [active] (written in or hovered) or the item is dragged. Dropping + * it moves the item in the list. + */ +@Composable +internal fun RowScope.ItemHandle( + itemId: String, + context: ItemContext, + active: Boolean, + checklist: Boolean, + lineHeight: Dp, +) { + val actions = itemActions(itemId, context) + ItemDragHandle( + itemId = itemId, + drag = context.drag, + spec = HandleSpec( + visible = active || context.drag.itemId == itemId, + label = LocalEditorStrings.current.text(EditorString.LIST_ITEM_REORDER), + height = lineHeight, + topPadding = if (checklist) LocalEditorTheme.current.spacing.textGap else 0.dp, + ), + onDrop = { places -> context.onMove(itemId, places) }, + modifier = Modifier.align(Alignment.Top), + semantics = { customActions = actions }, + ) +} + +/** How a handle looks: whether it is [visible], its [label] and size. */ +internal class HandleSpec(val visible: Boolean, val label: String, val height: Dp, val topPadding: Dp) + +/** + * The handle of an item: a grip, shown while [HandleSpec.visible]. A mouse drags it at once and shows a hand over it; + * a finger drags after a long press. The item follows through [drag]; letting go asks [onDrop] to move it by the + * number of places it was taken. The label is what TalkBack reads, with whatever [semantics] adds. + */ +@Composable +internal fun ItemDragHandle( + itemId: String, + drag: ListDrag, + spec: HandleSpec, + onDrop: (Int) -> Unit, + modifier: Modifier = Modifier, + semantics: SemanticsPropertyReceiver.() -> Unit = {}, +) { + val haptics = LocalHapticFeedback.current + val currentDrop by rememberUpdatedState(onDrop) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .padding(top = spec.topPadding) + .width(ReorderHandleWidth) + .height(spec.height) + .pointerHoverIcon(PointerIcon.Hand) + .testTag(ITEM_HANDLE_TAG) + .semantics { + contentDescription = spec.label + semantics() + } + .pointerInput(itemId) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val start = if (down.type == PointerType.Mouse) { + awaitTouchSlopOrCancellation(down.id) { change, _ -> change.consume() } + } else { + awaitLongPressOrCancellation(down.id) + } + if (start != null) { + if (down.type != PointerType.Mouse) haptics.performHapticFeedback(HapticFeedbackType.LongPress) + drag.begin(itemId) + // A mouse has already moved past the slop by now; the item goes with it. + drag.by(start.position.y - down.position.y) + val finished = drag(start.id) { change -> + drag.by(change.positionChange().y) + change.consume() + } + val places = if (finished) drag.placesMoved() else 0 + drag.end() + if (places != 0) currentDrop(places) + } + } + }, + ) { + if (spec.visible) { + Icon( + LocalEditorTheme.current.icons.grip, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = GRIP_ALPHA), + modifier = Modifier.width(GripSize).height(GripSize), + ) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/NoteLink.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/NoteLink.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/NoteLink.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/NoteLink.kt index e3d83a68..8e320507 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/NoteLink.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/NoteLink.kt @@ -1,14 +1,13 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks -import com.pasich.encly.domain.model.LinkDataBlock -import java.net.IDN +import com.pasich.encly.editor.model.LinkDataBlock // What a note link may be. Encly never fetches a link: it shows the address and hands it to an // app the user picks. Only web and mail links are saved or opened, and what the card shows is // read the way that app will read it, so the card can never name a different site. /** A note link, read from its address. */ -sealed interface NoteLink { +internal sealed interface NoteLink { val url: String /** An http(s) link to [host]; [detail] is the rest of the address, shown under it. */ @@ -73,13 +72,21 @@ private fun parseWebLink(url: String): NoteLink { /** * [host] as the card shows it: lower case, and a name in other scripts in its ASCII (punycode) * form, as browsers show one that could imitate another site ("аpple.com" with a Cyrillic "а"). - * Null when it is not a valid name. + * Null when it is not a valid name, or one that browsers and the platform's IDNA 2003 tables read + * differently: "faß.de" is "xn--fa-hia.de" in a browser but "fass.de" to [toAsciiHost]. */ -private fun displayHost(host: String): String? { +internal fun displayHost(host: String): String? { val lower = host.lowercase() - return if (lower.all { it.code < ASCII_END }) lower else runCatching { IDN.toASCII(lower) }.getOrNull() + return when { + lower.all { it.code < ASCII_END } -> lower + lower.any { it in IDNA_DEVIATIONS } -> null + else -> toAsciiHost(lower) + } } +// The characters IDNA 2003 maps or drops and UTS 46 keeps: sharp s, final sigma, the zero-width joiners. +private const val IDNA_DEVIATIONS = "\u00DF\u03C2\u200C\u200D" + private const val ASCII_END = 0x80 private fun parseMailLink(url: String): NoteLink { @@ -88,7 +95,7 @@ private fun parseMailLink(url: String): NoteLink { } /** Whether Encly may hand [url] to another app to open. */ -internal fun isOpenableLink(url: String): Boolean = parseNoteLink(url) !is NoteLink.Blocked +public fun isOpenableLink(url: String): Boolean = parseNoteLink(url) !is NoteLink.Blocked /** * The link to store for the address [rawUrl] the user entered or pasted; null when it may not be diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/Punycode.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/Punycode.kt new file mode 100644 index 00000000..975cc0d8 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/Punycode.kt @@ -0,0 +1,121 @@ +package com.pasich.encly.editor.ui.blocks + +/** + * Host names in their ASCII form (RFC 3492 Punycode, the `xn--` labels of IDNA), for the platforms + * without a system call for it (iOS). The host arrives lower-cased, so there is no case folding or + * Unicode normalization here; a label that cannot be encoded or is longer than DNS allows makes the + * whole name invalid. + */ +internal object Punycode { + private const val BASE = 36 + private const val T_MIN = 1 + private const val T_MAX = 26 + private const val SKEW = 38 + private const val DAMP = 700 + private const val INITIAL_BIAS = 72 + private const val INITIAL_N = 128 + private const val DELIMITER = '-' + private const val PREFIX = "xn--" + private const val ASCII_END = 0x80 + private const val MAX_LABEL = 63 + private const val SUPPLEMENTARY_START = 0x10000 + private const val SURROGATE_BITS = 10 + private const val LETTERS = 26 + + /** [host] with every label that has a non-ASCII character encoded; null when it is not a valid name. */ + @Suppress("ReturnCount") // Every invalid label ends the name at once. + fun toAsciiHost(host: String): String? { + val labels = host.split('.') + val encoded = ArrayList(labels.size) + for ((index, label) in labels.withIndex()) { + // Only the root dot at the very end may leave a label empty ("example.com."). + if (label.isEmpty()) { + if (index == labels.lastIndex && index > 0) encoded += "" else return null + continue + } + // An encoded label is never shorter than the label, so a long one is invalid before it is encoded + // (which also keeps the arithmetic of the encoder far from overflowing). + if (label.length > MAX_LABEL) return null + val ascii = if (label.all { it.code < ASCII_END }) label else PREFIX + encode(label) + if (ascii.length > MAX_LABEL) return null + encoded += ascii + } + return encoded.joinToString(".") + } + + /** The Punycode of [label] (RFC 3492 section 6.3), without the `xn--` prefix. */ + private fun encode(label: String): String { + val input = codePoints(label) + val output = StringBuilder() + input.filter { it < ASCII_END }.forEach { output.append(it.toChar()) } + val basic = output.length + var handled = basic + if (basic > 0) output.append(DELIMITER) + var n = INITIAL_N + var delta = 0 + var bias = INITIAL_BIAS + while (handled < input.size) { + val next = input.filter { it >= n }.min() + delta += (next - n) * (handled + 1) + n = next + for (c in input) { + if (c < n) delta++ + if (c == n) { + appendDelta(output, delta, bias) + bias = adapt(delta, handled + 1, handled == basic) + delta = 0 + handled++ + } + } + delta++ + n++ + } + return output.toString() + } + + /** [delta] as a generalized variable-length integer in base 36. */ + private fun appendDelta(output: StringBuilder, delta: Int, bias: Int) { + var q = delta + var k = BASE + while (true) { + val t = (k - bias).coerceIn(T_MIN, T_MAX) + if (q < t) break + output.append(digit(t + (q - t) % (BASE - t))) + q = (q - t) / (BASE - t) + k += BASE + } + output.append(digit(q)) + } + + private fun adapt(delta: Int, points: Int, first: Boolean): Int { + var d = if (first) delta / DAMP else delta / 2 + d += d / points + var k = 0 + while (d > ((BASE - T_MIN) * T_MAX) / 2) { + d /= BASE - T_MIN + k += BASE + } + return k + (BASE - T_MIN + 1) * d / (d + SKEW) + } + + /** 0..25 are a..z, 26..35 are 0..9. */ + private fun digit(value: Int): Char = if (value < LETTERS) 'a' + value else '0' + (value - LETTERS) + + private fun codePoints(text: String): List { + val points = ArrayList(text.length) + var index = 0 + while (index < text.length) { + val char = text[index] + val low = text.getOrNull(index + 1) + if (char.isHighSurrogate() && low != null && low.isLowSurrogate()) { + points += SUPPLEMENTARY_START + ((char - Char.MIN_HIGH_SURROGATE) shl SURROGATE_BITS) + + (low - Char.MIN_LOW_SURROGATE) + index += 2 + } else { + points += char.code + index++ + } + } + return points + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/QuoteBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/QuoteBlock.kt new file mode 100644 index 00000000..30fc97b0 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/QuoteBlock.kt @@ -0,0 +1,130 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.theme.EDITOR_PLACEHOLDER_ALPHA +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.theme.lineHeight +import com.pasich.encly.editor.ui.BlockActions + +/** The closing quote mark is the opening one turned around. */ +private const val HALF_TURN = 180f + +/** + * [focusRequester] and [fieldModifier] reach the quote's text field, [modifier] the card that + * draws the quote. + */ +@Composable +internal fun QuoteBlock( + block: Block.QuoteBlock, + blockActions: BlockActions, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, + isLocked: Boolean = false, +) { + val theme = LocalEditorTheme.current + val spacing = theme.spacing + val state = rememberBlockTextFieldState(block.text, blockActions) + val actions by rememberUpdatedState(blockActions) + // Enter ends the quote: the text after the cursor goes on as a paragraph (BlockInput). + val defer = rememberDefer() + val input = remember { BlockInput(BlockField.Styled, defer) { actions } } + val highlight = rememberFieldHighlight(blockActions.highlights, block.id) + RegisterCaretAtX(blockActions, state, highlight) + var focused by remember { mutableStateOf(false) } + val quoteStyle = theme.quoteStyle.copy( + color = MaterialTheme.colorScheme.onSurface, + fontSize = theme.fontSizes.quote, + lineHeight = theme.lineSpacing.lineHeight( + fontSize = theme.fontSizes.quote, + normal = theme.quoteStyle.lineHeight, + ), + ) + + // A tonal card between an opening and a closing quote mark in `primary`, 8 dp below the block + // above and 8 dp above the one below (design spec §4.4). + Column( + verticalArrangement = Arrangement.spacedBy(spacing.labelGap), + modifier = modifier + .padding(vertical = spacing.xs) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.medium) + .padding(horizontal = spacing.fieldGap, vertical = spacing.m), + ) { + QuoteMark() + BasicTextField( + state = state, + readOnly = isLocked, + inputTransformation = input, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = WritingKeyboard, + textStyle = quoteStyle, + onTextLayout = highlight.onTextLayout, + modifier = fieldModifier + .focusRequester(focusRequester) + .onFocusChanged { focused = it.isFocused } + .fillMaxWidth() + .then(highlight.modifier) + .textBlockKeys(state, blockActions, highlight), + decorator = { innerTextField -> + QuoteDecoration(state.text.isEmpty() && focused, quoteStyle, innerTextField) + }, + ) + // The closing mark: the opening one turned around, at the end of the quote. + QuoteMark(Modifier.align(Alignment.End).rotate(HALF_TURN)) + } +} + +/** A quote mark in `primary`; the closing one is this turned around. */ +@Composable +private fun QuoteMark(modifier: Modifier = Modifier) { + val theme = LocalEditorTheme.current + Icon( + theme.icons.quote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = modifier.size(theme.spacing.iconMedium), + ) +} + +@Composable +private fun QuoteDecoration(showPlaceholder: Boolean, style: TextStyle, innerTextField: @Composable () -> Unit) { + Box(modifier = Modifier.fillMaxWidth()) { + if (showPlaceholder) { + Text( + text = LocalEditorStrings.current.text(EditorString.QUOTE), + style = style.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), + ), + ) + } + innerTextField() + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/RangeInput.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/RangeInput.kt new file mode 100644 index 00000000..bd1bdbc7 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/RangeInput.kt @@ -0,0 +1,131 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +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.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.isShiftPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextRange +import com.pasich.encly.editor.focus.CaretLine +import com.pasich.encly.editor.state.HeadMove +import com.pasich.encly.editor.state.TextPoint +import com.pasich.encly.editor.ui.BlockActions +import kotlinx.coroutines.flow.drop + +/** + * Watches the mouse over a field for a drag or a Shift+click that runs into another field, which makes a text + * selection across blocks. It only looks (events are seen first and never consumed), so the field's own selection + * goes on; a touch is left to the selection handles. + */ +internal fun Modifier.rangeMouse(field: FieldHighlight): Modifier = pointerInput(field) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + if (down.type != PointerType.Mouse) return@awaitEachGesture + field.beginMouse(down.position, shift = currentEvent.keyboardModifiers.isShiftPressed) + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.firstOrNull { it.id == down.id }?.let { field.dragMouse(it.position) } + } while (event.changes.any { it.pressed }) + field.endMouse() + } +} + +/** + * Keeps the text selection across blocks in step with what the field itself has selected: a selection of the + * field's own (a long press, a drag, Ctrl+A, Shift+arrows) is the whole selection until it runs into another + * field, and its handles and tint are drawn the same way. Also lets the selection reach the field's own state, so a + * handle dragged inside the field moves the field's selection. + */ +@Composable +internal fun BindFieldRange(state: TextFieldState, blockActions: BlockActions, highlight: FieldHighlight) { + val actions by rememberUpdatedState(blockActions) + DisposableEffect(state, highlight) { + highlight.state = state + onDispose { if (highlight.state === state) highlight.state = null } + } + // The first value is where the field starts, not something the user selected. + LaunchedEffect(state, highlight) { + snapshotFlow { + state.selection + }.drop(1).collect { selection -> highlight.onSelection(selection, actions.textRange) } + } +} + +/** What the field does with [event] while a text selection across blocks exists or may start; true if it took it. */ +internal fun rangeKey(event: KeyEvent, field: RangeField, actions: BlockActions): Boolean { + if (event.type != KeyEventType.KeyDown) return false + val range = actions.textRange + val command = event.isCtrlPressed || event.isMetaPressed + val move = if (!event.isShiftPressed) { + null + } else if (command) { + event.key.noteMove() + } else { + event.key.headMove(field.rtl) + } + return if (move != null) { + range.extend(move, field.anchor, field.caret, onEdgeLine = field.edge(move)) + } else { + range.isCrossField && crossKey(event, command, actions) + } +} + +/** What the keys do over a selection that runs across fields; true if one was taken. */ +private fun crossKey(event: KeyEvent, command: Boolean, actions: BlockActions): Boolean = when { + event.key == Key.Escape -> true.also { actions.textRange.clear() } + event.key == Key.Delete || event.key == Key.Backspace || event.key == Key.Enter -> actions.replaceTextRange("") + command && event.key == Key.C -> actions.copyTextRange(markdown = event.isShiftPressed) + command && event.key == Key.X -> actions.cutTextRange() + else -> false +} + +/** What a field tells [rangeKey] about itself: its selection as points, its direction and its cursor's line. */ +internal class RangeField(val anchor: TextPoint, val caret: TextPoint, val rtl: Boolean, private val line: CaretLine?) { + /** Whether the cursor is on the line that Up (or Down) would leave the field from. */ + fun edge(move: HeadMove): Boolean = if (move == HeadMove.UP) line?.onFirst == true else line?.onLast == true +} + +/** The field's selection as points, from its text state [selection] in the field [itemId] of block [blockId]. */ +internal fun rangeField(blockId: String, itemId: String?, selection: TextRange, line: CaretLine?) = RangeField( + anchor = TextPoint(blockId, selection.start, itemId), + caret = TextPoint(blockId, selection.end, itemId), + rtl = line?.rtl == true, + line = line, +) + +/** The move that [this] key makes the end of a selection make, with Shift held; null for any other key. */ +internal fun Key.headMove(rtl: Boolean): HeadMove? = when (this) { + Key.DirectionLeft -> if (rtl) HeadMove.NEXT else HeadMove.PREVIOUS + Key.DirectionRight -> if (rtl) HeadMove.PREVIOUS else HeadMove.NEXT + Key.DirectionUp -> HeadMove.UP + Key.DirectionDown -> HeadMove.DOWN + Key.MoveHome -> HeadMove.FIELD_START + Key.MoveEnd -> HeadMove.FIELD_END + Key.PageUp -> HeadMove.PAGE_UP + Key.PageDown -> HeadMove.PAGE_DOWN + else -> null +} + +/** The move of Ctrl or Cmd+Shift+Home and End: the start and the end of the note; null for any other key. */ +internal fun Key.noteMove(): HeadMove? = when (this) { + Key.MoveHome -> HeadMove.NOTE_START + Key.MoveEnd -> HeadMove.NOTE_END + else -> null +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ReadingDirection.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ReadingDirection.kt new file mode 100644 index 00000000..11a3ec96 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ReadingDirection.kt @@ -0,0 +1,69 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import kotlinx.coroutines.flow.StateFlow + +private const val LEFT_TO_RIGHT_MARK = '\u200E' +private const val RIGHT_TO_LEFT_MARK = '\u200F' +private const val ARABIC_LETTER_MARK = '\u061C' + +/** The scripts written right to left: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic, Arabic Extended. */ +private val RtlBlocks = '\u0590'..'\u08FF' + +/** Hebrew and Arabic presentation forms. */ +private val RtlPresentationForms = '\uFB1D'..'\uFDFF' +private val RtlPresentationFormsB = '\uFE70'..'\uFEFF' + +/** + * The direction [text] reads in, by its first strong character (Android's `FIRSTSTRONG` rule, which is also + * what Compose picks for a paragraph): a letter of a right-to-left script, or a right-to-left mark, makes it + * right to left; any other letter, or a left-to-right mark, left to right. Digits, punctuation, symbols and + * emoji are neutral and skipped. Null when there is no strong character at all (an empty or neutral text). + */ +internal fun firstStrongDirection(text: CharSequence): LayoutDirection? = + text.asSequence().mapNotNull(::strongDirectionOf).firstOrNull() + +/** The direction [char] is strong in, or null for a neutral or weak one. */ +private fun strongDirectionOf(char: Char): LayoutDirection? = when { + char == LEFT_TO_RIGHT_MARK -> LayoutDirection.Ltr + char == RIGHT_TO_LEFT_MARK || char == ARABIC_LETTER_MARK -> LayoutDirection.Rtl + !char.isLetter() -> null + char.isRightToLeftScript() -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr +} + +private fun Char.isRightToLeftScript(): Boolean = + this in RtlBlocks || this in RtlPresentationForms || this in RtlPresentationFormsB + +/** + * Lays [content] out in the direction the paragraph [text] reads in: a block of Arabic text gets its + * marker, checkbox, indent and quote marks on the right, a block of Latin text on the left, whatever + * direction the app itself has. A text with no strong character follows the app's direction. + * + * [text] is read in a derived state, so only a change of direction recomposes [content], not each key. + */ +@Composable +internal fun ReadingDirection(text: () -> CharSequence, content: @Composable () -> Unit) { + val appDirection = LocalLayoutDirection.current + val latest by rememberUpdatedState(text) + val direction by remember(appDirection) { + derivedStateOf { firstStrongDirection(latest()) ?: appDirection } + } + CompositionLocalProvider(LocalLayoutDirection provides direction, content = content) +} + +/** [ReadingDirection] for a block whose live text is [text]. */ +@Composable +internal fun ReadingDirection(text: StateFlow, content: @Composable () -> Unit) { + val current: State = text.collectAsState() + ReadingDirection(text = { current.value }, content = content) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.kt new file mode 100644 index 00000000..c3fbde0e --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.kt @@ -0,0 +1,18 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.runtime.Composable + +/* + * The keyboard's own "Select all" (an Android IME asks the field to run the Select all menu action) only + * ever selects the text of one field. Where the field's text is already all selected, it is taken as a + * request for the whole note instead: `onSelectAll` runs and the keyboard's action is swallowed. + * + * Only Android has such a keyboard; the other platforms show the content as it is. + */ + +/** + * Shows [content] with the keyboard's Select all intercepted as above. [onSelectAll] returns whether it took + * the request (block selection is on); false lets the field select its own text. + */ +@Composable +internal expect fun BlockSelectAllIme(onSelectAll: () -> Boolean, content: @Composable () -> Unit) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllKey.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllKey.kt new file mode 100644 index 00000000..1a90e8b6 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllKey.kt @@ -0,0 +1,32 @@ +package com.pasich.encly.editor.ui.blocks + +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.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextRange +import com.pasich.encly.editor.ui.BlockActions + +/** Whether [this] is Ctrl or Cmd+A going down. */ +internal fun KeyEvent.isSelectAll(): Boolean = + isSelectAllKey(key, type, command = isCtrlPressed || isMetaPressed, shift = isShiftPressed) + +/** Whether [key] going [type] is Select all: A with Ctrl or Cmd ([command]) and no Shift. */ +internal fun isSelectAllKey(key: Key, type: KeyEventType, command: Boolean, shift: Boolean): Boolean = + type == KeyEventType.KeyDown && key == Key.A && command && !shift + +/** Whether [selection] covers a field's whole text of [length] (an empty field is covered by any cursor). */ +internal fun coversWholeText(selection: TextRange, length: Int): Boolean = + length == 0 || (selection.min == 0 && selection.max == length) + +/** + * Select all pressed twice: the first press is the field's own (it selects its text), the second, with + * [selection] already covering all of the field's text of [length], selects the whole note as text. + * True when this took the key. + */ +internal fun selectAllTextKey(event: KeyEvent, selection: TextRange, length: Int, actions: BlockActions): Boolean = + event.isSelectAll() && coversWholeText(selection, length) && actions.selectAllText() diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlock.kt new file mode 100644 index 00000000..8f2bd665 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlock.kt @@ -0,0 +1,46 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.components.editorInteractive + +/** + * A 1 dp rule in a 24 dp tap target that opens the block's sheet. Taller than the rule is only so a + * finger can find it; much taller made it easy to hit by accident when placing the cursor nearby. + */ +@Composable +internal fun SeparatorBlock(onClick: () -> Unit, modifier: Modifier = Modifier, isLocked: Boolean = false) { + val strings = LocalEditorStrings.current + val spacing = LocalEditorTheme.current.spacing + val label = strings.text(EditorString.BLOCK_SEPARATOR) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxWidth() + .height(spacing.l) + .editorInteractive(enabled = !isLocked) + // Its sheet only moves or deletes it: nothing to offer in a locked editor. + .clickable( + enabled = !isLocked, + role = Role.Button, + onClickLabel = strings.text(EditorString.MORE_OPTIONS), + onClick = onClick, + ) + .semantics { contentDescription = label }, + ) { + HorizontalDivider(thickness = spacing.hairline, color = MaterialTheme.colorScheme.outlineVariant) + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SlashMenuPopup.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SlashMenuPopup.kt new file mode 100644 index 00000000..9d317560 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SlashMenuPopup.kt @@ -0,0 +1,154 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +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.editor.EditorString +import com.pasich.encly.editor.EditorStrings +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.theme.EditorIcons +import com.pasich.encly.editor.theme.EditorToolbarReserve +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.SlashActions +import com.pasich.encly.editor.ui.components.CommandMenuItem +import com.pasich.encly.editor.ui.components.EditorCommandMenu +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * The slash picker of a paragraph: a small list of block kinds under the field, filtered by what + * was typed after the "/". It never takes focus, so the keyboard stays on the paragraph and the + * user goes on typing; Enter and the arrow keys are handled in the paragraph's key handling and + * in [BlockInput]. It stays hidden until the "/" is committed text, so a character still under IME + * composition never opens it. + */ +@Composable +internal fun SlashMenuPopup(field: TextFieldState, slash: SlashActions, modifier: Modifier = Modifier) { + // Only whether this paragraph owns the menu is observed here, so the other paragraphs do not + // recompose each time the open menu changes (a typed letter, a moved highlight). A menu opens + // only after the "/" is typed, so starting from "not the owner" never hides an open one. + val owns by remember(slash.menu, slash.blockId) { + slash.menu.map { it?.blockId == slash.blockId }.distinctUntilChanged() + }.collectAsState(initial = false) + if (owns) OpenSlashMenu(field, slash, modifier) +} + +@Composable +private fun OpenSlashMenu(field: TextFieldState, slash: SlashActions, modifier: Modifier = Modifier) { + val all by slash.menu.collectAsState() + val menu = all?.takeIf { it.blockId == slash.blockId } + val labels = slashLabels() + val strings = LocalEditorStrings.current + val icons = LocalEditorTheme.current.icons + 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) { EditorToolbarReserve.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), + ) { + EditorCommandMenu( + items = menu.matches.map { CommandMenuItem(it.icon(icons), labels.getValue(it)) }, + highlighted = menu.highlighted, + title = strings.text(EditorString.SLASH_MENU_TITLE), + onSelect = { currentSlash.select(menu.matches[it]) }, + modifier = modifier, + ) + } +} + +/** The labels built once for the whole editor (see [rememberSlashLabels]); null where nothing provides them. */ +internal val LocalSlashLabels = compositionLocalOf?> { null } + +/** The localized label of every command, for the list and for matching the query. */ +@Composable +private fun slashLabels(): Map = + LocalSlashLabels.current ?: rememberSlashLabels(LocalEditorStrings.current) + +/** + * The label of every command in [strings]' language, built once per [strings]: + * [com.pasich.encly.editor.EditorProvider] provides it as [LocalSlashLabels] above the editor. + */ +@Composable +internal fun rememberSlashLabels(strings: EditorStrings): Map = remember(strings) { + SlashCommand.entries.associateWith { command -> + command.headingLevel?.let(strings::heading) ?: strings.text(command.label) + } +} + +private fun SlashCommand.icon(icons: EditorIcons): ImageVector = when (this) { + SlashCommand.TEXT -> icons.text + SlashCommand.H1 -> icons.heading1 + SlashCommand.H2 -> icons.heading2 + SlashCommand.H3 -> icons.heading3 + SlashCommand.H4 -> icons.heading4 + SlashCommand.CHECKLIST -> icons.checklist + SlashCommand.BULLETS -> icons.bulletList + SlashCommand.NUMBERED -> icons.numberedList + SlashCommand.QUOTE -> icons.quote + SlashCommand.LINK -> icons.link + SlashCommand.DIVIDER -> icons.separator + SlashCommand.CODE -> icons.code + SlashCommand.TABLE -> icons.table +} + +/** + * 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 start = if (layoutDirection == LayoutDirection.Rtl) { + anchorBounds.right - popupContentSize.width + } else { + anchorBounds.left + } + val x = start.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/StrikeSegment.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/StrikeSegment.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/StrikeSegment.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/StrikeSegment.kt index 7d7f672e..5648751d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/StrikeSegment.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/StrikeSegment.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TableBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TableBlock.kt new file mode 100644 index 00000000..8232a97b --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TableBlock.kt @@ -0,0 +1,524 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.placeCursorAtEnd +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +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.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.focus.KeyboardUtils +import com.pasich.encly.editor.focus.caretLine +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.TableCell +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.state.CaretMove +import com.pasich.encly.editor.state.CellStep +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.TableEdit +import com.pasich.encly.editor.state.addColumnAfter +import com.pasich.encly.editor.state.addRowBelow +import com.pasich.encly.editor.state.cellText +import com.pasich.encly.editor.state.enterFrom +import com.pasich.encly.editor.state.neighborOf +import com.pasich.encly.editor.state.removeColumnOf +import com.pasich.encly.editor.state.removeRowOf +import com.pasich.encly.editor.state.tabFrom +import com.pasich.encly.editor.state.textDiff +import com.pasich.encly.editor.state.toggleHeader +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.theme.lineHeight +import com.pasich.encly.editor.ui.BlockActions +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** Every character a text field may hold as a line break. */ +private val LINE_BREAK_CHARS = setOf('\n', '\r', '\u2028', '\u2029') + +/** The width of a column; a table wider than the screen scrolls sideways. */ +private val CellWidth = 148.dp +private val CellMinHeight = 44.dp +private val CellBorder = 0.75.dp +private val MenuButtonPadding = 4.dp + +/** Test tag of the button that opens a table's cell menu. */ +internal const val TABLE_MENU_TAG = "table-menu" + +/** Which cell takes focus next, and where its cursor goes (null: where it is; [Int.MAX_VALUE]: the end). */ +private data class CellFocus(val cellId: String, val caret: Int?) + +/** The table's cell fields by cell id, the cell that has focus, and the cell to focus once it is composed. */ +private class TableFocus { + val requesters = mutableMapOf() + val states = mutableMapOf() + var pending by mutableStateOf(null) + + /** The cell whose field has focus or had it last: where the cell menu acts. */ + var current by mutableStateOf(null) +} + +/** The cell a request for the whole table means: the one it names, else by where the cursor comes from. */ +internal fun TableData.cellFor(itemId: String?, cursorToEnd: Boolean, fromBelow: Boolean): TableCell { + val named = itemId?.let(::cell) + return when { + named != null -> named + fromBelow -> rows.last().cells.first() + itemId != null || cursorToEnd -> rows.last().cells.last() + else -> rows.first().cells.first() + } +} + +/** The focus target of the table (what the editor asks for) and the carrying out of its pending focus. */ +@Composable +private fun rememberTableFocus(block: Block.TableBlock, blockActions: BlockActions): TableFocus { + val focus = remember { TableFocus() } + DisposableEffect(block, blockActions) { + val unregister = blockActions.registerFieldsFocusTarget { request -> + val cell = block.table.value.cellFor(request.itemId, request.cursorToEnd, request.fromBelow) + focus.pending = CellFocus(cell.id, request.caretOffset) + } + onDispose { unregister() } + } + // A new cell is composed a frame or two after the edit that added it. + LaunchedEffect(focus.pending) { + val request = focus.pending ?: return@LaunchedEffect + focus.pending = null + val requester = focus.requesters[request.cellId] ?: return@LaunchedEffect + runCatching { requester.requestFocus() } + val stored = block.table.value.cell(request.cellId)?.value + val state = focus.states[request.cellId] + if (stored != null && state != null && request.caret != null) state.placeCaret(stored, request.caret) + } + return focus +} + +/** + * A table: a grid of plain-text cells that scrolls sideways when it is wider than the screen (it starts at the + * reading side, so a right-to-left language mirrors the columns). Tab and Shift+Tab move between the cells, Enter + * goes down a row (adding one at the end), the arrow keys leave a cell at its edges, and a small menu adds or + * deletes rows and columns and switches the header row. Every change goes through [onTableChange], so it is + * saved and can be undone; the editor focuses the table through the target it registers + * ([BlockActions.registerFieldsFocusTarget]). + */ +@Composable +internal fun TableBlock( + block: Block.TableBlock, + blockActions: BlockActions, + onTableChange: (TableData, Boolean) -> Unit, + modifier: Modifier = Modifier, + isLocked: Boolean = false, +) { + val table by block.table.collectAsState() + val change by rememberUpdatedState(onTableChange) + val focus = rememberTableFocus(block, blockActions) + val defer = rememberDefer() + val commands = remember(block) { + TableCommands(block, focus) { edit -> + change(edit.table, false) + edit.focusCellId?.let { focus.pending = CellFocus(it, Int.MAX_VALUE) } + } + } + val values = remember(block) { CellValues(block.table) } + // Remembered, with one edit for all the cells: a new object each time would make every cell recompose on every + // keystroke, and only the cell that was typed in has anything to show. + val context = remember(block, blockActions, focus, commands, defer, values) { + CellContext(block, blockActions, focus, commands, defer, values) + } + val onCell = remember(block) { + { cellId: String, text: String -> change(block.table.value.withCell(cellId, text), true) } + } + Column(modifier = modifier.fillMaxWidth()) { + if (!isLocked) CellMenu(commands, table.header, modifier = Modifier.align(Alignment.End)) + Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { + Column(modifier = Modifier.border(CellBorder, MaterialTheme.colorScheme.outlineVariant)) { + table.rows.forEachIndexed { rowIndex, row -> + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + row.cells.forEach { cell -> + key(cell.id) { + TableCellField( + cell = cell, + isHeader = table.header && rowIndex == 0, + context = context, + onValue = onCell, + isLocked = isLocked, + ) + } + } + } + } + } + } + } +} + +/** What the commands of a table act on: the cell the cursor is in (or last was), else the first one. */ +private class TableCommands( + private val block: Block.TableBlock, + private val focus: TableFocus, + private val apply: (TableEdit) -> Unit, +) { + private fun cellId(): String = focus.current?.takeIf { block.table.value.cell(it) != null } + ?: block.table.value.rows.first().cells.first().id + + fun addRow() = block.table.value.addRowBelow(cellId())?.let(apply) + + fun addColumn() = block.table.value.addColumnAfter(cellId())?.let(apply) + + fun removeRow() = block.table.value.removeRowOf(cellId())?.let(apply) + + fun removeColumn() = block.table.value.removeColumnOf(cellId())?.let(apply) + + fun toggleHeader() = apply(block.table.value.toggleHeader()) + + /** Enter in [cellId]: down a row, adding one at the end. */ + fun enter(cellId: String) = block.table.value.enterFrom(cellId)?.let(apply) + + /** Tab in [cellId]: the next cell, adding a row after the last. */ + fun tab(cellId: String) = block.table.value.tabFrom(cellId)?.let(apply) + + /** Moves the cursor [step] cells away from [cellId]; false at the edge of the table. */ + fun step(cellId: String, step: CellStep, caret: Int): Boolean { + val target = block.table.value.neighborOf(cellId, step) ?: return false + focus.pending = CellFocus(target, caret) + return true + } +} + +/** What a cell needs of its table. */ +private class CellContext( + val block: Block.TableBlock, + val blockActions: BlockActions, + val focus: TableFocus, + val commands: TableCommands, + val defer: (() -> Unit) -> Unit, + val values: CellValues, +) + +/** + * The text of a table's cells by cell id. The map is built once per table value, so a cell field reads its text in + * constant time: with a search of the whole table for every cell, one keystroke cost every cell a pass over all of + * them. + */ +internal class CellValues(private val table: StateFlow) { + private var source: TableData? = null + private var byId = emptyMap() + + /** How many times the map was built; a test reads it to see that lookups do not scan. */ + var builds = 0 + private set + + /** The text of cell [id] in the table the model holds right now; null when it is not in it. */ + fun valueOf(id: String): String? { + val current = table.value + if (current !== source) { + source = current + byId = current.rows.flatMap { it.cells }.associate { it.id to it.value } + builds++ + } + return byId[id] + } + + /** Emits a cell's text when it changes, and only then: the other cells' fields do not wake. */ + fun changes(id: String): Flow = table.map { valueOf(id) }.distinctUntilChanged() +} + +/** The small button at the table's end that opens the menu of row, column and header commands. */ +@Composable +private fun CellMenu(commands: TableCommands, header: Boolean, modifier: Modifier = Modifier) { + val strings = LocalEditorStrings.current + val theme = LocalEditorTheme.current + var open by remember { mutableStateOf(false) } + Box(modifier) { + Icon( + theme.icons.grip, + contentDescription = strings.text(EditorString.TABLE_CELL_OPTIONS), + modifier = Modifier + .testTag(TABLE_MENU_TAG) + .clickable { open = true } + .padding(MenuButtonPadding) + .size(theme.spacing.iconMedium), + ) + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + MenuRow(strings.text(EditorString.TABLE_ADD_ROW)) { + open = false + commands.addRow() + } + MenuRow(strings.text(EditorString.TABLE_ADD_COLUMN)) { + open = false + commands.addColumn() + } + MenuRow(strings.text(EditorString.TABLE_REMOVE_ROW)) { + open = false + commands.removeRow() + } + MenuRow(strings.text(EditorString.TABLE_REMOVE_COLUMN)) { + open = false + commands.removeColumn() + } + MenuRow(strings.text(EditorString.TABLE_HEADER_ROW), checked = header) { + open = false + commands.toggleHeader() + } + } + } +} + +@Composable +private fun MenuRow(label: String, checked: Boolean = false, onClick: () -> Unit) { + DropdownMenuItem( + text = { Text(label, style = MaterialTheme.typography.labelLarge) }, + onClick = onClick, + trailingIcon = { if (checked) Icon(LocalEditorTheme.current.icons.check, contentDescription = null) }, + ) +} + +/** One cell: its text field, kept in step with the stored text, with the keys that move between cells. */ +@Composable +private fun TableCellField( + cell: TableCell, + isHeader: Boolean, + context: CellContext, + onValue: (cellId: String, text: String) -> Unit, + isLocked: Boolean, +) { + val theme = LocalEditorTheme.current + val block = context.block + val actions by rememberUpdatedState(context.blockActions) + val state = rememberSyncedTextFieldState( + model = { context.values.valueOf(cell.id) }, + modelChanges = remember(context.values, cell.id) { context.values.changes(cell.id) }, + onEdit = { text -> onValue(cell.id, text) }, + ) + val requester = remember { FocusRequester() } + val highlight = rememberFieldHighlight(context.blockActions.highlights, block.id, cell.id) + val focus = context.focus + RegisterCell(cell.id, CellField(state, requester), context) + val input = remember(context.commands) { + CellInput(context.defer, { actions }) { context.commands.enter(cell.id) } + } + val style = cellTextStyle(isHeader) + val background = if (isHeader) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.surface + ReadingDirection(text = { state.text }) { + Box( + modifier = Modifier + .width(CellWidth) + .heightIn(min = CellMinHeight) + .fillMaxHeight() + .border(CellBorder, MaterialTheme.colorScheme.outlineVariant) + .background(background) + .padding(theme.spacing.xs), + contentAlignment = Alignment.CenterStart, + ) { + BasicTextField( + state = state, + readOnly = isLocked, + inputTransformation = input, + textStyle = style, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = WritingKeyboard, + onTextLayout = highlight.onTextLayout, + modifier = Modifier + .fillMaxWidth() + .focusRequester(requester) + .onFocusChanged { focusState -> + if (focusState.isFocused) { + focus.current = cell.id + actions.onListItemCaret(cell.id, state.selection.start) + } + } + .then(highlight.modifier) + .cellKeys(cell.id, state, context, highlight), + ) + } + } +} + +/** The cell's field, as the table finds it to focus it and to place its cursor. */ +private class CellField(val state: TextFieldState, val requester: FocusRequester) + +/** Lets the table find the field of cell [id] while it is composed, and tells the editor where its cursor is. */ +@Composable +private fun RegisterCell(id: String, field: CellField, context: CellContext) { + val focus = context.focus + val actions by rememberUpdatedState(context.blockActions) + DisposableEffect(id) { + focus.requesters[id] = field.requester + focus.states[id] = field.state + onDispose { + focus.requesters.remove(id) + focus.states.remove(id) + } + } + LaunchedEffect(id, field.state) { + snapshotFlow { field.state.selection.start }.collect { offset -> + if (focus.current == id) actions.onListItemCaret(id, offset) + } + } +} + +/** A cell's text: the list's size, the body font, heavier in the header row. */ +@Composable +private fun cellTextStyle(isHeader: Boolean): TextStyle { + val theme = LocalEditorTheme.current + return MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + fontSize = theme.fontSizes.list, + fontFamily = theme.fontFamilies.body, + fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal, + lineHeight = theme.lineSpacing.lineHeight(theme.fontSizes.list, MaterialTheme.typography.bodyLarge.lineHeight), + ) +} + +/** + * The cell's keys: Tab and Shift+Tab move to the next and previous cell (Tab in the last cell adds a row, Shift+Tab + * in the first one goes to the block before the table, or stays), the arrow keys leave the cell only from its edges, + * to the cell beside or above or below it, or past the table to the block before or after it, and the keys over a text + * selection across blocks (a second Select all, Delete, Escape, Copy and Cut) work as in any other field. A cell is + * not a field of that selection, so Shift+arrows move the cell's own selection only. Tab with Ctrl, Alt or Meta held + * belongs to whoever asked for it. + */ +private fun Modifier.cellKeys( + cellId: String, + state: TextFieldState, + context: CellContext, + highlight: FieldHighlight, +): Modifier = onPreviewKeyEvent { event -> + val selection = state.selection + val line = if (event.isArrow()) caretLine(highlight.geometry(), state.text.length, selection.end) else null + val commands = context.commands + val actions = context.blockActions + tabKey(event, cellId, context) ?: ( + rangeKey(event, rangeField(context.block.id, cellId, selection, line), actions) || + selectAllTextKey(event, selection, state.text.length, actions) || + KeyboardUtils.handleKeyEvent( + event = event, + text = state.text, + cursorPosition = if (selection.collapsed) selection.start else -1, + onNavigateUp = { + commands.step(cellId, CellStep.UP, caret = Int.MAX_VALUE) || + actions.navigate(CaretMove.Up(line?.x)) + }, + onNavigateDown = { + commands.step(cellId, CellStep.DOWN, caret = 0) || + actions.navigate(CaretMove.Down(line?.x)) + }, + onNavigateLeft = { + commands.step(cellId, CellStep.PREVIOUS, caret = Int.MAX_VALUE) || + actions.navigate(CaretMove.Left) + }, + onNavigateRight = { + commands.step(cellId, CellStep.NEXT, caret = 0) || actions.navigate(CaretMove.Right) + }, + line = line, + ) + ) +} + +/** + * Tab in a cell: the next cell (after the last one a new row), Shift+Tab the one before (in the first cell: the block + * before the table, or the cursor stays). Always taken, so Tab never leaves the table half way through editing. Null + * for any other key, and for Tab with Ctrl, Alt or Meta held, which belongs to whoever asked for it. + */ +private fun tabKey(event: KeyEvent, cellId: String, context: CellContext): Boolean? { + val plain = event.key == Key.Tab && !event.isCtrlPressed && !event.isAltPressed && !event.isMetaPressed + return when { + !plain -> null + + event.type != KeyEventType.KeyDown -> true + + event.isShiftPressed -> true.also { + context.commands.step(cellId, CellStep.PREVIOUS, caret = Int.MAX_VALUE) || + context.blockActions.navigate(CaretMove.Left) + } + + else -> true.also { context.commands.tab(cellId) } + } +} + +/** + * The writing rules of a cell: a cell holds one line. A typed Enter goes to the cell below (a new row after the + * last one) and leaves the text as it was; line breaks in pasted text become spaces. While a selection runs across + * blocks from or into the cell, an edit replaces that selection instead. + */ +private class CellInput( + private val defer: (() -> Unit) -> Unit, + private val actions: () -> BlockActions, + private val onEnter: () -> Unit, +) : InputTransformation { + override fun TextFieldBuffer.transformInput() { + val old = originalText.toString() + val new = asCharSequence().toString() + if (old == new) return + val diff = textDiff(old, new, if (selection.collapsed) selection.start else -1) + when { + // A selection that runs from or into this cell is replaced, not typed into the cell. + actions().textRange.isCrossField -> replaceSelectedRange(diff.inserted, defer, actions()) + + new.none { it in LINE_BREAK_CHARS } -> Unit + + LineBreak.of(old, diff)?.isEnter == true -> { + revertAllChanges() + defer(onEnter) + } + + else -> { + replace(0, length, cellText(new)) + placeCursorAtEnd() + } + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextBlock.kt new file mode 100644 index 00000000..0669f88a --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextBlock.kt @@ -0,0 +1,86 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.theme.EDITOR_PLACEHOLDER_ALPHA +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.theme.lineHeight +import com.pasich.encly.editor.ui.BlockActions + +/** + * Keyboard of the note's writing fields: sentence case, suggestions and autocorrect. The app + * already asks keyboards not to learn from what is typed (SecureTextInputBoundary). + */ +public val WritingKeyboard: KeyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + autoCorrectEnabled = true, + keyboardType = KeyboardType.Text, +) + +/** A paragraph. Its placeholder shows only when [showPlaceholder] (focused, or the note's only block). */ +@Composable +internal fun TextBlock( + block: Block.TextBlock, + blockActions: BlockActions, + modifier: Modifier = Modifier, + isLocked: Boolean = false, + showPlaceholder: Boolean = true, +) { + val theme = LocalEditorTheme.current + val spacing = theme.lineSpacing + val state = rememberBlockTextFieldState(block.text, blockActions) + val style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + fontSize = theme.fontSizes.textBlock, + fontFamily = theme.fontFamilies.body, + lineHeight = spacing.lineHeight(theme.fontSizes.textBlock, MaterialTheme.typography.bodyLarge.lineHeight), + ) + val actions by rememberUpdatedState(blockActions) + val defer = rememberDefer() + val input = remember { BlockInput(BlockField.Paragraph, defer) { actions } } + val highlight = rememberFieldHighlight(blockActions.highlights, block.id) + RegisterCaretAtX(blockActions, state, highlight) + + BasicTextField( + state = state, + // Read-only, not disabled: a locked note's text can still be selected and copied. + readOnly = isLocked, + inputTransformation = input, + textStyle = style, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = WritingKeyboard, + onTextLayout = highlight.onTextLayout, + modifier = modifier + .fillMaxWidth() + .then(highlight.modifier) + .textBlockKeys(state, blockActions, highlight), + decorator = { innerTextField -> + Box(modifier = Modifier.fillMaxWidth()) { + SlashMenuPopup(field = state, slash = blockActions.slash) + if (showPlaceholder && state.text.isEmpty()) { + Text( + text = LocalEditorStrings.current.text(block.placeholder), + style = style.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), + ), + ) + } + innerTextField() + } + }, + ) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextBlockKeys.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextBlockKeys.kt new file mode 100644 index 00000000..89660894 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextBlockKeys.kt @@ -0,0 +1,71 @@ +package com.pasich.encly.editor.ui.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.editor.focus.KeyboardUtils +import com.pasich.encly.editor.focus.caretLine +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.CaretMove +import com.pasich.encly.editor.ui.BlockActions +import com.pasich.encly.editor.ui.SlashActions + +/** + * Keyboard handling of a text-bearing block editing [state]: Backspace at the start joins the + * block to the one above (a heading or quote first becomes a paragraph), Backspace in an empty + * paragraph removes it. Up from the first visual line and Down from the last move to the block + * above or below at the same column, Left at the start and Right at the end to its end or start; + * blocks without a field are skipped, and in the last block Down does nothing (no block is + * added). Enter is left to the field: its line break reaches [BlockInput], from a hardware key + * and a soft keyboard alike. [highlight] holds the field's measured lines. + */ +internal fun Modifier.textBlockKeys( + state: TextFieldState, + blockActions: BlockActions, + highlight: FieldHighlight, +): Modifier = onPreviewKeyEvent { event -> + val selection = state.selection + // Only the arrow keys need to know where the cursor is among the lines. + val line = if (event.isArrow()) caretLine(highlight.geometry(), state.text.length, selection.end) else null + slashKey(event, blockActions.slash) || + rangeKey(event, rangeField(blockActions.blockId, null, selection, line), blockActions) || + selectAllTextKey(event, selection, state.text.length, blockActions) || + KeyboardUtils.handleKeyEvent( + event = event, + text = state.text, + // A selection is not a cursor at either end: the field moves within it. + cursorPosition = if (selection.collapsed) selection.start else -1, + onBackspaceEmpty = { blockActions.onRemoveBlock(BlockRemoveAction.REMOVE_BACKSPACE) }, + onBackspaceAtStart = { blockActions.onBackspaceAtStart() }, + onNavigateUp = { blockActions.navigate(CaretMove.Up(line?.x)) }, + onNavigateDown = { blockActions.navigate(CaretMove.Down(line?.x)) }, + onNavigateLeft = { blockActions.navigate(CaretMove.Left) }, + onNavigateRight = { blockActions.navigate(CaretMove.Right) }, + line = line, + ) +} + +/** Whether [this] is one of the four arrow keys. */ +internal fun KeyEvent.isArrow(): Boolean = + key == Key.DirectionUp || key == Key.DirectionDown || key == Key.DirectionLeft || key == Key.DirectionRight + +/** + * 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/blocks/TextSync.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt similarity index 92% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextSync.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt index 2345ac76..da9607f3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextSync.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd @@ -10,8 +10,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange -import com.pasich.encly.presentation.editor.BlockActions -import com.pasich.encly.presentation.editor.focus.caretOffsetFor +import com.pasich.encly.editor.focus.caretOffsetFor +import com.pasich.encly.editor.state.snappedToCodePoint +import com.pasich.encly.editor.ui.BlockActions import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -37,7 +38,7 @@ internal class TextSync(var known: String) { * Call it inside the field's `key(...)`, so the state belongs to one field for its whole life. */ @Composable -internal fun rememberSyncedTextFieldState( +public fun rememberSyncedTextFieldState( model: () -> String?, modelChanges: Flow<*>, onEdit: (String) -> Unit, @@ -105,6 +106,7 @@ internal fun rememberBlockTextFieldState(text: StateFlow, blockActions: @Composable internal fun RegisterCaretAtX(blockActions: BlockActions, state: TextFieldState, highlight: FieldHighlight) { val actions by rememberUpdatedState(blockActions) + BindFieldRange(state, blockActions, highlight) DisposableEffect(state, highlight) { val unregister = actions.registerCaretAtX { x, fromBelow -> val offset = caretOffsetFor(highlight.geometry(), x, fromBelow, state.text.length) @@ -122,5 +124,5 @@ internal fun RegisterCaretAtX(blockActions: BlockActions, state: TextFieldState, */ internal fun TextFieldState.placeCaret(stored: String, offset: Int) { if (text.toString() != stored) setTextAndPlaceCursorAtEnd(stored) - edit { selection = TextRange(offset.coerceIn(0, length)) } + edit { selection = TextRange(asCharSequence().snappedToCodePoint(offset.coerceIn(0, length))) } } diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/CommandMenuItem.kt similarity index 86% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/CommandMenuItem.kt index ece4c5d4..517349eb 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/CommandMenuItem.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.designsystem +package com.pasich.encly.editor.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -26,10 +26,10 @@ 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 +import com.pasich.encly.editor.theme.LocalEditorTheme -/** One row of an [EnclyCommandMenu]. */ -data class CommandMenuItem(val icon: ImageVector, val label: String) +/** One row of an [EditorCommandMenu]. */ +internal data class CommandMenuItem(val icon: ImageVector, val label: String) private val MenuMaxHeight = 240.dp private val MenuMinWidth = 208.dp @@ -42,13 +42,14 @@ private val MenuElevation = 6.dp * are more than fit. [title] describes the list to TalkBack. */ @Composable -fun EnclyCommandMenu( +internal fun EditorCommandMenu( items: List, highlighted: Int, title: String, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, ) { + val spacing = LocalEditorTheme.current.spacing val colors = MaterialTheme.colorScheme val listState = rememberLazyListState() LaunchedEffect(highlighted) { @@ -67,20 +68,21 @@ fun EnclyCommandMenu( val active = index == highlighted Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), + horizontalArrangement = Arrangement.spacedBy(spacing.s), modifier = Modifier .fillMaxWidth() .background(if (active) colors.primaryContainer else colors.surfaceContainerHigh) + .editorInteractive() .clickable(role = Role.Button) { onSelect(index) } - .heightIn(min = EnclyTheme.spacing.minTouchTarget) - .padding(horizontal = EnclyTheme.spacing.m) + .heightIn(min = spacing.minTouchTarget) + .padding(horizontal = spacing.m) .semantics { selected = active }, ) { Icon( item.icon, contentDescription = null, tint = if (active) colors.onPrimaryContainer else colors.onSurfaceVariant, - modifier = Modifier.size(EnclyTheme.spacing.iconMedium), + modifier = Modifier.size(spacing.iconMedium), ) Text( text = item.label, diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/ContextEntry.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/ContextEntry.kt new file mode 100644 index 00000000..c8438f0c --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/ContextEntry.kt @@ -0,0 +1,11 @@ +package com.pasich.encly.editor.ui.components + +import androidx.compose.ui.graphics.vector.ImageVector + +/** One row of an [EditorContextMenu]. */ +internal class ContextEntry( + val label: String, + val icon: ImageVector, + val destructive: Boolean = false, + val onClick: () -> Unit, +) diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorContextMenu.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorContextMenu.kt new file mode 100644 index 00000000..3a23cbd1 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorContextMenu.kt @@ -0,0 +1,143 @@ +package com.pasich.encly.editor.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection + +/** + * A menu of [entries] that opens at the pointer on a right click on [content], or at its corner on the + * Menu key / Shift+F10 while it holds keyboard focus. It is a Material menu: themed, closed by Esc or a + * click outside, its rows take the arrow keys, Enter and hover. A finger never opens it. With + * [claimPress] the menu takes the right click before its children (a button inside would otherwise act on + * it); without, a right click a child already used (a text field's own cut / copy / paste menu) is left to + * that child. With [enabled] false the content is shown as is. + */ +@Composable +internal fun EditorContextMenu( + entries: () -> List, + modifier: Modifier = Modifier, + enabled: Boolean = true, + claimPress: Boolean = false, + content: @Composable () -> Unit, +) { + var anchor by remember { mutableStateOf(null) } + var size by remember { mutableStateOf(IntSize.Zero) } + // One lambda for the life of the menu, so the pointer loop is never restarted. + val open = remember { { position: Offset -> anchor = position } } + val density = LocalDensity.current + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box(modifier = modifier.onSizeChanged { size = it }.contextMenuTriggers(enabled, claimPress, open)) { + content() + anchor?.let { position -> + MenuPopup( + entries = remember(position) { entries() }, + // A menu is placed from the bottom start of its anchor. + offset = with(density) { + DpOffset(menuOffsetFromStart(position.x, size.width, rtl).toDp(), (position.y - size.height).toDp()) + }, + onDismiss = { anchor = null }, + ) + } + } +} + +/** + * How far from the start edge of a box [width] pixels wide a menu opened at [x] (counted from its left edge) + * is offset: a dropdown counts the offset from the right edge in a right-to-left layout ([rtl]). + */ +internal fun menuOffsetFromStart(x: Float, width: Int, rtl: Boolean): Float = if (rtl) width - x else x + +@Composable +private fun MenuPopup(entries: List, offset: DpOffset, onDismiss: () -> Unit) { + val colors = MaterialTheme.colorScheme + DropdownMenu( + expanded = true, + onDismissRequest = onDismiss, + offset = offset, + // Esc closes the menu from any row, also where the platform's popup does not do it itself. + modifier = Modifier.onKeyEvent { event -> + (event.key == Key.Escape && event.type == KeyEventType.KeyDown).also { if (it) onDismiss() } + }, + ) { + entries.forEach { entry -> + DropdownMenuItem( + text = { Text(entry.label, style = MaterialTheme.typography.labelLarge) }, + onClick = { + onDismiss() + entry.onClick() + }, + leadingIcon = { Icon(entry.icon, contentDescription = null) }, + colors = if (entry.destructive) { + MenuDefaults.itemColors(textColor = colors.error, leadingIconColor = colors.error) + } else { + MenuDefaults.itemColors() + }, + modifier = Modifier.editorInteractive(), + ) + } + } +} + +/** Where the menu opens for the keyboard: just inside the top start corner of the content, in pixels. */ +private val KeyboardMenuAnchor = Offset(x = 24f, y = 24f) + +private fun Modifier.contextMenuTriggers(enabled: Boolean, claimPress: Boolean, open: (Offset) -> Unit): Modifier = + if (enabled) { + this + .secondaryPress(if (claimPress) PointerEventPass.Initial else PointerEventPass.Final, open) + .onKeyEvent { event -> + val menuKey = event.key == Key.Menu || (event.key == Key.F10 && event.isShiftPressed) + (menuKey && event.type == KeyEventType.KeyDown).also { if (it) open(KeyboardMenuAnchor) } + } + } else { + this + } + +/** + * Calls [open] with the position of a secondary-button press, seen in [pass] (the initial pass is before + * the children, the final one after them); a press that is used here is consumed, so it starts nothing else. + */ +private fun Modifier.secondaryPress(pass: PointerEventPass, open: (Offset) -> Unit): Modifier = + pointerInput(pass, open) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(pass) + val press = event.changes.firstOrNull() + if (press != null && event.isSecondaryPress() && press.changedToDown()) { + press.consume() + open(press.position) + } + } + } + } + +private fun PointerEvent.isSecondaryPress() = type == PointerEventType.Press && buttons.isSecondaryPressed diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorControls.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorControls.kt new file mode 100644 index 00000000..bcd711ec --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorControls.kt @@ -0,0 +1,128 @@ +package com.pasich.encly.editor.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.theme.LocalEditorTheme + +// Small controls the editor draws itself, so it needs nothing from its host's design system. + +private val CheckboxRadius = 6.dp +private const val CHECKBOX_ANIMATION_MS = 150 +private const val CHECK_ICON_FRACTION = 0.7f +private const val MAX_RADIUS_FRACTION = 0.3f +private val CheckIconMax = 16.dp +private val ToolSize = 44.dp +private val ToolShape = RoundedCornerShape(14.dp) +private const val DISABLED_ALPHA = 0.38f + +/** + * A checklist's box: unchecked is a 2 dp `outline` square, checked fills `primary` with an + * `onPrimary` tick. It only draws; the list item toggles it. [boxSize] is the square's side. + */ +@Composable +internal fun EditorCheckbox(checked: Boolean, boxSize: Dp, modifier: Modifier = Modifier) { + val theme = LocalEditorTheme.current + val colors = MaterialTheme.colorScheme + val shape = RoundedCornerShape(minOf(CheckboxRadius, boxSize * MAX_RADIUS_FRACTION)) + val fill by animateColorAsState( + targetValue = if (checked) colors.primary else Color.Transparent, + animationSpec = tween(CHECKBOX_ANIMATION_MS), + label = "checkbox", + ) + Box(contentAlignment = Alignment.Center, modifier = modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(boxSize) + .background(fill, shape) + .then(if (checked) Modifier else Modifier.border(theme.spacing.stroke, colors.outline, shape)), + ) { + if (checked) { + Icon( + theme.icons.check, + contentDescription = null, + tint = colors.onPrimary, + modifier = Modifier.size(minOf(CheckIconMax, boxSize * CHECK_ICON_FRACTION)), + ) + } + } + } +} + +/** + * Shows [label] in a small tooltip when [content] (an icon-only button) is long-pressed or hovered, + * so a user can learn what the icon does. [modifier] lays out the anchor. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EditorTooltip(label: String, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text(label) } }, + state = rememberTooltipState(), + modifier = modifier, + content = content, + ) +} + +/** + * A 44 dp icon button filled with `primary` (the "Add link" button), radius 14, with a long-press + * tooltip of [contentDescription]. A null [onClick] shows it disabled. + */ +@Composable +internal fun EditorFilledToolButton( + icon: ImageVector, + contentDescription: String, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + val enabled = onClick != null + EditorTooltip(contentDescription, modifier = modifier) { + Surface( + onClick = { onClick?.invoke() }, + enabled = enabled, + shape = ToolShape, + color = colors.primary, + contentColor = if (enabled) colors.onPrimary else colors.onPrimary.copy(alpha = DISABLED_ALPHA), + modifier = Modifier + .editorInteractive(ToolShape, enabled) + .size(ToolSize) + .semantics { role = Role.Button }, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(LocalEditorTheme.current.spacing.iconMedium), + ) + } + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorSheet.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorSheet.kt new file mode 100644 index 00000000..610d30af --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorSheet.kt @@ -0,0 +1,187 @@ +package com.pasich.encly.editor.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.theme.LocalEditorTheme +import kotlinx.coroutines.delay + +/** How long a destructive row waits for the confirming second tap. */ +private const val CONFIRM_WINDOW_MS = 5_000L +private const val TITLE_MAX_LINES = 3 +private const val DISABLED_ALPHA = 0.38f +private val SheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) +private val HandleWidth = 32.dp +private val HandleHeight = 4.dp +private val HandleRadius = 2.dp + +/** + * The editor's bottom sheet: top corners 16, `surfaceContainerLow`, a 32×4 handle in + * `outlineVariant` and a gutter. [title] is shown in titleLarge above [content]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EditorSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + modifier: Modifier = Modifier, + title: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val spacing = LocalEditorTheme.current.spacing + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + shape = SheetShape, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + dragHandle = { SheetHandle() }, + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(spacing.xs), + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = spacing.gutter, end = spacing.gutter, bottom = spacing.l), + ) { + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = spacing.xs), + ) + } + content() + } + } +} + +@Composable +private fun SheetHandle() { + Box( + modifier = Modifier + .padding(vertical = LocalEditorTheme.current.spacing.s) + .size(width = HandleWidth, height = HandleHeight) + .background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(HandleRadius)), + ) +} + +/** A hairline between groups of sheet rows. */ +@Composable +internal fun EditorGroupDivider(modifier: Modifier = Modifier) { + val spacing = LocalEditorTheme.current.spacing + HorizontalDivider( + thickness = spacing.hairline, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = modifier.padding(horizontal = spacing.m), + ) +} + +/** + * A sheet action row: leading icon in `primary` (`error` when [destructive]) and the title in + * labelLarge. With [confirmFirst] the first tap only arms the action ("Tap again to delete"); a + * second tap within five seconds runs it. + */ +@Composable +internal fun EditorSheetRow( + title: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + destructive: Boolean = false, + enabled: Boolean = true, + confirmFirst: Boolean = false, +) { + val spacing = LocalEditorTheme.current.spacing + var armed by remember { mutableStateOf(false) } + LaunchedEffect(armed) { + if (armed) { + delay(CONFIRM_WINDOW_MS) + armed = false + } + } + val (accent, titleColor) = sheetRowColors(enabled = enabled, destructive = destructive) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.rowGap), + modifier = modifier + .fillMaxWidth() + .heightIn(min = spacing.rowHeight) + .editorInteractive(MaterialTheme.shapes.medium, enabled) + .clickable(enabled = enabled, role = Role.Button) { + if (confirmFirst && !armed) { + armed = true + } else { + armed = false + onClick() + } + } + .padding(horizontal = spacing.xxs, vertical = spacing.xs), + ) { + if (armed) { + CircularProgressIndicator( + color = accent, + strokeWidth = spacing.stroke, + modifier = Modifier.size(spacing.icon), + ) + } else { + Icon(icon, contentDescription = null, tint = accent, modifier = Modifier.size(spacing.icon)) + } + Text( + text = if (armed) LocalEditorStrings.current.text(EditorString.TAP_AGAIN_TO_DELETE) else title, + style = MaterialTheme.typography.labelLarge, + color = titleColor, + maxLines = TITLE_MAX_LINES, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +/** Icon and title colours of a sheet row: `primary` / `onSurface`, `error` when destructive. */ +@Composable +private fun sheetRowColors(enabled: Boolean, destructive: Boolean): Pair { + val colors = MaterialTheme.colorScheme + val disabled = colors.onSurface.copy(alpha = DISABLED_ALPHA) + return when { + !enabled -> disabled to disabled + destructive -> colors.error to colors.error + else -> colors.primary to colors.onSurface + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorTextField.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorTextField.kt new file mode 100644 index 00000000..32842fe2 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/EditorTextField.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.editor.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.theme.LocalEditorTheme + +private val LabelGap = 6.dp +private val RestBorder = 1.dp +private val ActiveBorder = 2.dp + +/** + * A labelled single-line field with an outline: 1 dp `outline` at rest, 2 dp `onSurface` while + * focused, 2 dp `error` with [errorMessage] below it when there is one. [modifier] lays out the whole + * block, [fieldModifier] reaches the text field itself (a FocusRequester). + */ +@Composable +internal fun EditorTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, + errorMessage: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + val spacing = LocalEditorTheme.current.spacing + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + val colors = MaterialTheme.colorScheme + val (borderWidth, borderColor) = when { + errorMessage != null -> ActiveBorder to colors.error + focused -> ActiveBorder to colors.onSurface + else -> RestBorder to colors.outline + } + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(LabelGap)) { + Text(text = label, style = MaterialTheme.typography.labelMedium, color = colors.onSurface) + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = colors.onSurface), + cursorBrush = SolidColor(colors.primary), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + modifier = fieldModifier.fillMaxWidth(), + decorationBox = { inner -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .heightIn(min = spacing.buttonHeight) + .border(borderWidth, borderColor, MaterialTheme.shapes.small) + .padding(horizontal = spacing.m, vertical = spacing.xs), + ) { + Box(modifier = Modifier.weight(1f)) { inner() } + } + }, + ) + if (errorMessage != null) { + Text(text = errorMessage, style = MaterialTheme.typography.bodySmall, color = colors.error) + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/Pointer.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/Pointer.kt new file mode 100644 index 00000000..d3e8a17f --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/components/Pointer.kt @@ -0,0 +1,100 @@ +package com.pasich.encly.editor.ui.components + +import androidx.compose.material3.LocalContentColor +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusEventModifierNode +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +private val FocusRingWidth = 2.dp +private val FocusRingInset = 1.dp + +/** + * What a desktop pointer and keyboard expect of a pressable thing, to go before its `clickable` (or on + * a Material surface): a hand cursor over it and a 2 dp focus ring just inside [shape] while it holds + * keyboard focus, in the content colour so it contrasts with the fill. The ring shows only after a key + * was pressed last (Tab, arrows), never after a tap or a click, and neither effect exists on touch. + * Hover and pressed state layers are the Material ripple's. A disabled target keeps the arrow and + * gets no ring. The same as the host app's design system has, which the editor cannot depend on. + */ +internal fun Modifier.editorInteractive(shape: Shape = RectangleShape, enabled: Boolean = true): Modifier = + if (enabled) { + this + .pointerHoverIcon(PointerIcon.Hand) + .then(FocusRingElement(shape)) + } else { + this + } + +private data class FocusRingElement(val shape: Shape) : ModifierNodeElement() { + override fun create() = FocusRingNode(shape) + + override fun update(node: FocusRingNode) { + node.shape = shape + node.invalidateDraw() + } +} + +private class FocusRingNode(var shape: Shape) : + Modifier.Node(), + FocusEventModifierNode, + DrawModifierNode, + PointerInputModifierNode, + CompositionLocalConsumerModifierNode { + private var focused = false + + // Focus that a press gave (a click, a tap) is not keyboard focus: no ring until focus leaves and returns. + private var pressed = false + + override fun onFocusEvent(focusState: FocusState) { + if (focused != focusState.hasFocus) { + focused = focusState.hasFocus + if (!focused) pressed = false + invalidateDraw() + } + } + + override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) { + if (pass == PointerEventPass.Initial && pointerEvent.type == PointerEventType.Press && !pressed) { + pressed = true + invalidateDraw() + } + } + + override fun onCancelPointerInput() = Unit + + override fun sharePointerInputWithSiblings() = true + + override fun ContentDrawScope.draw() { + drawContent() + // Read while drawing, so a change of input mode redraws. + if (!focused || pressed || currentValueOf(LocalInputModeManager).inputMode != InputMode.Keyboard) return + val inset = FocusRingInset.toPx() + FocusRingWidth.toPx() / 2 + val ringSize = Size((size.width - 2 * inset).coerceAtLeast(0f), (size.height - 2 * inset).coerceAtLeast(0f)) + val outline = shape.createOutline(ringSize, layoutDirection, this) + translate(inset, inset) { + drawOutline(outline, currentValueOf(LocalContentColor), style = Stroke(FocusRingWidth.toPx())) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionBlockDialog.kt similarity index 77% rename from app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionBlockDialog.kt index e11d933f..6bff4569 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionBlockDialog.kt @@ -1,6 +1,6 @@ -package com.pasich.encly.presentation.dialogs.blocks +package com.pasich.encly.editor.ui.sheets -sealed class ActionBlockDialog { +internal sealed class ActionBlockDialog { data class Move(val move: Int) : ActionBlockDialog() // 1 - top, 2 - bottom object Duplicate : ActionBlockDialog() object Delete : ActionBlockDialog() diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionLinkBottomSheet.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionLinkBottomSheet.kt new file mode 100644 index 00000000..511440ea --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionLinkBottomSheet.kt @@ -0,0 +1,76 @@ +package com.pasich.encly.editor.ui.sheets + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.blocks.LinkBlock +import com.pasich.encly.editor.ui.blocks.isOpenableLink +import com.pasich.encly.editor.ui.components.EditorGroupDivider +import com.pasich.encly.editor.ui.components.EditorSheet +import com.pasich.encly.editor.ui.components.EditorSheetRow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ActionLinkBottomSheet( + settings: SettingsBlockDialog, + sheetState: SheetState, + onAction: (ActionBlockDialog) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (settings.isBottomSheetVisible) { + val strings = LocalEditorStrings.current + val icons = LocalEditorTheme.current.icons + EditorSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { + val block = settings.block + var openable = false + if (block is Block.LinkBlock) { + val urlModel by block.block.collectAsState() + openable = isOpenableLink(urlModel.url) + if (!urlModel.isError) { + LinkBlock(block, blockActions = null, onClick = {}) + } + // The whole address, so the user sees exactly what "Open link" hands to another app. + SelectionContainer { + Text( + text = urlModel.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + // An address Encly will not open (see NoteLink.Blocked) can still be copied. + if (openable) { + EditorSheetRow( + title = strings.text(EditorString.LINK_OPEN), + icon = icons.external, + onClick = { onAction(ActionBlockDialog.OpenLink) }, + ) + } + EditorSheetRow( + title = strings.text(EditorString.LINK_COPY), + icon = icons.copy, + onClick = { onAction(ActionBlockDialog.CopyLink) }, + ) + if (settings.canEdit) { + EditorSheetRow( + title = strings.text(EditorString.LINK_EDIT), + icon = icons.edit, + onClick = { onAction(ActionBlockDialog.EditLink) }, + ) + EditorGroupDivider() + BlockEditRows(settings, onAction) + } + } + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt new file mode 100644 index 00000000..50417238 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt @@ -0,0 +1,41 @@ +package com.pasich.encly.editor.ui.sheets + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.runtime.Composable +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.ui.components.EditorSheet + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ActionOtherBottomSheet( + settings: SettingsBlockDialog, + sheetState: SheetState, + onAction: (ActionBlockDialog) -> Unit, + onDismiss: () -> Unit, +) { + if (settings.isBottomSheetVisible) { + EditorSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + title = LocalEditorStrings.current.text(settings.block.describe()), + ) { + // Every action here changes the note: a read-only editor offers none. + if (settings.canEdit) BlockEditRows(settings, onAction) + } + } +} + +/** Human-readable block type, as a string key. */ +internal fun Block.describe(): EditorString = when (this) { + is Block.TextBlock -> EditorString.TEXT + is Block.QuoteBlock -> EditorString.QUOTE + is Block.ListBlock -> EditorString.BLOCK_LIST + is Block.HBlock -> EditorString.BLOCK_HEADING + is Block.LinkBlock -> EditorString.BLOCK_LINK + is Block.SeparatorBlock -> EditorString.BLOCK_SEPARATOR + is Block.CodeBlock -> EditorString.BLOCK_CODE + is Block.TableBlock -> EditorString.BLOCK_TABLE +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/BlockEditRows.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/BlockEditRows.kt new file mode 100644 index 00000000..8fb3e366 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/BlockEditRows.kt @@ -0,0 +1,38 @@ +package com.pasich.encly.editor.ui.sheets + +import androidx.compose.runtime.Composable +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.ui.components.EditorSheetRow + +/** 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) { + val strings = LocalEditorStrings.current + val icons = LocalEditorTheme.current.icons + EditorSheetRow( + title = strings.text(EditorString.BLOCK_MOVE_UP), + icon = icons.arrowUp, + enabled = settings.blockMove != 1, + onClick = { onAction(ActionBlockDialog.Move(1)) }, + ) + EditorSheetRow( + title = strings.text(EditorString.BLOCK_MOVE_DOWN), + icon = icons.arrowDown, + enabled = settings.blockMove != 2, + onClick = { onAction(ActionBlockDialog.Move(2)) }, + ) + EditorSheetRow( + title = strings.text(EditorString.BLOCK_DUPLICATE), + icon = icons.duplicate, + onClick = { onAction(ActionBlockDialog.Duplicate) }, + ) + EditorSheetRow( + title = strings.text(EditorString.BLOCK_DELETE), + icon = icons.trash, + destructive = true, + confirmFirst = true, + onClick = { onAction(ActionBlockDialog.Delete) }, + ) +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/BlockSheetKind.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/BlockSheetKind.kt new file mode 100644 index 00000000..ec7280ff --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/BlockSheetKind.kt @@ -0,0 +1,81 @@ +package com.pasich.encly.editor.ui.sheets + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorClipboard +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.LocalLinkOpener +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.ui.EditorController +import kotlinx.coroutines.launch + +/** The sheets a block can open: a saved link's, or the one of every other block (a separator's). */ +public enum class BlockSheetKind { LINK, OTHER } + +/** + * The sheet of one block, found by the block's id on every composition, so it can never act on a + * position that undo, redo or a delete already changed. Nothing shows while [kind] or [blockId] is + * null or names no block. [canEdit] is false in a read-only editor: only what does not change the + * note is offered. Copying a link goes to the provided `EditorClipboard` as sensitive text, opening + * it to the provided `LinkOpener`. [onClose] is called once the sheet has slid away. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun EditorBlockSheet( + kind: BlockSheetKind?, + blockId: String?, + canEdit: Boolean, + controller: EditorController, + onClose: () -> Unit, +) { + val strings = LocalEditorStrings.current + val clipboard = LocalEditorClipboard.current + val linkOpener = LocalLinkOpener.current + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val block = blockId?.let { id -> controller.blocks.firstOrNull { it.id == id } } + if (block == null || kind == null) return + + fun dismiss() { + scope.launch { sheetState.hide() }.invokeOnCompletion { onClose() } + } + + fun onAction(action: ActionBlockDialog) { + val link = (block as? Block.LinkBlock)?.block?.value?.url.orEmpty() + when (action) { + ActionBlockDialog.Duplicate -> controller.duplicateBlock(block) + ActionBlockDialog.Delete -> controller.removeBlock(block, BlockRemoveAction.REMOVE) + is ActionBlockDialog.Move -> controller.moveBlock(action.move == 1) + ActionBlockDialog.OpenLink -> linkOpener.open(link) + ActionBlockDialog.CopyLink -> clipboard.copy(strings.text(EditorString.BLOCK_LINK), link, sensitive = true) + ActionBlockDialog.EditLink -> (block as? Block.LinkBlock)?.let(controller::editLink) + } + if (action !is ActionBlockDialog.Move) dismiss() + } + + val settings = SettingsBlockDialog( + block = block, + isBottomSheetVisible = true, + blockMove = controller.interactedBlockPosition(), + canEdit = canEdit, + ) + if (kind == BlockSheetKind.LINK) { + ActionLinkBottomSheet( + settings = settings, + sheetState = sheetState, + onDismiss = ::dismiss, + onAction = ::onAction, + ) + } else { + ActionOtherBottomSheet( + settings = settings, + sheetState = sheetState, + onDismiss = ::dismiss, + onAction = ::onAction, + ) + } +} diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/SettingsBlockDialog.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/SettingsBlockDialog.kt new file mode 100644 index 00000000..10a4d7ba --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/SettingsBlockDialog.kt @@ -0,0 +1,11 @@ +package com.pasich.encly.editor.ui.sheets + +import com.pasich.encly.editor.model.Block + +internal data class SettingsBlockDialog( + val block: Block, + val blockMove: Int = -1, // 1 - up, 2 - down + val isBottomSheetVisible: Boolean = false, + // False in a read-only editor: only actions that do not change the note are offered. + val canEdit: Boolean = true, +) diff --git a/app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockConverterTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockConverterTest.kt index 6ba6f97e..17b7d067 100644 --- a/app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockConverterTest.kt @@ -1,16 +1,16 @@ -package com.pasich.encly.core.serialization +package com.pasich.encly.editor.codec -import com.google.gson.JsonParser -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 com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock 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 +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class BlockConverterTest { @@ -78,7 +78,7 @@ class BlockConverterTest { "[1, 2]", "{\"blockType\":\"TEXT\"}", ).forEach { json -> - assertNull(json, BlockConverter.jsonToBlocksOrNull(json)) + assertNull(BlockConverter.jsonToBlocksOrNull(json), json) } } @@ -120,7 +120,7 @@ class BlockConverterTest { ), ) - assertFalse(json, json.contains("\"id\"")) + assertFalse(json.contains("\"id\""), json) } /** @@ -154,7 +154,7 @@ class BlockConverterTest { {"blockType":"LIST_NUMBER","items":[{"value":"a","isCheck":true}]} ] """ - assertEquals(JsonParser.parseString(expected), JsonParser.parseString(json)) + assertEquals(Json.parseToJsonElement(expected), Json.parseToJsonElement(json)) } private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt new file mode 100644 index 00000000..3a8003d7 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt @@ -0,0 +1,508 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The bytes of `Note.value`, pinned as written by the shipped versions: two-space pretty + * printing with `": "`, HTML-safe escapes, no `null`, no ids. Every string here is what an older + * app wrote or reads; none may change when the serializer library does. + */ +class BlockJsonFormatTest { + + private fun reserialize(json: String) = BlockConverter.blocksToJson(BlockConverter.jsonToBlocks(json)) + + private fun assertStable(json: String) = assertEquals(json, reserialize(json)) + + @Test + fun anEmptyNoteIsAnEmptyArray() { + assertStable("[]") + assertEquals("[]", BlockConverter.blocksToJson(emptyList())) + assertEquals(emptyList(), BlockConverter.jsonToBlocks("")) + } + + @Test + fun textBlock() = assertStable( + """ + [ + { + "blockType": "TEXT", + "text": "plain" + } + ] + """.trimIndent(), + ) + + @Test + fun everyHeadingLevel() = assertStable( + """ + [ + { + "blockType": "H1", + "text": "one" + }, + { + "blockType": "H2", + "text": "two" + }, + { + "blockType": "H3", + "text": "three" + }, + { + "blockType": "H4", + "text": "four" + } + ] + """.trimIndent(), + ) + + @Test + fun quoteBlock() = assertStable( + """ + [ + { + "blockType": "QUOTE", + "text": "said" + } + ] + """.trimIndent(), + ) + + @Test + fun linkBlockWithAndWithoutImage() = assertStable( + """ + [ + { + "blockType": "LINK", + "block": { + "url": "https://example.com/a?b\u003d1\u0026c\u003d2", + "title": "Example", + "imageUrl": "https://example.com/i.png" + } + }, + { + "blockType": "LINK", + "block": { + "url": "https://example.com", + "title": "", + "imageUrl": "" + } + } + ] + """.trimIndent(), + ) + + @Test + fun separatorBlock() = assertStable( + """ + [ + { + "blockType": "SEPARATOR" + } + ] + """.trimIndent(), + ) + + @Test + fun everyListKindAndTheLockedFlag() = assertStable( + """ + [ + { + "blockType": "LIST_CHECK", + "items": [ + { + "value": "open", + "isCheck": false + }, + { + "value": "done", + "isCheck": true + } + ] + }, + { + "blockType": "LIST_CHECK", + "items": [ + { + "value": "kept out of reach", + "isCheck": false + } + ], + "isLocked": true + }, + { + "blockType": "LIST_BULLET", + "items": [ + { + "value": "milk", + "isCheck": false + } + ] + }, + { + "blockType": "LIST_NUMBER", + "items": [ + { + "value": "first", + "isCheck": false + }, + { + "value": "second", + "isCheck": false + } + ] + } + ] + """.trimIndent(), + ) + + @Test + fun aListWithoutNonBlankItemsIsNotStored() { + val json = BlockConverter.blocksToJson( + listOf( + Block.TextBlock(MutableStateFlow("a")), + Block.ListBlock(MutableStateFlow(emptyList()), BlockType.LIST_BULLET), + ), + ) + + assertEquals( + """ + [ + { + "blockType": "TEXT", + "text": "a" + } + ] + """.trimIndent(), + json, + ) + assertEquals("[]", reserialize("""[{"blockType":"LIST_BULLET","items":[]}]""")) + } + + @Test + fun aBlankLineBetweenParagraphsIsAnEmptyString() = assertStable( + """ + [ + { + "blockType": "TEXT", + "text": "one" + }, + { + "blockType": "TEXT", + "text": "" + }, + { + "blockType": "TEXT", + "text": "two" + } + ] + """.trimIndent(), + ) + + /** + * Non-ASCII is written as is (an emoji stays a surrogate pair, not `😀`); control + * characters, quotes, backslashes, U+2028/2029 and the HTML-sensitive `< > & = '` are escaped. + */ + @Test + fun unicodeEmojiAndEscapesAreWrittenByteForByte() = assertStable( + """ + [ + { + "blockType": "TEXT", + "text": "Привіт, світе! 你好 ✓ 😀 café" + }, + { + "blockType": "TEXT", + "text": "line\nbreak\ttab\rreturn \"quoted\" back\\slash \u0000 \u001f \b \f \u000b / end" + }, + { + "blockType": "TEXT", + "text": "\u003cb\u003e \u0026 \u003d \u0027 \u2028 \u2029" + } + ] + """.trimIndent(), + ) + + /** DEL is the one ASCII control character Gson left unescaped. */ + @Test + fun deleteIsWrittenRaw() { + val json = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("a\u007fb")))) + + assertEquals(true, json.contains("a" + 127.toChar() + "b")) + } + + @Test + fun everyBlockKindWrittenFromTheEditorSide() { + val json = BlockConverter.blocksToJson( + listOf( + Block.TextBlock(MutableStateFlow("t")), + Block.HBlock(MutableStateFlow("h"), BlockType.H4), + Block.QuoteBlock(MutableStateFlow("q")), + Block.LinkBlock(MutableStateFlow(LinkDataBlock(url = "u", title = "ti", imageUrl = "i"))), + Block.SeparatorBlock(), + Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("a", isCheck = true), ItemListBlock(""))), + BlockType.LIST_CHECK, + locked = MutableStateFlow(true), + ), + ), + ) + + assertEquals( + """ + [ + { + "blockType": "TEXT", + "text": "t" + }, + { + "blockType": "H4", + "text": "h" + }, + { + "blockType": "QUOTE", + "text": "q" + }, + { + "blockType": "LINK", + "block": { + "url": "u", + "title": "ti", + "imageUrl": "i" + } + }, + { + "blockType": "SEPARATOR" + }, + { + "blockType": "LIST_CHECK", + "items": [ + { + "value": "a", + "isCheck": true + } + ], + "isLocked": true + } + ] + """.trimIndent(), + json, + ) + } + + // Shapes older or hand-made notes have: read as before, written back in the current form. + + @Test + fun theCompactFormAndAnyKeyOrderAreRead() { + assertEquals( + """ + [ + { + "blockType": "TEXT", + "text": "a" + }, + { + "blockType": "LIST_CHECK", + "items": [ + { + "value": "x", + "isCheck": true + } + ] + } + ] + """.trimIndent(), + reserialize( + """[{"text":"a","blockType":"TEXT"},""" + + """{"items":[{"isCheck":true,"value":"x"}],"blockType":"LIST_CHECK"}]""", + ), + ) + } + + @Test + fun missingFieldsFallBackToTheirDefaults() { + assertEquals( + """ + [ + { + "blockType": "TEXT", + "text": "x" + }, + { + "blockType": "TEXT", + "text": "" + }, + { + "blockType": "LIST_CHECK", + "items": [ + { + "value": "only value", + "isCheck": false + } + ] + } + ] + """.trimIndent(), + reserialize( + """[{"blockType":"TEXT","text":"x"},{"blockType":"TEXT"},""" + + """{"blockType":"LIST","items":[{"value":"only value"},{"isCheck":true}]}]""", + ), + ) + // A text block without text is a blank line; a link without its data and a list without + // items are blocks without content. + assertEquals("[]", reserialize("""[{"blockType":"LINK"},{"blockType":"LIST_BULLET"}]""")) + } + + @Test + fun theLegacyListNameIsAChecklistAndUnknownKeysAreIgnored() { + assertEquals( + """ + [ + { + "blockType": "LIST_CHECK", + "items": [ + { + "value": "old", + "isCheck": true + } + ] + } + ] + """.trimIndent(), + reserialize( + """[{"blockType":"LIST","id":"abc","extra":1,"items":[{"value":"old","isCheck":true,"id":"z"}]}]""", + ), + ) + } + + @Test + fun anExplicitlyFalseLockIsNotWrittenBack() { + assertEquals( + """ + [ + { + "blockType": "LIST_NUMBER", + "items": [ + { + "value": "a", + "isCheck": false + } + ] + } + ] + """.trimIndent(), + reserialize("""[{"blockType":"LIST_NUMBER","isLocked":false,"items":[{"value":"a"}]}]"""), + ) + } + + @Test + fun escapedAndRawFormsOfTheSameTextReadAlike() { + val raw = BlockConverter.jsonToBlocks("""[{"blockType":"TEXT","text":"
& ' é ✓ 😀"}]""") + val escaped = BlockConverter.jsonToBlocks( + """[{"blockType":"TEXT","text":"\u003ca\u003e \u0026 \u0027 \u00e9 \u2713 \ud83d\ude00"}]""", + ) + + assertEquals(" & ' é ✓ 😀", (raw.single() as Block.TextBlock).text.value) + assertEquals((raw.single() as Block.TextBlock).text.value, (escaped.single() as Block.TextBlock).text.value) + } + + @Test + fun wrongShapesAreUnreadableNotCrashes() { + listOf( + """{"blockType":"TEXT","text":"not an array"}""", + """[{"blockType":"TEXT","text":null}]""", + """[{"blockType":null}]""", + """[{"blockType":"H9","text":"x"}]""", + """[{"blockType":"LINK","block":"nope"}]""", + """[{"blockType":"LIST_CHECK","items":{"value":"x"}}]""", + """[{"blockType":"LIST_CHECK","items":[null]}]""", + """[{"blockType":"LIST_CHECK","items":["x"]}]""", + """[{"blockType":"TEXT","text":{"a":1}}]""", + "[", + """[{"blockType":"TEXT","text":"cut""", + ).forEach { json -> assertNull(BlockConverter.jsonToBlocksOrNull(json), json) } + } + + @Test + fun aJsonNullIsAnEmptyNote() { + assertEquals(emptyList(), BlockConverter.jsonToBlocks("null")) + } + + @Test + fun scalarValuesAreReadAsTheirText() { + val blocks = BlockConverter.jsonToBlocks( + """[{"blockType":"TEXT","text":42},{"blockType":"LIST_CHECK","items":[{"value":"a","isCheck":"true"}]}]""", + ) + + assertEquals("42", (blocks[0] as Block.TextBlock).text.value) + assertEquals(true, (blocks[1] as Block.ListBlock).items.value.single().isCheck) + } + + // CODE and TABLE are newer than any release: an older app refuses a note that holds one (see BlockJson), so + // their bytes are pinned here from the start. + + @Test + fun codeBlock() = assertStable( + """ + [ + { + "blockType": "CODE", + "text": "if (a \u003c b) {\n\tgo()\n}" + } + ] + """.trimIndent(), + ) + + @Test + fun tableBlock() = assertStable( + """ + [ + { + "blockType": "TABLE", + "hasHeader": true, + "rows": [ + [ + "name", + "qty" + ], + [ + "a|b", + "" + ] + ] + } + ] + """.trimIndent(), + ) + + @Test + fun tableBlockWithoutHeader() = assertStable( + """ + [ + { + "blockType": "TABLE", + "hasHeader": false, + "rows": [ + [ + "x" + ] + ] + } + ] + """.trimIndent(), + ) + + @Test + fun aStoredListWithNoItemsHasOneEmptyItem() { + val list = BlockConverter.jsonToBlocks("""[{"blockType":"LIST_BULLET","items":[]}]""").single() + + assertEquals(listOf(""), (list as Block.ListBlock).items.value.map { it.value }) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/InlineHtmlTest.kt similarity index 77% rename from app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/InlineHtmlTest.kt index 0db44014..3f2cca19 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/InlineHtmlTest.kt @@ -1,7 +1,7 @@ -package com.pasich.encly.data.handoff +package com.pasich.encly.editor.codec -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals class InlineHtmlTest { @@ -36,6 +36,16 @@ class InlineHtmlTest { assertEquals("&bogus; � � �", InlineHtml.toPlainText("&bogus; � � �")) } + @Test + fun theEdgesOfTheCodePointRangeAreDecidedExactly() { + // One past the last code point and the last low surrogate stay as typed; the code points around + // the surrogates, the first of the supplementary planes and the last one are text. + assertEquals( + "� � ퟿  𐀀 􏿿 \u0001", + InlineHtml.toPlainText("� � ퟿  𐀀 􏿿 "), + ) + } + @Test fun textThatOnlyLooksLikeMarkupIsKept() { assertEquals("a < b > c, 3<4", InlineHtml.toPlainText("a < b > c, 3<4")) diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt new file mode 100644 index 00000000..f0a54edd --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt @@ -0,0 +1,187 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +class MarkdownBlockMapperTest { + /** Blocks as short strings for exact assertions. */ + private 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.CodeBlock -> "code:${block.text.value}" + + is Block.TableBlock -> "table${if (block.table.value.header) "" else "-headless"}:" + + block.table.value.rows.joinToString("/") { row -> row.cells.joinToString("|") { it.value } } + + 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 } + } + } + } + + 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 aHeadingLosesItsBlanksAndItsClosingHashesButNotAHashThatIsPartOfTheTitle() { + assertEquals( + listOf("h1:A", "h2:A b", "h1:a#", "h1:#", "h3:#", "h1:a #"), + blocks("#\t A ## \n\n## A b #\n\n# a#\n\n# #\n\n### #\n\n# a # #"), + ) + } + + @Test + fun aHeadingWithAHugeRunOfBlanksIsReadInLinearTime() { + val blanks = " \t".repeat(50_000) + + assertEquals(listOf("h1:a"), blocks("# a$blanks")) + assertEquals(listOf("h1:!"), blocks("#$blanks!")) + assertEquals(listOf("h1:a"), blocks("# a$blanks#$blanks")) + } + + @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 fencedCodeIsACodeBlockWithoutItsFences() { + assertEquals(listOf("code: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", "table:a|b"), + blocks("#hashtag no space\n\n| a | b |\n|---|---|"), + ) + 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 aNumberedListNeedsAtMostThreeDigitsInAPasteAndInAnImport() { + assertEquals(listOf("text:2024. Report"), pasted("2024. Report")) + assertEquals(listOf("number:[ ] one"), pasted("12. one")) + // Encly numbers items itself, so an import cannot keep a start number: a long number is text. + assertEquals(listOf("text:2024. Report"), blocks("2024. Report")) + assertEquals(listOf("number:[ ] one|[ ] two"), blocks("1. one\n2) two")) + } + + @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)) + } + + @Test + fun angleBracketTextThatIsNotHtmlIsKept() { + assertEquals( + listOf("text:Map and Dear , ad"), + blocks("Map and Dear , ad"), + ) + } + + @Test + fun realInlineTagsAreRemovedAndTheirTextStays() { + assertEquals( + listOf("text:x l\ny z"), + blocks("x
l
y z "), + ) + } + + @Test + fun aLongRunOfBracketsDoesNotHangTheWikiLinkMatch() { + val started = TimeSource.Monotonic.markNow() + MarkdownBlockMapper.toBlocks("[".repeat(200_000)) + MarkdownBlockMapper.toBlocks("[[a|".repeat(50_000)) + assertTrue(started.elapsedNow() < 1.seconds, "took ${started.elapsedNow()}") + } + + @Test + fun aWikiLinkWithBracketsAroundItStillResolves() { + assertEquals(listOf("text:see alias and Name"), blocks("see [[Note|alias]] and [[Name]]")) + } + + @Test + fun anImportDropsABlankItemButAPasteKeepsIt() { + assertEquals(listOf("bullet:[ ] a|[ ] b"), blocks("- a\n- \n- b")) + assertEquals(listOf("bullet:[ ] a|[ ] |[ ] b"), pasted("- a\n- \n- b")) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/NestedListCodecTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/NestedListCodecTest.kt new file mode 100644 index 00000000..3759a4d9 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/NestedListCodecTest.kt @@ -0,0 +1,271 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Nested lists in the stored JSON and in Markdown, in both directions. */ +class NestedListCodecTest { + private fun row(text: String, level: Int = 0, kind: BlockType? = null, check: Boolean = false) = + ItemListBlock(text, isCheck = check, level = level, kind = kind) + + private fun list(type: BlockType, vararg rows: ItemListBlock) = + Block.ListBlock(MutableStateFlow(rows.toList()), type) + + /** A list as `kind:text level` pieces, for exact assertions. */ + private fun shape(block: Block): String { + val list = block as Block.ListBlock + val kind = list.blockType.name.removePrefix("LIST_").lowercase() + return kind + ":" + list.items.value.joinToString("|") { item -> + val own = item.kind?.name?.removePrefix("LIST_")?.lowercase()?.let { "<$it>" }.orEmpty() + "${if (item.isCheck) "x" else ""}${item.value}${item.level}$own" + } + } + + private fun markdown(text: String) = MarkdownBlockMapper.toBlocks(text).map(::shape) + + // --- JSON ------------------------------------------------------------------------------------- + + @Test + fun aFlatListIsStoredExactlyAsBefore() { + val json = BlockConverter.blocksToJson( + listOf(list(BlockType.LIST_CHECK, row("milk"), row("eggs", check = true))), + ) + + assertEquals( + """ + [ + { + "blockType": "LIST_CHECK", + "items": [ + { + "value": "milk", + "isCheck": false + }, + { + "value": "eggs", + "isCheck": true + } + ] + } + ] + """.trimIndent(), + json, + ) + assertFalse("level" in json) + assertFalse("kind" in json) + } + + @Test + fun aNestedItemStoresItsLevelAfterItsCheckAndAKindOnlyWhenItDiffers() { + val json = BlockConverter.blocksToJson( + listOf( + list( + BlockType.LIST_BULLET, + row("a"), + row("b", 1), + row("c", 2, BlockType.LIST_CHECK, check = true), + row("d", 1, BlockType.LIST_BULLET), + ), + ), + ) + + assertEquals( + """ + [ + { + "blockType": "LIST_BULLET", + "items": [ + { + "value": "a", + "isCheck": false + }, + { + "value": "b", + "isCheck": false, + "level": 1 + }, + { + "value": "c", + "isCheck": true, + "level": 2, + "kind": "LIST_CHECK" + }, + { + "value": "d", + "isCheck": false, + "level": 1 + } + ] + } + ] + """.trimIndent(), + json, + ) + } + + @Test + fun nestedItemsComeBackAsTheyWereStored() { + val original = listOf( + list(BlockType.LIST_NUMBER, row("a"), row("b", 1, BlockType.LIST_CHECK), row("c", 2), row("d")), + ) + + val back = BlockConverter.jsonToBlocks(BlockConverter.blocksToJson(original)) + + assertEquals(listOf("number:a0|b1|c2|d0"), back.map(::shape)) + assertEquals(BlockConverter.blocksToJson(original), BlockConverter.blocksToJson(back)) + } + + @Test + fun aBlankItemIsDroppedAndItsChildrenAreBroughtUpToAValidLevel() { + val json = BlockConverter.blocksToJson(listOf(list(BlockType.LIST_BULLET, row(" "), row("child", 1), row("x")))) + + val back = BlockConverter.jsonToBlocks(json) + + assertEquals(listOf("bullet:child0|x0"), back.map(::shape)) + assertFalse("level" in json) + } + + @Test + fun levelsThatAreNotValidAreMadeValidWhenAStoredNoteIsRead() { + val json = """[{"blockType":"LIST_BULLET","items":[ + {"value":"a","isCheck":false,"level":3}, + {"value":"b","isCheck":false,"level":"2"}, + {"value":"c","isCheck":false,"level":"deep"}, + {"value":"d","isCheck":false,"level":-1,"kind":"QUOTE"}, + {"value":"e","isCheck":false,"level":1,"kind":"LIST_BULLET"}, + {"value":"f","isCheck":false,"level":1,"kind":"NOT_A_KIND"} + ]}]""" + + val back = BlockConverter.jsonToBlocks(json) + + assertEquals(listOf("bullet:a0|b1|c0|d0|e1|f1"), back.map(::shape)) + } + + @Test + fun aNoteWithoutLevelsReadsFlat() { + val json = """[{"blockType":"LIST_CHECK","items":[ + {"value":"a","isCheck":true},{"value":"b","isCheck":false} + ]}]""" + + assertEquals(listOf("check:xa0|b0"), BlockConverter.jsonToBlocks(json).map(::shape)) + } + + @Test + fun aReaderTakesTheKeysItKnowsAndIgnoresTheRest() { + // How an app without nesting reads a nested note: `level` and `kind` are keys it does not know. This is the + // same tolerance, tested with a key nothing knows: every item comes back, in order, with its text and check. + val json = """[{"blockType":"LIST_BULLET","items":[ + {"value":"a","isCheck":true,"future":{"x":1}}, + {"value":"b","isCheck":false,"level":1,"kind":"LIST_CHECK","other":[1,2]} + ]}]""" + + val read = BlockConverter.jsonToBlocks(json).single() as Block.ListBlock + + assertEquals(listOf("a", "b"), read.items.value.map { it.value }) + assertEquals(listOf(true, false), read.items.value.map { it.isCheck }) + } + + // --- Markdown in ------------------------------------------------------------------------------ + + @Test + fun itemsIndentedByTwoSpacesNestUnderTheOneAbove() { + assertEquals( + listOf("bullet:a0|b1|c2|d1|e0"), + markdown("- a\n - b\n - c\n - d\n- e"), + ) + } + + @Test + fun itemsIndentedByFourSpacesOrATabNestToo() { + assertEquals(listOf("bullet:a0|b1|c2|d0"), markdown("- a\n - b\n - c\n- d")) + assertEquals(listOf("bullet:a0|b1|c2|d0"), markdown("- a\n\t- b\n\t\t- c\n- d")) + assertEquals(listOf("bullet:a0|b1|c2"), markdown("- a\n \t- b\n \t - c")) + } + + @Test + fun anUnevenIndentStillNestsOneLevelAtATime() { + assertEquals(listOf("bullet:a0|b1|c2|d1|e0"), markdown("- a\n - b\n - c\n - d\n- e")) + } + + @Test + fun nestingDeeperThanThreeLevelsStaysAtTheThird() { + assertEquals(listOf("bullet:a0|b1|c2|d2"), markdown("- a\n - b\n - c\n - d")) + } + + @Test + fun aNestedListMayBeOfAnotherKindThanItsParent() { + assertEquals( + listOf("bullet:a0|b1|c1|d1|e0"), + markdown("- a\n 1. b\n 2. c\n - [ ] d\n- e"), + ) + } + + @Test + fun aNumberedListWithNestedBulletsAndChecks() { + assertEquals( + listOf("number:a0|b1|xc1|d0"), + markdown("1. a\n - b\n - [x] c\n2. d"), + ) + } + + @Test + fun anotherKindAtTheTopStartsAListOfItsOwn() { + assertEquals(listOf("bullet:a0|b1", "number:c0|d1"), markdown("- a\n - b\n1. c\n 2. d")) + } + + @Test + fun aNestedItemOfTheListsOwnKindStoresNoKind() { + val blocks = MarkdownBlockMapper.toBlocks("- a\n - b") + + assertEquals(listOf(null, null), (blocks.single() as Block.ListBlock).items.value.map { it.kind }) + } + + @Test + fun anIndentedFirstItemStartsAtTheTop() { + assertEquals(listOf("bullet:a0|b0|c1"), markdown(" - a\n - b\n - c")) + } + + @Test + fun aBlankNestedItemIsDroppedWithoutLeavingOrphans() { + assertEquals(listOf("bullet:a0|c1"), markdown("- a\n - \n - c")) + assertEquals(listOf("bullet:a0|x0"), markdown("- \n - a\n- x")) + } + + @Test + fun aParagraphEndsTheListAndTheIndentStartsAgain() { + assertEquals( + listOf("bullet:a0|b1", "text", "bullet:c0"), + MarkdownBlockMapper.toBlocks("- a\n - b\n\ntext\n\n - c") + .map { if (it is Block.ListBlock) shape(it) else "text" }, + ) + } + + @Test + fun indentedTextAfterAnItemStillContinuesIt() { + val blocks = MarkdownBlockMapper.toBlocks("- a\n - b\n more of b") + + assertEquals(listOf("a", "b more of b"), (blocks.single() as Block.ListBlock).items.value.map { it.value }) + } + + @Test + fun aPastedNestedListKeepsItsLevels() { + val blocks = MarkdownBlockMapper.pasteBlocks("- a\n - b\n - c") + + assertTrue(blocks.single() is Block.ListBlock) + assertEquals("bullet:a0|b1|c1", shape(blocks.single())) + } + + @Test + fun checklistsNestToo() { + assertEquals( + listOf("check:a0|xb1|c1"), + markdown("- [ ] a\n - [x] b\n - [ ] c"), + ) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt new file mode 100644 index 00000000..e898b848 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt @@ -0,0 +1,193 @@ +package com.pasich.encly.editor.codec + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.TableData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.SerializationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Code and table blocks in the stored JSON, and as Markdown. Notes that hold none are untouched (see + * [BlockJsonFormatTest]): the new blocks add their own `blockType` values and keys, nothing else. + */ +class TablesAndCodeCodecTest { + private fun describe(blocks: List): List = blocks.map { block -> + when (block) { + is Block.CodeBlock -> "code:${block.text.value}" + + is Block.TableBlock -> "table${if (block.table.value.header) "" else "-headless"}:" + + block.table.value.rows.joinToString("/") { row -> row.cells.joinToString("|") { it.value } } + + is Block.TextBlock -> "text:${block.text.value}" + + is Block.ListBlock -> "list:${block.items.value.joinToString("|") { it.value }}" + + is Block.HBlock -> "h:${block.text.value}" + + else -> block::class.simpleName.orEmpty() + } + } + + private fun md(markdown: String) = describe(MarkdownBlockMapper.toBlocks(markdown)) + + // --- the stored JSON ---------------------------------------------------------------------------- + + @Test + fun aCodeBlockIsStoredAsItsTextUnderItsOwnType() { + val json = BlockConverter.blocksToJson(listOf(Block.CodeBlock(MutableStateFlow(" if (a < b) {\n\tx()\n}")))) + + assertTrue("\"blockType\": \"CODE\"" in json, json) + assertEquals( + listOf("code: if (a < b) {\n\tx()\n}"), + describe(BlockConverter.jsonToBlocks(json)), + ) + } + + @Test + fun aTableIsStoredAsRowsOfCellTexts() { + val data = TableData.of(listOf(listOf("a", "b|c"), listOf("", "\"q\"")), header = false) + val json = BlockConverter.blocksToJson(listOf(Block.TableBlock(MutableStateFlow(data)))) + + assertTrue("\"blockType\": \"TABLE\"" in json, json) + assertTrue("\"hasHeader\": false" in json, json) + assertEquals(listOf("table-headless:a|b|c/|\"q\""), describe(BlockConverter.jsonToBlocks(json))) + } + + @Test + fun aStoredTableWithoutTheHeaderKeyHasOne() { + val json = """[{"blockType":"TABLE","rows":[["a"],["b"]]}]""" + + assertEquals(listOf("table:a/b"), describe(BlockConverter.jsonToBlocks(json))) + } + + @Test + fun aStoredCodeBlockWithoutTextIsEmpty() { + assertEquals(listOf("code:"), describe(BlockConverter.jsonToBlocks("""[{"blockType":"CODE"}]"""))) + } + + @Test + fun aStoredTableWithoutRowsIsOneEmptyCell() { + assertEquals(listOf("table:"), describe(BlockConverter.jsonToBlocks("""[{"blockType":"TABLE"}]"""))) + } + + @Test + fun aMalformedTableIsRefusedLikeAnyMalformedBlock() { + assertFailsWith { + BlockConverter.jsonToBlocks("""[{"blockType":"TABLE","rows":["a"]}]""") + } + assertFailsWith { + BlockConverter.jsonToBlocks("""[{"blockType":"TABLE","rows":[[null]]}]""") + } + assertFailsWith { + BlockConverter.jsonToBlocks("""[{"blockType":"TABLE","rows":[[["a"]]]}]""") + } + assertNull(BlockConverter.jsonToBlocksOrNull("""[{"blockType":"TABLE","rows":"a"}]""")) + } + + @Test + fun aTypeThisVersionDoesNotKnowIsStillRefusedWholeSoNothingIsSilentlyDropped() { + assertFailsWith { + BlockConverter.jsonToBlocks("""[{"blockType":"TEXT","text":"a"},{"blockType":"CALLOUT","text":"b"}]""") + } + } + + @Test + fun emptyCodeAndTablesAreStoredWhereverTheyAre() { + // The blank paragraph a fresh note starts with is still dropped, but a block the user made is kept. + val alone = BlockConverter.blocksToJson(listOf(Block.CodeBlock(), Block.TableBlock(), Block.TextBlock())) + assertEquals(listOf("code:", "table:|/|"), describe(BlockConverter.jsonToBlocks(alone))) + + val between = BlockConverter.blocksToJson( + listOf(Block.CodeBlock(MutableStateFlow("x")), Block.TableBlock(), Block.TextBlock(MutableStateFlow("t"))), + ) + assertEquals(listOf("code:x", "table:|/|", "text:t"), describe(BlockConverter.jsonToBlocks(between))) + } + + // --- Markdown import -------------------------------------------------------------------------- + + @Test + fun fencedCodeKeepsEverySpaceAndIsNotRewritten() { + assertEquals( + listOf("code: a *b* c\n\n\t d"), + md("~~~js\n a *b* c\n\n\t d\n\n~~~"), + ) + assertEquals(emptyList(), md("```\n\n```")) + } + + @Test + fun aPipeTableIsATableWhateverItsAlignmentAndOuterBars() { + assertEquals(listOf("table:a|b/1|2"), md("| a | b |\n| :--- | ---: |\n| 1 | 2 |")) + assertEquals(listOf("table:a|b/1|2"), md("a | b\n:-:|--\n1 | 2")) + assertEquals(listOf("table:a/1/2"), md("|a|\n|-|\n|1|\n|2|")) + } + + @Test + fun aBarInACellIsEscapedAndSoIsABackslash() { + assertEquals(listOf("table:a|b/x|y|z\\w"), md("| a | b |\n|---|---|\n| x\\|y | z\\w |")) + assertEquals(listOf("table:a/p\\q"), md("| a |\n|---|\n| p\\\\q |")) + } + + @Test + fun aFenceEndsOnlyAtAFenceOfTheSameMarkAtLeastAsLong() { + assertEquals(listOf("code:```kotlin\nx\n```"), md("````\n```kotlin\nx\n```\n````")) + assertEquals(listOf("code:a\n~~~\nb"), md("```\na\n~~~\nb\n```")) + assertEquals(listOf("code:a\n``` not a close\nb"), md("```\na\n``` not a close\nb\n```")) + assertEquals(listOf("code:open until the end"), md("```\nopen until the end")) + assertEquals(listOf("code:a", "text:after"), md("```\na\n`````\n\nafter")) + } + + @Test + fun rowsAreMadeAsWideAsTheWidest() { + assertEquals(listOf("table:a|b|/1||/1|2|3"), md("| a | b |\n|---|---|\n| 1 |\n| 1 | 2 | 3 |")) + } + + @Test + fun aHeaderOfEmptyCellsIsATableWithoutAHeader() { + assertEquals(listOf("table-headless:x|y"), md("| | |\n|---|---|\n| x | y |")) + assertEquals(listOf("table-headless:"), md("|\n|---|")) + } + + @Test + fun aTableEndsAtABlankLineAParagraphOrAFenceAndInterruptsAParagraph() { + assertEquals(listOf("text:before", "table:a/1", "text:after"), md("before\n| a |\n|---|\n| 1 |\n\nafter")) + assertEquals(listOf("table:a/1", "text:after"), md("| a |\n|---|\n| 1 |\nafter")) + assertEquals(listOf("table:a/1", "code:x"), md("| a |\n|---|\n| 1 |\n```\nx\n```")) + assertEquals(listOf("table:a", "code:y"), md("| a |\n|---|\n```js|x\ny\n```")) + assertEquals(listOf("table:a/1", "table:b/2"), md("| a |\n|---|\n| 1 |\n\n| b |\n|---|\n| 2 |")) + } + + @Test + fun aLineWithABarThatIsNoTableHeaderStaysText() { + assertEquals(listOf("text:a | b"), md("a | b")) + assertEquals(listOf("text:a | b\nnext"), md("a | b\nnext")) + assertEquals(listOf("text:a | b", "text:next"), md("a | b\n\nnext")) + assertEquals(listOf("text:a | b\nc | d"), md("a | b\nc | d")) + // The delimiter must have the header's number of cells, each only dashes and colons. + assertEquals(listOf("text:a | b\n--- | ---- | -"), md("a | b\n--- | ---- | -")) + assertEquals(listOf("text:a | b\nx | y"), md("a | b\nx | y")) + assertEquals(listOf("text:a | b", "SeparatorBlock"), md("a | b\n---")) + assertEquals(listOf("h:a | b"), md("# a | b")) + assertEquals(listOf("list:a | b"), md("- a | b")) + assertEquals(listOf("text:a | b", "code:x"), md("a | b\n```\nx\n```")) + } + + @Test + fun aPasteKeepsTablesAndFencesAsTypedText() { + assertEquals( + listOf("text:| a | b |\n|---|---|"), + describe(MarkdownBlockMapper.pasteBlocks("| a | b |\n|---|---|")), + ) + assertEquals(listOf("text:```\nx\n```"), describe(MarkdownBlockMapper.pasteBlocks("```\nx\n```"))) + } + + @Test + fun importBlocksBuildCodeAndTables() { + assertEquals(listOf("code:x"), describe(listOf(ImportBlocks.code("x")))) + val table = ImportBlocks.table(listOf(listOf("a", "b"), listOf("c")), header = false) + assertEquals(listOf("table-headless:a|b/c|"), describe(listOf(table))) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistryTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistryTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistryTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistryTest.kt index 29d5c89a..7e250d1f 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistryTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistryTest.kt @@ -1,20 +1,20 @@ -package com.pasich.encly.presentation.editor.focus +package com.pasich.encly.editor.focus -import com.pasich.encly.domain.model.LinkDataBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.presentation.editor.state.FocusRequest +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.state.FocusRequest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class BlockFocusRegistryTest { diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/focus/CaretGeometryTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/CaretGeometryTest.kt similarity index 87% rename from app/src/test/java/com/pasich/encly/presentation/editor/focus/CaretGeometryTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/CaretGeometryTest.kt index b7ea2b71..ce58fe69 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/focus/CaretGeometryTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/CaretGeometryTest.kt @@ -1,13 +1,13 @@ -package com.pasich.encly.presentation.editor.focus +package com.pasich.encly.editor.focus -import com.pasich.encly.presentation.editor.state.CaretMove -import com.pasich.encly.presentation.editor.state.FocusRequest -import com.pasich.encly.presentation.editor.state.goesUp -import com.pasich.encly.presentation.editor.state.requestFor -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import com.pasich.encly.editor.state.CaretMove +import com.pasich.encly.editor.state.FocusRequest +import com.pasich.encly.editor.state.goesUp +import com.pasich.encly.editor.state.requestFor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class CaretGeometryTest { /** Lines of [width] characters, [charWidth] pixels each. */ diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/focus/KeyboardUtilsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/KeyboardUtilsTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/presentation/editor/focus/KeyboardUtilsTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/KeyboardUtilsTest.kt index 74c37814..c69de096 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/focus/KeyboardUtilsTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/focus/KeyboardUtilsTest.kt @@ -1,11 +1,11 @@ -package com.pasich.encly.presentation.editor.focus +package com.pasich.encly.editor.focus import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class KeyboardUtilsTest { private var enters = 0 diff --git a/app/src/test/java/com/pasich/encly/dynamicBlocks/BlockFactoryTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockFactoryTest.kt similarity index 81% rename from app/src/test/java/com/pasich/encly/dynamicBlocks/BlockFactoryTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockFactoryTest.kt index 3e275592..d2f67e57 100644 --- a/app/src/test/java/com/pasich/encly/dynamicBlocks/BlockFactoryTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockFactoryTest.kt @@ -1,15 +1,11 @@ -package com.pasich.encly.dynamicBlocks +package com.pasich.encly.editor.model -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.domain.model.LinkDataBlock -import com.pasich.encly.dynamicBlocks.factory.BlockFactory -import com.pasich.encly.dynamicBlocks.utils.BlockUtils import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class BlockFactoryTest { @@ -24,9 +20,15 @@ class BlockFactoryTest { is Block.LinkBlock -> BlockType.LINK is Block.SeparatorBlock -> BlockType.SEPARATOR is Block.ListBlock -> block.blockType + is Block.CodeBlock -> BlockType.CODE + is Block.TableBlock -> BlockType.TABLE } assertEquals(type, created) - if (type != BlockType.SEPARATOR) assertTrue("$type starts empty", BlockUtils.isBlockEmpty(block)) + if (type != BlockType.SEPARATOR && + type != BlockType.TABLE + ) { + assertTrue(BlockUtils.isBlockEmpty(block), "$type starts empty") + } } } diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockIdTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockIdTest.kt new file mode 100644 index 00000000..e46e8811 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockIdTest.kt @@ -0,0 +1,20 @@ +package com.pasich.encly.editor.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BlockIdTest { + @Test + fun anIdIsALowercaseHyphenatedUuid() { + val id = newBlockId() + assertTrue(Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}").matches(id), id) + } + + @Test + fun idsAreRandomVersion4Uuids() { + val ids = List(200) { newBlockId() } + assertEquals(200, ids.toSet().size) + assertTrue(ids.all { it[14] == '4' && it[19] in "89ab" }) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/ListLevelsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/ListLevelsTest.kt new file mode 100644 index 00000000..c12f4dd5 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/ListLevelsTest.kt @@ -0,0 +1,308 @@ +package com.pasich.encly.editor.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** The rules of nested list items: valid levels, subtrees, siblings, numbers, indenting and moving. */ +class ListLevelsTest { + private fun row(text: String, level: Int = 0, kind: BlockType? = null, check: Boolean = false) = + ItemListBlock(text, isCheck = check, level = level, kind = kind) + + private fun List.shape() = joinToString(" ") { "${it.value}${it.level}" } + + private val bullet = BlockType.LIST_BULLET + private val number = BlockType.LIST_NUMBER + private val check = BlockType.LIST_CHECK + + // a0 b1 c2 d1 e0 + private val tree = listOf(row("a"), row("b", 1), row("c", 2), row("d", 1), row("e")) + + @Test + fun aListKindIsABulletedNumberedOrCheckList() { + assertTrue(bullet.isListKind() && number.isListKind() && check.isListKind()) + assertTrue(!BlockType.TEXT.isListKind() && !BlockType.QUOTE.isListKind()) + } + + @Test + fun aNestedRowHasItsOwnKindAndATopRowTheListsOwn() { + assertEquals(bullet, row("a").kindIn(bullet)) + assertEquals(bullet, row("a", 1).kindIn(bullet)) + assertEquals(check, row("a", 1, check).kindIn(bullet)) + // A kind on a row at the top level is ignored. + assertEquals(bullet, row("a", 0, check).kindIn(bullet)) + } + + // --- normalizedLevels ------------------------------------------------------------------------ + + @Test + fun validLevelsAreLeftAloneAndTheSameListIsReturned() { + assertSame(tree, tree.normalizedLevels(bullet)) + assertTrue(emptyList().normalizedLevels(bullet).isEmpty()) + } + + @Test + fun theFirstRowIsAtTheTopAndNoRowIsMoreThanOneDeeperThanTheOneBefore() { + val damaged = listOf(row("a", 2), row("b", 2), row("c", 0), row("d", 2), row("e", 5)) + + assertEquals("a0 b1 c0 d1 e2", damaged.normalizedLevels(bullet).shape()) + } + + @Test + fun aNegativeLevelIsTheTopAndNothingGoesPastTheDeepestLevel() { + val rows = listOf(row("a", -3), row("b", 1), row("c", 2), row("d", 3), row("e", 99)) + + assertEquals("a0 b1 c2 d2 e2", rows.normalizedLevels(bullet).shape()) + } + + @Test + fun aKindIsKeptOnlyWhereItDiffersFromTheListAndTheRowIsNested() { + val rows = listOf( + row("a", 0, check), + row("b", 1, bullet), + row("c", 1, check), + row("d", 1, BlockType.QUOTE), + ) + + val fixed = rows.normalizedLevels(bullet) + + assertEquals(listOf(null, null, check, null), fixed.map { it.kind }) + } + + @Test + fun fixingLevelsKeepsTextIdsAndChecks() { + val rows = listOf(row("a", 4, check = true)) + + val fixed = rows.normalizedLevels(bullet).single() + + assertEquals(0, fixed.level) + assertEquals("a", fixed.value) + assertEquals(rows.single().id, fixed.id) + assertTrue(fixed.isCheck) + } + + // --- subtrees and siblings ------------------------------------------------------------------- + + @Test + fun aSubtreeIsTheRowAndTheDeeperRowsAfterIt() { + assertEquals(4, tree.subtreeEnd(0)) + assertEquals(3, tree.subtreeEnd(1)) + assertEquals(3, tree.subtreeEnd(2)) + assertEquals(4, tree.subtreeEnd(3)) + assertEquals(5, tree.subtreeEnd(4)) + } + + @Test + fun siblingsShareAParentAndALevel() { + assertEquals(listOf(0, 4), tree.siblingStarts(0)) + assertEquals(listOf(0, 4), tree.siblingStarts(4)) + assertEquals(listOf(1, 3), tree.siblingStarts(1)) + assertEquals(listOf(1, 3), tree.siblingStarts(3)) + assertEquals(listOf(2), tree.siblingStarts(2)) + } + + @Test + fun siblingsDoNotCrossAParent() { + val rows = listOf(row("a"), row("b", 1), row("c"), row("d", 1), row("e", 1)) + + assertEquals(listOf(1), rows.siblingStarts(1)) + assertEquals(listOf(3, 4), rows.siblingStarts(4)) + } + + // --- numbers --------------------------------------------------------------------------------- + + @Test + fun numbersCountTheRunOfEachLevelAndStartAgainUnderEveryParent() { + val rows = listOf(row("a"), row("b", 1), row("c", 1), row("d"), row("e", 1), row("f", 2), row("g", 1)) + + assertEquals(listOf(1, 1, 2, 2, 1, 1, 2), rows.numbers(number)) + } + + @Test + fun aDeeperRowDoesNotBreakTheRunOfItsParentsSiblings() { + val rows = listOf(row("a"), row("b", 1), row("c"), row("d")) + + assertEquals(listOf(1, 1, 2, 3), rows.numbers(number)) + } + + @Test + fun onlyNumberedRowsGetANumberAndAChangeOfKindStartsTheCountAgain() { + val rows = listOf( + row("a"), + row("b", 1, bullet), + row("c", 1, number), + row("d", 1, number), + row("e", 1, bullet), + row("f", 1, number), + ) + + assertEquals(listOf(1, 0, 1, 2, 0, 1), rows.numbers(number)) + assertEquals(listOf(0, 0, 1, 2, 0, 1), rows.numbers(bullet)) + } + + // --- shifted --------------------------------------------------------------------------------- + + @Test + fun aRowGoesOneLevelDeeperWithItsChildren() { + // b1 c2 d1 under a: indenting d makes it a child of b. + val moved = tree.shifted(3, deeper = true, listType = bullet) + + assertEquals("a0 b1 c2 d2 e0", moved?.shape()) + } + + @Test + fun aRowCannotGoDeeperThanTheRowBeforeItPlusOneOrWithoutAnyRowBefore() { + assertNull(tree.shifted(0, deeper = true, listType = bullet)) + // c is already one deeper than b. + assertNull(tree.shifted(2, deeper = true, listType = bullet)) + // e at the top follows d at 1: it can go to 1. + assertEquals("a0 b1 c2 d1 e1", tree.shifted(4, deeper = true, listType = bullet)?.shape()) + } + + @Test + fun aRowWhoseChildrenWouldPassTheDeepestLevelCannotGoDeeper() { + // b has a child c at level 2: b cannot go to level 2 with c at 3. + val rows = listOf(row("a", 0), row("a2", 1), row("b", 1), row("c", 2)) + + assertNull(rows.shifted(2, deeper = true, listType = bullet)) + // a2 has no children and can go... it is at 1 after a at 0, which is the most. + assertNull(rows.shifted(1, deeper = true, listType = bullet)) + } + + @Test + fun aRowComesOutOneLevelWithItsChildren() { + assertEquals("a0 b0 c1 d1 e0", tree.shifted(1, deeper = false, listType = bullet)?.shape()) + assertEquals("a0 b1 c1 d1 e0", tree.shifted(2, deeper = false, listType = bullet)?.shape()) + } + + @Test + fun aRowAtTheTopCannotComeOutAndAMissingRowCannotMove() { + assertNull(tree.shifted(0, deeper = false, listType = bullet)) + assertNull(tree.shifted(9, deeper = true, listType = bullet)) + assertNull(tree.shifted(-1, deeper = false, listType = bullet)) + } + + @Test + fun aRowThatComesOutToTheTopLosesItsKind() { + val rows = listOf(row("a"), row("b", 1, check)) + + val out = rows.shifted(1, deeper = false, listType = bullet) + + assertNull(out?.get(1)?.kind) + assertEquals(0, out?.get(1)?.level) + } + + // --- movedBy --------------------------------------------------------------------------------- + + @Test + fun aRowMovesWithItsChildrenPastItsSiblings() { + val (down, downAt) = assertNotNull(tree.movedBy(0, 1)) + assertEquals("e0 a0 b1 c2 d1", down.shape()) + assertEquals(1, downAt) + + val (up, upAt) = assertNotNull(tree.movedBy(4, -1)) + assertEquals("e0 a0 b1 c2 d1", up.shape()) + assertEquals(0, upAt) + } + + @Test + fun aNestedRowMovesAmongItsOwnSiblings() { + val (down, at) = assertNotNull(tree.movedBy(1, 1)) + + assertEquals("a0 d1 b1 c2 e0", down.shape()) + assertEquals(2, at) + assertEquals(listOf("a", "d", "b", "c", "e"), down.map { it.value }) + } + + @Test + fun aRowCannotMoveWhereThereIsNoSiblingOrNoMove() { + assertNull(tree.movedBy(0, -1)) + assertNull(tree.movedBy(4, 1)) + assertNull(tree.movedBy(2, 1)) + assertNull(tree.movedBy(1, 0)) + assertNull(tree.movedBy(9, 1)) + assertNull(tree.movedBy(-1, 1)) + assertNull(tree.movedBy(0, 5)) + } + + @Test + fun aRowMovesSeveralPlaces() { + val flat = listOf(row("a"), row("b"), row("c"), row("d")) + + assertEquals("b0 c0 d0 a0", assertNotNull(flat.movedBy(0, 3)).first.shape()) + assertEquals("d0 a0 b0 c0", assertNotNull(flat.movedBy(3, -3)).first.shape()) + } + + // --- siblingOffsetAt ------------------------------------------------------------------------- + + @Test + fun thePlacesAPointerIsOverCountSiblingsNotRows() { + // Rows are 10 high: a 0-10, b 10-20, c 20-30, d 30-40, e 40-50. + val tops = listOf(0f, 10f, 20f, 30f, 40f) + val bottoms = listOf(10f, 20f, 30f, 40f, 50f) + + // a (with its children, down to y 40) dragged to the row of e. + assertEquals(1, tree.siblingOffsetAt(0, tops, bottoms, 45f)) + // Over its own children it has not moved. + assertEquals(0, tree.siblingOffsetAt(0, tops, bottoms, 25f)) + // e dragged up over a's subtree. + assertEquals(-1, tree.siblingOffsetAt(4, tops, bottoms, 15f)) + // d dragged over b's row, or over c, a child of b. + assertEquals(-1, tree.siblingOffsetAt(3, tops, bottoms, 12f)) + assertEquals(-1, tree.siblingOffsetAt(3, tops, bottoms, 25f)) + } + + @Test + fun aPointerPastEitherEndOfTheSiblingsIsOverTheNearestEnd() { + val tops = listOf(0f, 10f, 20f, 30f, 40f) + val bottoms = listOf(10f, 20f, 30f, 40f, 50f) + + assertEquals(1, tree.siblingOffsetAt(0, tops, bottoms, 500f)) + assertEquals(-1, tree.siblingOffsetAt(4, tops, bottoms, -500f)) + } + + @Test + fun aRowWithoutKnownBoundsIsNotMoved() { + assertEquals(0, tree.siblingOffsetAt(0, emptyList(), emptyList(), 5f)) + assertEquals(0, tree.siblingOffsetAt(0, listOf(0f, 10f, 20f, 30f, 40f), emptyList(), 5f)) + } + + // --- withSiblingsOfKind ---------------------------------------------------------------------- + + @Test + fun aKindIsSetOnARowAndItsSiblings() { + val changed = tree.withSiblingsOfKind(1, check, bullet) + + assertEquals(listOf(null, check, null, check, null), changed?.map { it.kind }) + } + + @Test + fun aKindThatChangesNothingOrAtTheTopChangesNothing() { + assertNull(tree.withSiblingsOfKind(0, check, bullet)) + assertNull(tree.withSiblingsOfKind(1, bullet, bullet)) + assertNull(tree.withSiblingsOfKind(9, check, bullet)) + } + + @Test + fun aKindEqualToTheListsOwnIsStoredAsNone() { + val checked = tree.withSiblingsOfKind(1, check, bullet) ?: error("changed") + + val back = checked.withSiblingsOfKind(1, bullet, bullet) + + assertEquals(listOf(null, null, null, null, null), back?.map { it.kind }) + } + + @Test + fun alongsideTakesTheLevelAndKindOfTheOtherRow() { + val other = row("x", 2, check) + + val placed = row("y").alongside(other) + + assertEquals(2, placed.level) + assertEquals(check, placed.kind) + assertEquals("y", placed.value) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/TableDataTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/TableDataTest.kt new file mode 100644 index 00000000..c07a0a72 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/TableDataTest.kt @@ -0,0 +1,163 @@ +package com.pasich.encly.editor.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TableDataTest { + private fun table(vararg rows: List, header: Boolean = true) = TableData.of(rows.toList(), header) + + private fun grid(data: TableData) = data.rows.map { row -> row.cells.map { it.value } } + + @Test + fun aNewTableIsTwoByTwoWithAHeaderAndNeverSmallerThanOneByOne() { + val data = TableData.empty() + assertEquals(2, data.rowCount) + assertEquals(2, data.columnCount) + assertTrue(data.header) + assertEquals(listOf(listOf("")), grid(TableData.empty(rows = 0, columns = -3))) + } + + @Test + fun aGridThatIsEmptyOrRaggedIsRefusedAtConstruction() { + assertFailsWith { TableData(emptyList()) } + assertFailsWith { TableData(listOf(TableRow(emptyList()))) } + assertFailsWith { + TableData(listOf(TableRow(listOf(TableCell("a"), TableCell("b"))), TableRow(listOf(TableCell("c"))))) + } + assertEquals(1, TableData(listOf(TableRow(listOf(TableCell("a"))))).columnCount) + } + + @Test + fun ofPadsShortRowsAndMakesAnEmptyGridOneCell() { + assertEquals( + listOf(listOf("a", "b", "c"), listOf("d", "", "")), + grid(table(listOf("a", "b", "c"), listOf("d"))), + ) + assertEquals(listOf(listOf("")), grid(TableData.of(emptyList()))) + assertEquals(listOf(listOf("")), grid(TableData.of(listOf(emptyList())))) + assertFalse(TableData.of(listOf(listOf("a")), header = false).header) + } + + @Test + fun valueAtAnswersEmptyOutsideTheTable() { + val data = table(listOf("a", "b"), listOf("c", "d")) + assertEquals("d", data.valueAt(1, 1)) + assertEquals("", data.valueAt(2, 0)) + assertEquals("", data.valueAt(0, 2)) + } + + @Test + fun aCellIsFoundByIdWhereverItSits() { + val data = table(listOf("a", "b"), listOf("c", "d")) + val d = data.rows[1].cells[1] + + assertEquals(1 to 1, data.locate(d.id)) + assertEquals(d, data.cell(d.id)) + assertNull(data.locate("nope")) + assertNull(data.cell("nope")) + } + + @Test + fun withCellChangesOnlyThatCellAndKeepsItsId() { + val data = table(listOf("a", "b")) + val b = data.rows[0].cells[1] + + val changed = data.withCell(b.id, "B") + + assertEquals(listOf(listOf("a", "B")), grid(changed)) + assertEquals(b.id, changed.rows[0].cells[1].id) + assertEquals(grid(data), grid(data.withCell("nope", "x"))) + } + + @Test + fun rowsAreAddedAtAnyPlaceAndClamped() { + val data = table(listOf("a", "b"), listOf("c", "d")) + + assertEquals(listOf(listOf("", ""), listOf("a", "b"), listOf("c", "d")), grid(data.addRow(0))) + assertEquals(listOf(listOf("a", "b"), listOf("", ""), listOf("c", "d")), grid(data.addRow(1))) + assertEquals(listOf(listOf("a", "b"), listOf("c", "d"), listOf("", "")), grid(data.addRow(99))) + assertEquals(3, data.addRow(-4).rowCount) + // The new cells never share an id with the old ones. + assertEquals(6, data.addRow(1).rows.flatMap { it.cells }.map { it.id }.toSet().size) + } + + @Test + fun aRowIsRemovedUnlessItIsTheOnlyOneOrIsNotThere() { + val data = table(listOf("a"), listOf("b")) + + assertEquals(listOf(listOf("b")), grid(data.removeRow(0))) + assertEquals(listOf(listOf("a")), grid(data.removeRow(1))) + assertSame(data, data.removeRow(2)) + assertSame(data, data.removeRow(-1)) + val single = table(listOf("a")) + assertSame(single, single.removeRow(0)) + } + + @Test + fun columnsAreAddedAndRemovedLikeRows() { + val data = table(listOf("a", "b"), listOf("c", "d")) + + assertEquals(listOf(listOf("", "a", "b"), listOf("", "c", "d")), grid(data.addColumn(0))) + assertEquals(listOf(listOf("a", "b", ""), listOf("c", "d", "")), grid(data.addColumn(5))) + assertEquals(3, data.addColumn(-1).columnCount) + assertEquals(listOf(listOf("b"), listOf("d")), grid(data.removeColumn(0))) + assertEquals(listOf(listOf("a"), listOf("c")), grid(data.removeColumn(1))) + assertSame(data, data.removeColumn(2)) + assertSame(data, data.removeColumn(-1)) + val single = table(listOf("a"), listOf("b")) + assertSame(single, single.removeColumn(0)) + } + + @Test + fun theHeaderRowIsSwitched() { + val data = table(listOf("a")) + assertFalse(data.withHeader(false).header) + assertTrue(data.withHeader(false).withHeader(true).header) + } + + @Test + fun aDuplicateHasTheSameTextAndNewIds() { + val data = table(listOf("a", "b")) + val copy = data.duplicate() + + assertEquals(grid(data), grid(copy)) + assertNotEquals(data.rows[0].cells[0].id, copy.rows[0].cells[0].id) + assertEquals(data.header, copy.header) + } + + @Test + fun tableBlocksHaveContentOnlyWithTextInSomeCell() { + assertFalse(Block.TableBlock().hasContent()) + assertFalse(Block.TableBlock(MutableStateFlow(table(listOf(" ", ""), listOf(" ", "\t")))).hasContent()) + assertTrue(Block.TableBlock(MutableStateFlow(table(listOf("", ""), listOf("", "x")))).hasContent()) + } + + @Test + fun codeBlocksHaveContentOnlyWithSomethingBesideBlanks() { + assertFalse(Block.CodeBlock().hasContent()) + assertFalse(Block.CodeBlock(MutableStateFlow(" \n")).hasContent()) + assertTrue(Block.CodeBlock(MutableStateFlow(" x")).hasContent()) + } + + @Test + fun theFactoryMakesEmptyCodeAndTableBlocks() { + assertTrue(BlockFactory.createBlock(BlockType.CODE) is Block.CodeBlock) + val made = BlockFactory.createBlock(BlockType.TABLE) as Block.TableBlock + assertEquals(2 to 2, made.table.value.rowCount to made.table.value.columnCount) + } + + @Test + fun emptyForBackspaceMeansNoTextAnywhere() { + assertTrue(BlockUtils.isBlockEmpty(Block.TableBlock())) + assertFalse(BlockUtils.isBlockEmpty(Block.TableBlock(MutableStateFlow(table(listOf("", "x")))))) + assertTrue(BlockUtils.isBlockEmpty(Block.CodeBlock())) + assertFalse(BlockUtils.isBlockEmpty(Block.CodeBlock(MutableStateFlow("x")))) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockActionsTest.kt similarity index 93% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockActionsTest.kt index 6046451d..8d0c23f6 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockActionsTest.kt @@ -1,17 +1,17 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock 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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue /** Duplicate, move, delete and convert on the block the user works on: each is one undo step. */ class BlockActionsTest { diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt index 757fb2f1..b53615d7 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt @@ -1,18 +1,18 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock 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.assertNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue /** Enter, Backspace, paste and typed marks: how text moves between blocks, and that undo takes it back. */ class BlockEditingTest { @@ -380,25 +380,44 @@ class BlockEditingTest { fun backspaceAtTheStartOfALaterItemJoinsItToTheItemAbove() { val list = list(BlockType.LIST_BULLET, "milk", " and eggs", "flour") editor.load(listOf(list)) - val first = list.items.value[0] + val second = list.items.value[1] - editor.listBackspaceAtStart(list.id, list.items.value[1].id) + editor.listBackspaceAtStart(list.id, second.id) assertEquals(listOf("milk and eggs", "flour"), list.items.value.map { it.value }) + // The item the cursor was in stays (and keeps its field, so a held Backspace goes on). + assertEquals(second.id, list.items.value[0].id) val focus = editor.lastFocus() - assertEquals(first.id, focus.itemId) + assertEquals(second.id, focus.itemId) assertEquals(4, focus.caret) } @Test - fun backspaceOnAnEmptyLastItemLeavesTheList() { + fun backspaceOnAnEmptyLastItemRemovesItAndKeepsTheFocusInTheList() { val list = list(BlockType.LIST_BULLET, "milk", "") editor.load(listOf(list)) + val empty = list.items.value[1] - editor.listBackspaceAtStart(list.id, list.items.value[1].id) + editor.listBackspaceAtStart(list.id, empty.id) assertEquals(listOf("milk"), list.items.value.map { it.value }) - assertTrue(editor.blocks[1] is Block.TextBlock) + assertEquals(1, editor.blocks.size) + assertEquals(empty.id, list.items.value.single().id) + val focus = editor.lastFocus() + assertEquals(empty.id, focus.itemId) + assertEquals(4, focus.caret) + } + + @Test + fun theItemAboveGivesItsTickedStateToTheJoinedItem() { + val above = ItemListBlock("done", isCheck = true) + val list = Block.ListBlock(MutableStateFlow(listOf(above, ItemListBlock("more"))), BlockType.LIST_CHECK) + editor.load(listOf(list)) + + editor.listBackspaceAtStart(list.id, list.items.value[1].id) + + assertEquals(listOf("donemore"), list.items.value.map { it.value }) + assertTrue(list.items.value.single().isCheck) } @Test @@ -435,7 +454,7 @@ class BlockEditingTest { assertTrue(editor.applyShortcut(block.id, mark, shortcut)) - assertEquals(mark, type, editor.blocks.single().typeOf()) + assertEquals(type, editor.blocks.single().typeOf(), mark) } } @@ -507,6 +526,8 @@ class BlockEditingTest { is Block.TextBlock -> BlockType.TEXT is Block.LinkBlock -> BlockType.LINK is Block.SeparatorBlock -> BlockType.SEPARATOR + is Block.CodeBlock -> BlockType.CODE + is Block.TableBlock -> BlockType.TABLE } private fun BlockEditorState.lastFocus(): FocusRequest = runTestResult { selection.requests.first() } diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditorStateTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditorStateTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditorStateTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditorStateTest.kt index 9b08137c..67c7f95e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditorStateTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditorStateTest.kt @@ -1,18 +1,18 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeoutOrNull -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue class BlockEditorStateTest { private var now = 0L diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockOperationsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockOperationsTest.kt new file mode 100644 index 00000000..712e3482 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockOperationsTest.kt @@ -0,0 +1,189 @@ +package com.pasich.encly.editor.state + +import androidx.compose.runtime.snapshots.SnapshotStateList +import com.pasich.encly.editor.model.Block +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BlockOperationsTest { + private var now = 0L + private val block = Block.TextBlock() + private val blocks = SnapshotStateList().apply { add(block) } + private val operations = BlockOperations(blocks) { now } + + @Test + fun typingWithinTheMergeWindowIsOneUndoStep() { + "hello".forEachIndexed { i, _ -> + operations.changeValue(block.id, block.text, "hello".take(i + 1), mergeable = true) + now += 100 + } + + assertTrue(operations.undo()) + assertEquals("", block.text.value) + assertFalse(operations.canUndo()) + } + + @Test + fun aPauseStartsANewUndoStep() { + operations.changeValue(block.id, block.text, "one", mergeable = true) + now += BlockOperations.MERGE_WINDOW_MS + 1 + operations.changeValue(block.id, block.text, "one two", mergeable = true) + + operations.undo() + + assertEquals("one", block.text.value) + } + + @Test + fun editsAfterAnUndoAreNotMergedIntoTheStepBelow() { + operations.changeValue(block.id, block.text, "a", mergeable = true) + operations.addBlock(1, Block.SeparatorBlock()) + operations.undo() + + operations.changeValue(block.id, block.text, "ab", mergeable = true) + operations.undo() + + assertEquals("a", block.text.value) + } + + @Test + fun historyIsCapped() { + repeat(BlockOperations.MAX_HISTORY + 50) { i -> + operations.changeValue(block.id, block.text, "v$i", mergeable = false) + } + + var undone = 0 + while (operations.undo()) undone++ + + assertEquals(BlockOperations.MAX_HISTORY, undone) + } + + @Test + fun contentUndoFollowsTheBlockNotItsPosition() { + val other = Block.TextBlock(MutableStateFlow("other")) + operations.changeValue(block.id, block.text, "mine", mergeable = false) + operations.addBlock(0, other) + + // Undo the insert, then the edit: the edit still lands on its own block. + operations.undo() + operations.undo() + + assertEquals("", block.text.value) + assertEquals("other", other.text.value) + } + + @Test + fun eachWordTypedIsItsOwnUndoStep() { + var text = "" + "one two".forEach { char -> + text += char + operations.changeValue(block.id, block.text, text, mergeable = true) + now += 50 + } + + operations.undo() + assertEquals("one ", block.text.value) + operations.undo() + assertEquals("", block.text.value) + } + + @Test + fun deletingAfterTypingIsItsOwnStep() { + operations.changeValue(block.id, block.text, "abc", mergeable = true) + now += 50 + operations.changeValue(block.id, block.text, "ab", mergeable = true) + + operations.undo() + + assertEquals("abc", block.text.value) + } + + @Test + fun aBatchIsUndoneAndRedoneAsOneStep() { + val added = Block.TextBlock(MutableStateFlow("world")) + operations.changeValue(block.id, block.text, "hello world", mergeable = false) + + operations.batch { + setValue(block.id, block.text, "hello") + addBlock(1, added) + } + assertEquals(listOf("hello", "world"), blocks.map { (it as Block.TextBlock).text.value }) + + operations.undo() + assertEquals(listOf("hello world"), blocks.map { (it as Block.TextBlock).text.value }) + operations.redo() + assertEquals(listOf("hello", "world"), blocks.map { (it as Block.TextBlock).text.value }) + } + + @Test + fun anEmptyBatchRecordsNothing() { + operations.batch { setValue(block.id, block.text, block.text.value) } + + assertFalse(operations.canUndo()) + } + + private fun recordStep(at: Int) { + operations.addBlock(blocks.size, Block.SeparatorBlock()) + operations.attachCaret( + BlockOperations.CaretStep(CaretTarget("b$at", null, 0), CaretTarget("a$at", null, 0)), + ) + } + + @Test + fun clearForgetsTheCaretOfEveryStepAndTheOneLastMoved() { + recordStep(0) + recordStep(1) + operations.undo() + assertEquals(2, operations.caretStepCount()) + + operations.clear() + + assertEquals(0, operations.caretStepCount()) + assertEquals(null, operations.lastMovedCaret()) + } + + @Test + fun aNewEditDropsTheCaretOfTheStepsItMadeUnredoable() { + recordStep(0) + recordStep(1) + operations.undo() + + operations.changeValue(block.id, block.text, "x", mergeable = false) + + assertEquals(1, operations.caretStepCount()) + assertEquals(null, operations.lastMovedCaret()) + } + + @Test + fun aBatchAlsoDropsTheCaretOfTheStepsItMadeUnredoable() { + recordStep(0) + operations.undo() + + operations.batch { addBlock(0, Block.SeparatorBlock()) } + + assertEquals(0, operations.caretStepCount()) + } + + @Test + fun caretStepsStayBoundedOverALongSession() { + repeat(BlockOperations.MAX_HISTORY * 3) { recordStep(it) } + + assertEquals(BlockOperations.MAX_HISTORY, operations.caretStepCount()) + } + + @Test + fun mergingIntoAStepThatHadACaretDoesNotLeaveItsCaretBehind() { + operations.changeValue(block.id, block.text, "a", mergeable = true) + operations.attachCaret( + BlockOperations.CaretStep(CaretTarget(block.id, null, 0), CaretTarget(block.id, null, 1)), + ) + now += 10 + + operations.changeValue(block.id, block.text, "ab", mergeable = true) + + assertEquals(0, operations.caretStepCount()) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsMoreTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsMoreTest.kt new file mode 100644 index 00000000..f605c67b --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsMoreTest.kt @@ -0,0 +1,147 @@ +package com.pasich.encly.editor.state + +import androidx.compose.runtime.snapshots.SnapshotStateList +import com.pasich.encly.editor.codec.MarkdownBlockMapper +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.hasContent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** More of the quieter directions of the state: nullable text, every list kind, glyphs, a link above a paragraph. */ +class BranchGapsMoreTest { + private fun text(value: String = "") = Block.TextBlock(MutableStateFlow(value)) + + private fun editorOf(vararg blocks: Block) = BlockEditorState { 0L }.also { it.load(blocks.toList()) } + + @Test + fun whichBlocksAreStored() { + assertFalse(Block.HBlock(MutableStateFlow(" "), BlockType.H1).hasContent()) + assertTrue(Block.HBlock(MutableStateFlow("h"), BlockType.H1).hasContent()) + assertFalse(Block.QuoteBlock(MutableStateFlow("")).hasContent()) + assertTrue(Block.QuoteBlock(MutableStateFlow("q")).hasContent()) + assertFalse(Block.ListBlock(MutableStateFlow(listOf(ItemListBlock(""))), BlockType.LIST_BULLET).hasContent()) + assertTrue(Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("x"))), BlockType.LIST_BULLET).hasContent()) + assertFalse(Block.LinkBlock().hasContent()) + assertTrue(Block.LinkBlock(MutableStateFlow(LinkDataBlock(url = "https://x.org"))).hasContent()) + assertTrue(Block.SeparatorBlock().hasContent()) + assertFalse(text(" ").hasContent()) + } + + @Test + fun glyphsAndCheckMarksReadTheirState() { + fun checks(markdown: String) = + (MarkdownBlockMapper.toBlocks(markdown).single() as Block.ListBlock).items.value.map { it.isCheck } + assertEquals(listOf(true), checks("☑ done")) + assertEquals(listOf(true), checks("✅ done")) + assertEquals(listOf(false), checks("☐ todo")) + assertEquals(listOf(true), checks("- [X] done")) + assertEquals(listOf(false), checks("- [ ] todo")) + } + + @Test + fun nullableTextHasNoTypingStep() { + val history = SnapshotStateList() + val ops = BlockOperations(history) { 0L } + val field = MutableStateFlow("") + ops.changeValue("a", field, "typed", mergeable = true) + ops.changeValue("a", field, null, mergeable = true) + ops.changeValue("a", field, "again", mergeable = true) + var steps = 0 + while (ops.undo()) steps++ + assertTrue(steps in 1..3) + } + + @Test + fun everyListKindConvertsFromEveryOtherKind() { + val kinds = listOf(BlockType.LIST_CHECK, BlockType.LIST_BULLET, BlockType.LIST_NUMBER) + for (from in kinds) { + for (to in kinds) { + val source = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("a"), ItemListBlock("b"))), from) + val converted = convertBlock(source, to) + if (from == to) { + assertTrue(converted is Block.TextBlock) + } else { + assertEquals(to, (converted as Block.ListBlock).blockType) + } + } + val paragraph = convertBlock(text("a\nb"), from) as Block.ListBlock + assertEquals(2, paragraph.items.value.size) + } + } + + @Test + fun anExactToolOnAHeadingChangesItsLevel() { + val heading = Block.HBlock(MutableStateFlow("h"), BlockType.H1) + assertEquals(BlockType.H3, (convertBlock(heading, BlockType.H3, exact = true) as Block.HBlock).blockType) + assertTrue(convertBlock(heading, BlockType.H1, exact = true) is Block.TextBlock) + assertTrue(convertBlock(text("t"), BlockType.TEXT, exact = true) == null) + assertNotNull(convertBlock(Block.QuoteBlock(MutableStateFlow("q")), BlockType.H2, exact = true)) + } + + @Test + fun aSeparatorToolBeforeAParagraphFocusesThatParagraph() { + val link = Block.LinkBlock(MutableStateFlow(LinkDataBlock(url = "https://x.org"))) + val paragraph = text("after") + val editor = editorOf(link, paragraph) + editor.selection.onInteraction(link.id) + + editor.applyTool(BlockType.SEPARATOR) + + assertEquals(3, editor.blocks.size) + assertTrue(editor.blocks[1] is Block.SeparatorBlock) + assertEquals(paragraph.id, editor.blocks[2].id) + } + + @Test + fun theNearestFocusableBlockAboveSkipsBlocksWithoutAField() { + val blocks = listOf(text("a"), Block.SeparatorBlock(), Block.SeparatorBlock()) + assertEquals(0, blocks.previousFocusableIndex(3)) + assertEquals(0, blocks.previousFocusableIndex(2)) + } + + @Test + fun anItemRememberedForAParagraphIsIgnored() { + val paragraph = text("abc") + val editor = editorOf(paragraph) + editor.selection.onFocused(paragraph.id) + editor.selection.onItemCaret(paragraph.id, "stray", 2) + // Added without moving focus, so the paragraph keeps it when the step is undone. + editor.batch { addBlock(1, text("next")) } + + editor.undo() + + assertEquals(1, editor.blocks.size) + } + + @Test + fun aCursorCanBeCarriedWithoutAnItem() { + val bullets = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("ab"))), BlockType.LIST_BULLET) + val numbers = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("ab"))), BlockType.LIST_NUMBER) + assertNull(carriedCaret(bullets, numbers, 1, null)) + assertNull(carriedCaret(bullets, numbers, 1, "stray")) + assertNotNull(carriedCaret(bullets, bullets, 1, bullets.items.value.single().id)) + } + + @Test + fun aConvertedBlockKeepsTheCursorOfItsSource() { + val paragraph = text("hello") + val editor = editorOf(paragraph) + editor.selection.onCaret(paragraph.id, 3) + editor.replaceBlock(0, Block.QuoteBlock(MutableStateFlow("hello"))) + editor.undo() + editor.redo() + + val plain = editorOf(text("x")) + plain.selection.onCaret(plain.blocks[0].id, 1) + plain.replaceBlock(0, Block.SeparatorBlock()) + assertEquals(1, plain.blocks.size) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsTest.kt new file mode 100644 index 00000000..23fc73db --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsTest.kt @@ -0,0 +1,591 @@ +package com.pasich.encly.editor.state + +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.codec.BlockJson +import com.pasich.encly.editor.codec.InlineHtml +import com.pasich.encly.editor.codec.MarkdownBlockMapper +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.BlockUtils +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.theme.EditorLineSpacing +import com.pasich.encly.editor.theme.frameInset +import com.pasich.encly.editor.theme.isCompact +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The less travelled direction of every condition in the editor's state: out-of-range, missing, equal, empty. */ +@Suppress("LargeClass", "TooManyFunctions") +class BranchGapsTest { + private fun text(value: String = "") = Block.TextBlock(MutableStateFlow(value)) + + private fun list(vararg values: String, type: BlockType = BlockType.LIST_BULLET) = + Block.ListBlock(MutableStateFlow(values.map { ItemListBlock(it) }), type) + + private fun editorOf(vararg blocks: Block) = BlockEditorState { 0L }.also { it.load(blocks.toList()) } + + private fun enter(head: String = "", tail: String = "") = LineBreak(head, listOf("", ""), tail) + + private fun items(editor: BlockEditorState, index: Int) = (editor.blocks[index] as Block.ListBlock).items.value + + // --- model and theme ------------------------------------------------------------------------ + + @Test + fun whichBlocksCountAsEmpty() { + assertTrue(BlockUtils.isBlockEmpty(Block.ListBlock(MutableStateFlow(emptyList()), BlockType.LIST_BULLET))) + assertTrue(BlockUtils.isBlockEmpty(list(""))) + assertFalse(BlockUtils.isBlockEmpty(list("a"))) + assertFalse(BlockUtils.isBlockEmpty(list("", ""))) + assertTrue(BlockUtils.isBlockEmpty(Block.LinkBlock())) + assertFalse(BlockUtils.isBlockEmpty(Block.SeparatorBlock())) + } + + @Test + fun spacingKnowsItsCompactEnd() { + assertFalse(EditorLineSpacing.DEFAULT.isCompact) + assertTrue(EditorLineSpacing.COMPACT.isCompact) + assertEquals(0.dp, EditorLineSpacing.COMPACT.frameInset) + assertEquals(4.dp, EditorLineSpacing.DEFAULT.frameInset) + } + + // --- the undo history ------------------------------------------------------------------------- + + private val history = SnapshotStateList().apply { + add(text("a")) + add(text("b")) + } + private var now = 0L + private val ops = BlockOperations(history) { now } + + private fun undoSteps(): Int { + var steps = 0 + while (ops.undo()) steps++ + return steps + } + + @Test + fun operationsOutsideTheListChangeNothing() { + val extra = text("x") + ops.addBlock(-1, extra) + ops.addBlock(3, extra) + ops.removeBlock(-1) + ops.removeBlock(2) + ops.moveBlock(-1, 0) + ops.moveBlock(2, 0) + ops.moveBlock(0, -1) + ops.moveBlock(0, 2) + ops.replaceBlock(-1, extra) + ops.replaceBlock(2, extra) + ops.batch { + addBlock(-1, extra) + addBlock(3, extra) + removeBlock(-1) + removeBlock(2) + replaceBlock(-1, extra) + replaceBlock(2, extra) + } + + assertEquals(2, history.size) + assertFalse(ops.canUndo()) + } + + @Test + fun anEditToTheSameValueIsNotAStep() { + val field = MutableStateFlow("same") + ops.changeValue("id", field, "same", mergeable = true) + ops.batch { setValue("id", field, "same") } + assertFalse(ops.canUndo()) + } + + @Test + fun valuesThatAreNotTextHaveNoTypingStep() { + val flag = MutableStateFlow(false) + ops.changeValue("id", flag, true, mergeable = true) + ops.changeValue("id", flag, false, mergeable = true) + ops.changeValue( + "id", + MutableStateFlow("a"), + "ab", + mergeable = true, + step = TypingStep("id", TypingKind.INSERT, false), + ) + assertTrue(undoSteps() >= 2) + } + + @Test + fun typingMergesOnlyInTheSameFieldWithinTheWindow() { + val field = MutableStateFlow("") + val other = MutableStateFlow("") + ops.changeValue("a", field, "x", mergeable = true) + ops.changeValue("a", field, "xy", mergeable = true) + assertEquals(1, undoSteps()) + + ops.clear() + ops.changeValue("a", field, "1", mergeable = true) + ops.changeValue("b", field, "12", mergeable = true) + assertEquals(2, undoSteps()) + + ops.clear() + ops.changeValue("a", field, "p", mergeable = true) + ops.changeValue("a", other, "q", mergeable = true) + assertEquals(2, undoSteps()) + + ops.clear() + ops.changeValue("a", field, "r", mergeable = true) + now += BlockOperations.MERGE_WINDOW_MS + 1 + ops.changeValue("a", field, "rs", mergeable = true) + assertEquals(2, undoSteps()) + } + + @Test + fun anEditThatIsNotTypingNeverMergesAndTypingKindsDoNot() { + val field = MutableStateFlow("") + ops.changeValue("a", field, "one", mergeable = false) + ops.changeValue("a", field, "one two", mergeable = true) + assertEquals(2, undoSteps()) + + ops.clear() + ops.changeValue("a", field, "abc", mergeable = true) + ops.changeValue("a", field, "ab", mergeable = true) + assertEquals(2, undoSteps()) + } + + @Test + fun theCursorOfAStepIsRememberedOncePerStep() { + val target = CaretTarget("a", null, 1) + val step = BlockOperations.CaretStep(target, target) + ops.attachCaret(step) + assertNull(ops.lastMovedCaret()) + + ops.addBlock(0, text("new")) + ops.attachCaret(BlockOperations.CaretStep(target, target)) + ops.attachCaret(step) + ops.undo() + assertEquals(step, ops.lastMovedCaret()) + + ops.redo() + ops.addBlock(0, text("another")) + ops.undo() + assertNull(ops.lastMovedCaret()) + } + + // --- the editor state ------------------------------------------------------------------------- + + @Test + fun removingFollowsItsActionsAndKnowsWhatIsThere() { + val editor = editorOf(list("a", "b"), text("x")) + editor.removeBlock(text("unknown"), BlockRemoveAction.REMOVE) + assertEquals(2, editor.blocks.size) + editor.removeBlock(editor.blocks[1], BlockRemoveAction.REMOVE_BACKSPACE) + assertEquals(2, editor.blocks.size) + editor.removeBlock(editor.blocks[0], BlockRemoveAction.REMOVE_BACKSPACE_LIST) + assertEquals(1, editor.blocks.size) + + val single = editorOf(text()) + single.removeBlock(single.blocks[0], BlockRemoveAction.REMOVE_BACKSPACE) + assertEquals(1, single.blocks.size) + } + + @Test + fun replacingAndMovingOutsideTheNoteDoNothing() { + val editor = editorOf(text("a"), text("b")) + assertFalse(editor.replaceBlock(-1, text())) + assertFalse(editor.replaceBlock(2, text())) + editor.moveBlock(-1, 0) + editor.moveBlock(2, 0) + editor.moveBlock(0, -1) + editor.moveBlock(0, 2) + assertEquals(listOf("a", "b"), editor.blocks.map { (it as Block.TextBlock).text.value }) + } + + @Test + fun addingReplacesOnlyAnEmptyFirstParagraph() { + val empty = editorOf(text()) + empty.addBlock(list("")) + assertEquals(1, empty.blocks.size) + + val filled = editorOf(text("x")) + filled.addBlock(list("")) + assertEquals(2, filled.blocks.size) + + val separator = editorOf(Block.SeparatorBlock()) + separator.addBlock(list("")) + assertEquals(2, separator.blocks.size) + + val second = editorOf(text(), text()) + second.selection.onInteraction(second.blocks[1].id) + second.addBlock(list("")) + assertEquals(3, second.blocks.size) + } + + @Test + fun undoAfterTheBlockLeftTheNoteKeepsTheFocusAbove() { + val first = text("a") + val second = text() + val editor = editorOf(first, second) + editor.selection.onFocused(second.id) + editor.changeValue(second.id, second.text, "hello", mergeable = false) + (editor.blocks as MutableList).remove(second) + + editor.undo() + + assertEquals(first.id, editor.blocks.single().id) + } + + @Test + fun undoWhereTheFieldCanNoLongerTakeFocusFallsBack() { + val checks = list("a", type = BlockType.LIST_CHECK) + val editor = editorOf(text("t"), checks) + editor.selection.onFocused(checks.id) + editor.changeValue(checks.id, checks.items, checks.items.value.map { it.copy(value = "ab") }, mergeable = false) + editor.undo() + checks.locked.value = true + + editor.redo() + + assertEquals("ab", checks.items.value.single().value) + } + + @Test + fun undoOfAStepWithoutTextKeepsTheCursorWhereItWas() { + val checks = list("a", "b", type = BlockType.LIST_CHECK) + val editor = editorOf(checks) + val second = checks.items.value[1] + editor.selection.onItemCaret(checks.id, second.id, 1) + editor.selection.onFocused(checks.id) + editor.changeValue( + checks.id, + checks.items, + checks.items.value.map { + it.copy(isCheck = true) + }, + mergeable = false, + ) + editor.undo() + editor.redo() + + editor.selection.onItemCaret(checks.id, "ghost", 1) + editor.undo() + + val paragraph = text("p") + val plain = editorOf(paragraph) + plain.selection.onFocused(paragraph.id) + plain.selection.onCaret(paragraph.id, 1) + plain.batch { addBlock(1, text("next")) } + plain.undo() + assertEquals(1, plain.blocks.size) + } + + // --- Smart Paste and text diffs --------------------------------------------------------------- + + @Test + fun diffsFallBackToTheSmallestRangeWhenTheCursorDoesNotFit() { + assertEquals(TextDiff(4, "", "XY"), textDiff("abcd", "abcdXY", cursor = 1)) + assertEquals(TextDiff(2, "", "c"), textDiff("ab", "abc", cursor = 10)) + assertEquals(TextDiff(2, "c", ""), textDiff("abc", "ab", cursor = 2)) + assertNotNull(textDiff("abc", "zbXYc", cursor = 4)) + assertNotNull(textDiff("abc", "aXbcY", cursor = 2)) + } + + @Test + fun aLineBreakNeedsTwoLines() { + assertFailsWith { LineBreak("", listOf("x"), "") } + } + + @Test + fun pastedLinesAreReadWithOrWithoutTextAroundThem() { + val lines = listOf("word", "") + for ((head, tail) in listOf("" to "", "" to "t", "h" to "")) { + val paragraph = text(head + tail) + val editor = editorOf(paragraph) + editor.breakLine(paragraph.id, LineBreak(head, lines, tail)) + assertTrue(editor.blocks.isNotEmpty()) + } + } + + @Test + fun theCommandPickerIgnoresWhatHasNothingToMove() { + val picker = SlashPicker() + picker.commit() + picker.moveHighlight(1) + assertNull(picker.menu.value) + + picker.setLabels(mapOf(SlashCommand.TEXT to "Zorp")) + picker.open("p") + picker.onText("p", "/zorp") + assertEquals(listOf(SlashCommand.TEXT), picker.menu.value?.matches) + picker.setLabels(mapOf(SlashCommand.TEXT to "Text")) + assertTrue(picker.menu.value?.matches.orEmpty().isEmpty()) + picker.moveHighlight(1) + assertEquals(0, picker.menu.value?.highlighted) + } + + @Test + fun aTemplateFocusesTheFirstEmptyFieldOrTheLastBlock() { + val heading = { Block.HBlock(MutableStateFlow("h"), BlockType.H2) } + val filledOnly = editorOf(text()) + assertTrue(filledOnly.applyTemplate(listOf(heading(), text("filled")))) + assertEquals(1, filledOnly.selection.focusedIndex) + + val emptyText = editorOf(text()) + emptyText.applyTemplate(listOf(heading(), text())) + assertEquals(1, emptyText.selection.focusedIndex) + + val filledList = editorOf(text()) + filledList.applyTemplate(listOf(heading(), list("x"))) + assertEquals(1, filledList.selection.focusedIndex) + + val noItems = editorOf(text()) + noItems.applyTemplate(listOf(heading(), Block.ListBlock(MutableStateFlow(emptyList()), BlockType.LIST_BULLET))) + assertEquals(1, noItems.selection.focusedIndex) + + assertFalse(editorOf(text("x")).isBlankNote()) + assertFalse(editorOf(text(), text()).isBlankNote()) + assertFalse(editorOf(Block.SeparatorBlock()).isBlankNote()) + } + + // --- tools ------------------------------------------------------------------------------------ + + @Test + fun theKeyboardReturnsToTheCursorOfAParagraphButNotOfAList() { + val paragraph = text("abc") + val editor = editorOf(paragraph) + editor.focusWorkingBlock() + editor.selection.onCaret(paragraph.id, 2) + editor.focusWorkingBlock() + + val bullets = list("a") + val withList = editorOf(bullets) + withList.selection.onFocused(bullets.id) + withList.focusWorkingBlock() + assertEquals(0, withList.selection.focusedIndex) + } + + @Test + fun onlyAChecklistCanBeLocked() { + val bullets = list("a") + val paragraph = text("p") + val checks = list("c", type = BlockType.LIST_CHECK) + val editor = editorOf(bullets, paragraph, checks) + assertFalse(editor.toggleChecklistLock(bullets.id)) + assertFalse(editor.toggleChecklistLock(paragraph.id)) + assertFalse(editor.toggleChecklistLock("unknown")) + assertTrue(editor.toggleChecklistLock(checks.id)) + } + + @Test + fun aToolOnALockedChecklistChangesNothing() { + val checks = list("c", type = BlockType.LIST_CHECK) + val editor = editorOf(checks) + editor.toggleChecklistLock(checks.id) + editor.selection.onInteraction(checks.id) + editor.applyTool(BlockType.QUOTE) + assertTrue(editor.blocks.single() is Block.ListBlock) + + val plain = editorOf(list("b"), text("p")) + plain.selection.onInteraction(plain.blocks[1].id) + plain.applyTool(BlockType.SEPARATOR) + assertTrue(plain.blocks.any { it is Block.SeparatorBlock }) + plain.selection.onInteraction(plain.blocks[0].id) + plain.applyTool(BlockType.SEPARATOR) + assertTrue(plain.blocks.count { it is Block.SeparatorBlock } == 2) + } + + @Test + fun everyToolConvertsATextBlock() { + for (tool in BlockType.entries) { + val paragraph = text("x\ny") + val converted = convertBlock(paragraph, tool) + if (tool in listOf(BlockType.TEXT, BlockType.SEPARATOR, BlockType.LINK, BlockType.TABLE)) { + assertNull(converted, tool.name) + } else { + assertNotNull(converted, tool.name) + } + convertBlock(Block.HBlock(MutableStateFlow("h"), BlockType.H3), tool) + convertBlock(list("a", "b"), tool) + convertBlock(Block.QuoteBlock(MutableStateFlow("")), tool) + } + assertTrue(convertBlock(Block.QuoteBlock(MutableStateFlow("")), BlockType.LINK) == null) + } + + @Test + fun theToolbarKnowsItsEdges() { + val editor = editorOf(text("a"), text("b")) + editor.selection.onInteraction(editor.blocks[0].id) + assertFalse(editor.canMoveInteracted(up = true)) + editor.moveInteracted(up = true) + assertTrue(editor.canMoveInteracted(up = false)) + editor.selection.onInteraction(editor.blocks[1].id) + assertFalse(editor.canMoveInteracted(up = false)) + editor.moveInteracted(up = false) + editor.moveBlockOf("unknown", up = true) + editor.moveBlockOf(editor.blocks[0].id, up = true) + editor.moveBlockOf(editor.blocks[1].id, up = false) + assertTrue(editor.canRemoveInteracted()) + editor.removeInteracted() + assertFalse(editor.canRemoveInteracted()) + editor.removeInteracted() + editor.duplicateInteracted() + assertFalse(editor.duplicateBlockOf("unknown")) + } + + // --- Enter and Backspace ---------------------------------------------------------------------- + + @Test + fun backspaceAtTheStartOfAHeadingOrAQuoteTurnsItIntoAParagraph() { + val heading = Block.HBlock(MutableStateFlow("h"), BlockType.H1) + val quote = Block.QuoteBlock(MutableStateFlow("q")) + val editor = editorOf(heading, quote) + assertTrue(editor.backspaceAtStart(heading.id)) + assertTrue(editor.backspaceAtStart(quote.id)) + assertTrue(editor.blocks.all { it is Block.TextBlock }) + } + + @Test + fun aParagraphBelowAnEmptyListJoinsIt() { + val empty = Block.ListBlock(MutableStateFlow(emptyList()), BlockType.LIST_BULLET) + val paragraph = text("tail") + val editor = editorOf(empty, paragraph) + + assertTrue(editor.backspaceAtStart(paragraph.id)) + + assertEquals("tail", empty.items.value.single().value) + } + + @Test + fun anAddressIsPastedOnlyIntoAnEmptyParagraphThatIsThere() { + val link = com.pasich.encly.editor.model.LinkDataBlock(url = "https://x.org") + val unknown = editorOf(text()) + assertFalse(unknown.pasteLink("unknown", "https://x.org", link)) + val filled = text("x") + assertFalse(editorOf(filled).pasteLink(filled.id, "https://x.org", link)) + val withNext = text() + val next = text("next") + val editor = editorOf(withNext, next) + assertTrue(editor.pasteLink(withNext.id, "https://x.org", link)) + assertEquals(2, editor.blocks.size) + } + + @Test + fun listEditingKnowsWhatIsNotThere() { + val bullets = list("a", "", "c") + val paragraph = text("p") + val editor = editorOf(bullets, paragraph) + assertNull(editor.listLineBreak(paragraph.id, "x", enter())) + assertNull(editor.listLineBreak(bullets.id, "unknown", enter())) + assertFalse(editor.listBackspaceAtStart(paragraph.id, "x")) + assertFalse(editor.listBackspaceAtStart(bullets.id, "unknown")) + // A middle empty item joins the one above; a non-empty last item does too. + assertTrue(editor.listBackspaceAtStart(bullets.id, bullets.items.value[1].id)) + assertTrue(editor.listBackspaceAtStart(bullets.id, bullets.items.value[1].id)) + assertEquals(listOf("ac"), bullets.items.value.map { it.value }) + } + + @Test + fun aTypingStepIsOnlyOneItemTyped() { + val a = ItemListBlock("a", id = "1") + val b = ItemListBlock("b", id = "2") + assertNull(listTypingStep(listOf(a), listOf(a, b))) + assertNull(listTypingStep(listOf(a), listOf(b))) + assertNull(listTypingStep(listOf(a), listOf(a.copy(isCheck = true, value = "ax")))) + assertNull(listTypingStep(listOf(a, b), listOf(a.copy(value = "x"), b.copy(value = "y")))) + assertNull(listTypingStep(listOf(a), listOf(a))) + assertNotNull(listTypingStep(listOf(a, b), listOf(a, b.copy(value = "by")))) + } + + @Test + fun marksOnPastedLinesAreKeptOnlyForLists() { + assertEquals("plain", listItemText("plain").value) + assertEquals("# heading", listItemText("# heading").value) + assertEquals("item", listItemText("- item").value) + } + + // --- selection, find and the cursor -------------------------------------------------------------- + + @Test + fun focusAndInteractionIgnoreBlocksThatAreNotThere() { + val editor = editorOf(Block.SeparatorBlock(), text("a")) + editor.selection.onInteraction("unknown") + editor.selection.focusAt(7) + editor.selection.focusAt(-1) + assertEquals(1, editor.blocks.previousFocusableIndex(10)) + assertEquals(0, listOf(Block.SeparatorBlock()).previousFocusableIndex(5)) + } + + @Test + fun findRefreshesOnlyWhileOpenWithAQuery() { + val paragraph = text("alpha beta") + val find = NoteFind() + find.refresh(listOf(paragraph)) + find.open() + find.refresh(listOf(paragraph)) + find.search("beta", listOf(paragraph)) + paragraph.text.value = "gamma" + find.refresh(listOf(paragraph)) + assertEquals(-1, find.state.value.current) + paragraph.text.value = "beta beta" + find.refresh(listOf(paragraph)) + assertEquals(0, find.state.value.current) + } + + @Test + fun cursorsFollowJoinsAndLostItems() { + val one = ItemListBlock("ab", id = "1") + val two = ItemListBlock("cd", id = "2") + assertNull(caretTargetOf("b", "same", "same")) + assertNull(caretTargetOf("b", listOf(), "text")) + assertNull(caretTargetOf("b", "text", 3)) + assertNull(caretTargetOf("b", listOf(one), listOf(one, one.copy(value = "x")))) + assertNull(caretTargetOf("b", listOf(one), listOf(two))) + assertNull(caretTargetOf("b", listOf(one), listOf(one))) + assertNotNull(caretTargetOf("b", listOf(one, two), listOf(one.copy(value = "xab")))) + assertNotNull(caretTargetOf("b", listOf(one, two), listOf(one.copy(value = "abcd")))) + assertNotNull(caretTargetOf("b", listOf(one, two), listOf(two))) + val target = caretTargetOf("b", listOf(one), listOf(one.copy(value = "abc"))) + assertEquals(3, target?.caret) + } + + @Test + fun convertedBlocksCarryTheirCursorOnlyWhereItCanGo() { + val paragraph = text("ab") + val quote = Block.QuoteBlock(MutableStateFlow("ab")) + val bullets = list("ab") + assertNotNull(carriedCaret(paragraph, quote, 1, null)) + assertNull(carriedCaret(bullets, bullets, 1, "unknown")) + assertNotNull(carriedCaret(bullets, bullets, 1, bullets.items.value[0].id)) + assertNull(carriedCaret(Block.SeparatorBlock(), Block.SeparatorBlock(), 1, null)) + assertNull(carriedCaret(paragraph, quote, null, null)) + assertNull(carriedCaret(paragraph, Block.SeparatorBlock(), 1, null)) + assertNull(carriedCaret(bullets, Block.SeparatorBlock(), 1, bullets.items.value[0].id)) + } + + // --- codecs ------------------------------------------------------------------------------------- + + @Test + fun missingTextReadsAsEmpty() { + val blocks = BlockJson.decode("""[{"blockType":"H1"},{"blockType":"QUOTE"},{"blockType":"TEXT"}]""") + assertEquals(3, blocks.size) + } + + @Test + fun markdownEdgesProduceNoEmptyBlocks() { + assertEquals(1, MarkdownBlockMapper.toBlocks("```\ncode").size) + assertTrue(MarkdownBlockMapper.toBlocks("```\n```").isEmpty()) + assertTrue(MarkdownBlockMapper.toBlocks("").isEmpty()) + assertTrue(MarkdownBlockMapper.toBlocks("> ").isEmpty()) + assertTrue(MarkdownBlockMapper.toBlocks("- ").isEmpty()) + } + + @Test + fun entitiesOutsideTextAreKept() { + assertEquals("�", InlineHtml.toPlainText("�")) + assertEquals("�", InlineHtml.toPlainText("�")) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ChecklistLockTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ChecklistLockTest.kt new file mode 100644 index 00000000..3fd6fd87 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ChecklistLockTest.kt @@ -0,0 +1,113 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ChecklistLockTest { + private val editor = BlockEditorState { 0L } + + private fun checklist() = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("milk"), ItemListBlock("eggs", isCheck = true))), + blockType = BlockType.LIST_CHECK, + ) + + @Test + fun lockingIsOneUndoStepAndOnlyForChecklists() { + val list = checklist() + val bullets = Block.ListBlock(blockType = BlockType.LIST_BULLET) + editor.load(listOf(list, bullets)) + + assertTrue(editor.toggleChecklistLock(list.id)) + assertTrue(list.locked.value) + editor.undo() + assertFalse(list.locked.value) + editor.redo() + assertTrue(list.locked.value) + + assertFalse(editor.toggleChecklistLock(bullets.id)) + assertFalse(bullets.locked.value) + } + + @Test + fun movingABlockThatCannotTakeFocusLeavesFocusWhereItIs() { + val paragraph = Block.TextBlock(MutableStateFlow("typing")) + val list = checklist().also { it.locked.value = true } + val separator = Block.SeparatorBlock() + editor.load(listOf(paragraph, list, separator)) + editor.selection.onFocused(paragraph.id) + + editor.moveBlock(1, 0) + editor.moveBlock(2, 1) + + assertEquals(paragraph.id, editor.selection.focusedBlockId.value) + // The toolbar still applies where the user last worked: the block just moved. + assertEquals(1, editor.selection.interactedIndex) + editor.selection.onFocusLost(paragraph.id) + assertEquals(null, editor.selection.focusedBlockId.value) + } + + @Test + fun aLockedChecklistIsNotConvertedByTheToolbar() { + val list = checklist() + editor.load(listOf(list)) + editor.selection.onFocused(list.id) + editor.toggleChecklistLock(list.id) + + editor.applyTool(BlockType.LIST_BULLET) + + assertTrue(editor.blocks.single() === list) + assertTrue(list.locked.value) + } + + @Test + fun aLockedChecklistTakesNoFocusButKeepsItsItems() { + val list = checklist() + editor.load(listOf(list)) + + editor.toggleChecklistLock(list.id) + + assertFalse(list.canTakeFocus()) + assertEquals(listOf("milk", "eggs"), list.items.value.map { it.value }) + } + + @Test + fun theLockSurvivesSavingAndAnUnlockedListStoresNoExtraKey() { + val locked = checklist().also { it.locked.value = true } + val open = checklist() + + val json = BlockConverter.blocksToJson(listOf(locked, open)) + val back = BlockConverter.jsonToBlocks(json).filterIsInstance() + + assertEquals(listOf(true, false), back.map { it.locked.value }) + assertEquals(1, Regex("isLocked").findAll(json).count()) + } + + @Test + fun aNoteWrittenBeforeTheLockOpensUnlocked() { + val old = """[{"blockType":"LIST_CHECK","items":[{"value":"milk","isCheck":false}]}]""" + + val list = BlockConverter.jsonToBlocks(old).single() as Block.ListBlock + + assertFalse(list.locked.value) + } + + @Test + fun aDuplicateKeepsTheLockWithoutSharingIt() { + val list = checklist().also { it.locked.value = true } + editor.load(listOf(list)) + + editor.duplicateBlockOf(list.id) + + val copy = editor.blocks[1] as Block.ListBlock + assertTrue(copy.locked.value) + copy.locked.value = false + assertTrue(list.locked.value) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ContentChangesTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ContentChangesTest.kt new file mode 100644 index 00000000..bb2f67e2 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ContentChangesTest.kt @@ -0,0 +1,54 @@ +package com.pasich.encly.editor.state + +import androidx.compose.runtime.snapshots.SnapshotStateList +import com.pasich.encly.editor.codec.ImportBlocks +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** What tells the host a note changed, the default clock, and the builders the importers use. */ +class ContentChangesTest { + @Test + fun everyKindOfBlockReportsItsContentAndAFlatNoteStillReports() = runTest { + val editor = BlockEditorState() + editor.load( + listOf( + Block.TextBlock(), + Block.HBlock(blockType = BlockType.H1), + Block.QuoteBlock(), + Block.LinkBlock(), + Block.ListBlock(blockType = BlockType.LIST_CHECK), + Block.SeparatorBlock(), + Block.CodeBlock(), + Block.TableBlock(), + ), + ) + editor.contentChanges.first() + + editor.load(listOf(Block.SeparatorBlock())) + editor.contentChanges.first() + } + + @Test + fun theDefaultClockTellsTime() { + assertTrue(currentTimeMillis() > 0L) + val operations = BlockOperations(SnapshotStateList()) + assertTrue(!operations.canUndo()) + } + + @Test + fun importersBuildBlocksOfEveryKind() { + assertTrue(ImportBlocks.heading("h", 3) is Block.HBlock) + assertTrue(ImportBlocks.text("t") is Block.TextBlock) + assertTrue(ImportBlocks.quote("q") is Block.QuoteBlock) + val list = ImportBlocks.list(BlockType.LIST_BULLET, listOf(ItemListBlock("a"))) as Block.ListBlock + assertEquals(BlockType.LIST_BULLET, list.blockType) + assertEquals(2, ImportBlocks.paragraphs("one\n\ntwo").size) + assertTrue(ImportBlocks.paragraphs(" ").isEmpty()) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/ConvertCaretTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ConvertCaretTest.kt similarity index 92% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/ConvertCaretTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ConvertCaretTest.kt index d1269031..158e322e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/ConvertCaretTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ConvertCaretTest.kt @@ -1,13 +1,13 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals /** A block tool keeps the cursor where it was: in the same list item, or at the same place in the text. */ class ConvertCaretTest { diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/CopiedBlocksTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/CopiedBlocksTest.kt new file mode 100644 index 00000000..33ff986d --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/CopiedBlocksTest.kt @@ -0,0 +1,202 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TableData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Pasting back what this editor copied or cut: the blocks go in as they were, not the escaped text. */ +class CopiedBlocksTest { + private val editor = BlockEditorState { 0L } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun table() = Block.TableBlock(MutableStateFlow(TableData.of(listOf(listOf("a", "b"), listOf("c|d", "*"))))) + + private fun nested() = Block.ListBlock( + MutableStateFlow( + listOf( + ItemListBlock("one"), + ItemListBlock("two", level = 1), + ItemListBlock("three", level = 2), + ), + ), + BlockType.LIST_BULLET, + ) + + private fun contents() = editor.blocks.map { + when (it) { + is Block.TextBlock -> "P:" + it.text.value + + is Block.TableBlock -> + "T:" + it.table.value.rows.joinToString("/") { r -> r.cells.joinToString(",") { c -> c.value } } + + is Block.ListBlock -> "L:" + it.items.value.joinToString("|") { i -> "${i.level}${i.value}" } + + else -> it::class.simpleName.orEmpty() + } + } + + @Test + fun aPastedCutParagraphKeepsItsCharactersInsteadOfTheEscapes() { + val target = text("") + editor.load(listOf(target)) + editor.rememberCopied("price\\*2", listOf(text("price*2"))) + + val kept = editor.pasteCopied(target.id, null, "", "price\\*2", "") + + assertEquals("price*2", kept) + assertEquals(listOf("P:price*2"), contents()) + } + + @Test + fun theTextGoesInAtTheCursorBetweenWhatWasThereAndAWindowsLineEndingIsTheSameText() { + val target = text("ab") + editor.load(listOf(target)) + editor.rememberCopied("x\\_\ny", listOf(text("x_\ny"))) + + val kept = editor.pasteCopied(target.id, null, "a", "x\\_\r\ny", "b") + + assertEquals("ax_\nyb", kept) + assertEquals(listOf("P:ax_\nyb"), contents()) + } + + @Test + fun aTableAndANestedListComeBackWhole() { + val target = text("") + editor.load(listOf(target)) + val source = listOf(table(), nested()) + editor.rememberCopied("md", source) + + val kept = editor.pasteCopied(target.id, null, "", "md", "") + + assertEquals("", kept) + assertEquals(listOf("T:a,b/c|d,*", "L:0one|1two|2three"), contents().take(2)) + // New blocks: nothing is shared with the originals. + assertTrue(editor.blocks.none { b -> source.any { it.id == b.id } }) + val pasted = (editor.blocks[0] as Block.TableBlock).table.value + val original = (source[0] as Block.TableBlock).table.value + assertTrue(pasted.rows.flatMap { r -> r.cells.map { it.id } }.none { original.cell(it) != null }) + } + + @Test + fun blocksPastedInsideAParagraphSplitItAndTheTextAfterTheCursorGoesOn() { + val target = text("headtail") + editor.load(listOf(target)) + editor.rememberCopied("md", listOf(text("first"), table(), text("last"))) + + val kept = editor.pasteCopied(target.id, null, "head", "md", "tail") + + assertEquals("headfirst", kept) + assertEquals(listOf("P:headfirst", "T:a,b/c|d,*", "P:lasttail"), contents()) + } + + @Test + fun aPasteThatIsNotTheCopiedTextIsLeftToTheNormalPaste() { + val target = text("") + editor.load(listOf(target)) + assertNull(editor.pasteCopied(target.id, null, "", "price\\*2", "")) + + editor.rememberCopied("price\\*2", listOf(text("price*2"))) + assertNull(editor.pasteCopied(target.id, null, "", "price\\*3", "")) + assertEquals(listOf("P:"), contents()) + } + + @Test + fun theLastCopyReplacesTheOneBefore() { + val target = text("") + editor.load(listOf(target)) + editor.rememberCopied("one", listOf(text("1"))) + editor.rememberCopied("two", listOf(text("2"))) + + assertNull(editor.pasteCopied(target.id, null, "", "one", "")) + assertEquals("2", editor.pasteCopied(target.id, null, "", "two", "")) + } + + @Test + fun nothingEmptyIsRemembered() { + val target = text("") + editor.load(listOf(target)) + editor.rememberCopied("", listOf(text("x"))) + assertNull(editor.pasteCopied(target.id, null, "", "", "")) + + editor.rememberCopied("x", emptyList()) + assertNull(editor.pasteCopied(target.id, null, "", "x", "")) + } + + @Test + fun editingTheOriginalAfterTheCopyChangesNothing() { + val original = text("kept") + editor.load(listOf(original, text(""))) + editor.rememberCopied("kept", listOf(original)) + original.text.value = "changed" + + editor.pasteCopied(editor.blocks[1].id, null, "", "kept", "") + + assertEquals(listOf("P:changed", "P:kept"), contents()) + } + + @Test + fun loadingANoteForgetsWhatWasCopied() { + editor.load(listOf(text(""))) + editor.rememberCopied("secret", listOf(text("secret"))) + + editor.load(emptyList()) + + assertNull(editor.pasteCopied(editor.blocks[0].id, null, "", "secret", "")) + } + + @Test + fun aHeadingTakesTheCopiedTextOnOneLine() { + val heading = Block.HBlock(MutableStateFlow(""), BlockType.H2) + editor.load(listOf(heading)) + editor.rememberCopied("a\nb", listOf(text("a\nb"))) + + assertEquals("a b", editor.pasteCopied(heading.id, null, "", "a\nb", "")) + } + + @Test + fun codeAndOtherBlocksTakeNoCopiedPaste() { + val code = Block.CodeBlock(MutableStateFlow("")) + val rule = Block.SeparatorBlock() + editor.load(listOf(code, rule)) + editor.rememberCopied("x\\*", listOf(text("x*"))) + + assertNull(editor.pasteCopied(code.id, null, "", "x\\*", "")) + assertNull(editor.pasteCopied(rule.id, null, "", "x\\*", "")) + assertNull(editor.pasteCopied("gone", null, "", "x\\*", "")) + assertEquals("", code.text.value) + } + + @Test + fun aListItemTakesTheCopiedTextAndAListWithoutThatItemTakesNothing() { + val list = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("ab"))), BlockType.LIST_BULLET) + editor.load(listOf(list)) + editor.rememberCopied("x\\*", listOf(text("x*"))) + val item = list.items.value[0].id + + assertNull(editor.pasteCopied(list.id, null, "a", "x\\*", "b")) + assertNull(editor.pasteCopied(list.id, "gone", "a", "x\\*", "b")) + assertEquals("ax*b", editor.pasteCopied(list.id, item, "a", "x\\*", "b")) + assertEquals(listOf("0ax*b"), list.items.value.map { "${it.level}${it.value}" }) + } + + @Test + fun severalBlocksPastedInAListItemBecomeItems() { + val list = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("ab"))), BlockType.LIST_BULLET) + editor.load(listOf(list)) + editor.rememberCopied("md", listOf(text("first"), text("second"))) + + val kept = editor.pasteCopied(list.id, list.items.value[0].id, "a", "md", "b") + + assertEquals("afirst", kept) + assertEquals(listOf("afirst", "secondb"), list.items.value.map { it.value }) + assertNotEquals(list.items.value[0].id, list.items.value[1].id) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/EditorFeatureFixesTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/EditorFeatureFixesTest.kt new file mode 100644 index 00000000..a975a442 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/EditorFeatureFixesTest.kt @@ -0,0 +1,266 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TableData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A locked checklist is out of reach of every edit that crosses blocks, and a selection that ends inside a table + * leaves the table alone unless it covers it whole. + */ +class EditorFeatureFixesTest { + private val editor = BlockEditorState { 0L } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun checklist(vararg items: ItemListBlock, locked: Boolean = true) = + Block.ListBlock(MutableStateFlow(items.toList()), BlockType.LIST_CHECK, locked = MutableStateFlow(locked)) + + private fun grid(vararg rows: List) = Block.TableBlock(MutableStateFlow(TableData.of(rows.toList()))) + + private fun cell(block: Block.TableBlock, row: Int, column: Int) = block.table.value.rows[row].cells[column].id + + private fun texts() = editor.blocks.map { + when (it) { + is Block.TextBlock -> it.text.value + is Block.TableBlock -> "table" + is Block.ListBlock -> it.items.value.joinToString("|") { item -> item.value } + else -> it::class.simpleName.orEmpty() + } + } + + // --- a locked checklist ------------------------------------------------------------------------------ + + @Test + fun aSelectionOfTheWholeNoteChangesNothingWhileAChecklistIsLocked() { + val list = checklist(ItemListBlock("one"), ItemListBlock("two")) + editor.load(listOf(list, text("tail"))) + editor.textRange.selectAll() + + assertFalse(editor.canReplaceTextRange()) + assertFalse(editor.replaceTextRange("x")) + assertEquals(listOf("one|two", "tail"), texts()) + assertTrue(editor.textRange.isActive) + } + + @Test + fun aLockedChecklistInTheMiddleOrAtTheEndOfARangeIsNotChangedEither() { + val first = text("start") + val list = checklist(ItemListBlock("one"), ItemListBlock("two")) + val last = text("end") + editor.load(listOf(first, list, last)) + + editor.textRange.select(TextPoint(first.id, 2), TextPoint(last.id, 1)) + assertFalse(editor.replaceTextRange("")) + + editor.textRange.select(TextPoint(first.id, 2), TextPoint(list.id, 1, list.items.value[1].id)) + assertFalse(editor.replaceTextRange("")) + assertEquals(listOf("start", "one|two", "end"), texts()) + } + + @Test + fun unlockedTheSameSelectionIsReplaced() { + val first = text("start") + val list = checklist(ItemListBlock("one"), ItemListBlock("two"), locked = false) + editor.load(listOf(first, list)) + editor.textRange.select(TextPoint(first.id, 2), TextPoint(list.id, 1, list.items.value[1].id)) + + assertTrue(editor.canReplaceTextRange()) + assertTrue(editor.replaceTextRange("")) + } + + @Test + fun withoutASelectionThereIsNothingToReplace() { + editor.load(listOf(text("a"))) + + assertFalse(editor.canReplaceTextRange()) + assertFalse(editor.replaceTextRange("x")) + } + + @Test + fun aLockedChecklistCannotBeIndentedMovedOrChangedToAnotherKind() { + val items = listOf(ItemListBlock("one"), ItemListBlock("two"), ItemListBlock("three", level = 1)) + val list = checklist(*items.toTypedArray()) + editor.load(listOf(list)) + val two = list.items.value[1].id + val three = list.items.value[2].id + + assertFalse(editor.canShiftListItem(list.id, two, deeper = true)) + assertFalse(editor.shiftListItem(list.id, two, deeper = true)) + assertFalse(editor.canMoveListItem(list.id, two, -1)) + assertFalse(editor.moveListItem(list.id, two, -1)) + assertFalse(editor.setNestedListKind(list.id, three, BlockType.LIST_BULLET)) + assertEquals(items.map { it.copy(id = "") }, list.items.value.map { it.copy(id = "") }) + + list.locked.value = false + assertTrue(editor.canShiftListItem(list.id, two, deeper = true)) + assertTrue(editor.moveListItem(list.id, two, -1)) + assertTrue(editor.shiftListItem(list.id, three, deeper = false)) + } + + // --- a selection that ends in a table ---------------------------------------------------------------- + + @Test + fun aDragFromACellIntoTheParagraphBelowReplacesOnlyWhatIsOutsideTheTable() { + val table = grid(listOf("a", "b"), listOf("c", "d")) + val below = text("below text") + editor.load(listOf(table, below)) + editor.textRange.select(TextPoint(table.id, 1, cell(table, 1, 0)), TextPoint(below.id, 6)) + + assertTrue(editor.textRange.isCrossField) + assertTrue(editor.replaceTextRange("x")) + assertEquals(listOf("table", "xtext"), texts()) + } + + @Test + fun aDragFromAParagraphIntoACellStopsAtTheTable() { + val above = text("above text") + val table = grid(listOf("a", "b"), listOf("c", "d")) + editor.load(listOf(above, table)) + editor.textRange.select(TextPoint(above.id, 6), TextPoint(table.id, 1, cell(table, 1, 0))) + + assertTrue(editor.textRange.isCrossField) + assertEquals("text", editor.textRangeBlocks().single().let { (it as Block.TextBlock).text.value }) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf("above ", "table"), texts()) + } + + @Test + fun aTableIsTakenWholeWhenTheSelectionCoversItFromItsFirstCellOrToItsLast() { + // To the very end of the last cell. + val above = text("above") + val table = grid(listOf("a", "b"), listOf("c", "dd")) + editor.load(listOf(above, table, text("below"))) + editor.textRange.select(TextPoint(above.id, 2), TextPoint(table.id, 2, cell(table, 1, 1))) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf("ab", "below"), texts()) + + // From the start of the first cell, named or not. + val named = grid(listOf("a", "b")) + val after = text("after") + editor.load(listOf(named, after)) + editor.textRange.select(TextPoint(named.id, 0, cell(named, 0, 0)), TextPoint(after.id, 2)) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf("ter"), texts()) + + val unnamed = grid(listOf("a", "b")) + val last = text("last") + editor.load(listOf(unnamed, last)) + editor.textRange.select(TextPoint(unnamed.id, 0), TextPoint(last.id, 2)) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf("st"), texts()) + + // Select all covers a table that is the whole note. + val only = grid(listOf("a", "bb")) + editor.load(listOf(only)) + editor.textRange.selectAll() + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf(""), texts()) + } + + private class Scene(val above: Block.TextBlock, val table: Block.TableBlock, val below: Block.TextBlock) + + // A note of a paragraph, a table and a paragraph, replaced from [from] to [to] with nothing. + private fun cut(from: (Scene) -> TextPoint, to: (Scene) -> TextPoint): List { + val scene = Scene(text("above"), grid(listOf("a", "bb"), listOf("c", "dd")), text("below")) + editor.load(listOf(scene.above, scene.table, scene.below)) + editor.textRange.select(from(scene), to(scene)) + assertTrue(editor.replaceTextRange("")) + return texts() + } + + @Test + fun aTableCoveredOnlyInPartIsLeftOutWhicheverEdgeOrCellThePointIsIn() { + // The end is in the last cell but not at its end, or in another cell. + val kept = listOf("ab", "table", "below") + assertEquals( + kept, + cut({ TextPoint(it.above.id, 2) }, { TextPoint(it.table.id, 1, cell(it.table, 1, 1)) }), + ) + assertEquals( + kept, + cut({ TextPoint(it.above.id, 2) }, { TextPoint(it.table.id, 2, cell(it.table, 1, 0)) }), + ) + + // The start is not at the start of the first cell, or is in another cell. + val rest = listOf("above", "table", "low") + assertEquals( + rest, + cut({ TextPoint(it.table.id, 1, cell(it.table, 0, 0)) }, { TextPoint(it.below.id, 2) }), + ) + assertEquals( + rest, + cut({ TextPoint(it.table.id, 0, cell(it.table, 0, 1)) }, { TextPoint(it.below.id, 2) }), + ) + } + + @Test + fun aSelectionBetweenTwoTablesIsLeftWithWhatTheyCover() { + val table = grid(listOf("a", "b")) + val other = grid(listOf("c", "d")) + editor.load(listOf(table, other)) + + // Both ends in the middle of tables: nothing is left of the selection, which still counts as across fields. + editor.textRange.select(TextPoint(table.id, 1, cell(table, 0, 0)), TextPoint(other.id, 0, cell(other, 0, 1))) + assertTrue(editor.textRange.isCrossField) + assertFalse(editor.canReplaceTextRange()) + assertFalse(editor.replaceTextRange("x")) + + // The first table is covered in part, the second to its end: the second goes, the first stays. + editor.textRange.select(TextPoint(table.id, 1, cell(table, 0, 0)), TextPoint(other.id, 1, cell(other, 0, 1))) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf("table", ""), texts()) + } + + // --- the cursor goes back to the cell it was in ------------------------------------------------------ + + @Test + fun theKeyboardComesBackToTheCellTheCursorWasIn() { + val table = grid(listOf("ab", "cd"), listOf("ef", "gh")) + editor.load(listOf(table)) + var request: FocusRequest? = null + editor.selection.immediateFocus = { request = it } + + editor.selection.onItemCaret(table.id, cell(table, 1, 0), 1) + editor.focusWorkingBlock() + assertEquals(cell(table, 1, 0), request?.itemId) + assertEquals(1, request?.caret) + + // A cell that has gone is not asked for. + table.table.value = table.table.value.removeRow(1) + editor.focusWorkingBlock() + assertNull(request?.itemId) + } + + @Test + fun theKeyboardComesBackToATableThatNeverHadACursorWithoutACell() { + val table = grid(listOf("ab", "cd")) + editor.load(listOf(table)) + var request: FocusRequest? = null + editor.selection.immediateFocus = { request = it } + + editor.focusWorkingBlock() + + assertNull(request?.itemId) + assertTrue(request?.cursorToEnd == true) + } + + @Test + fun theKeyboardComesBackToAParagraphWithoutAnyCell() { + val paragraph = text("abc") + editor.load(listOf(paragraph)) + var request: FocusRequest? = null + editor.selection.immediateFocus = { request = it } + + editor.focusWorkingBlock() + + assertNull(request?.itemId) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ListNestingTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ListNestingTest.kt new file mode 100644 index 00000000..92739e66 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ListNestingTest.kt @@ -0,0 +1,456 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Indenting, outdenting and moving list items, and what writing in a nested list does; each is one undo step. */ +class ListNestingTest { + private val editor = BlockEditorState { 0L } + private val enter = LineBreak("", listOf("", ""), "") + + private fun enterAt(text: String, cursor: Int) = LineBreak(text.take(cursor), listOf("", ""), text.drop(cursor)) + + private fun row(text: String, level: Int = 0, kind: BlockType? = null, check: Boolean = false) = + ItemListBlock(text, isCheck = check, level = level, kind = kind) + + private fun list(type: BlockType = BlockType.LIST_BULLET, vararg rows: ItemListBlock) = + Block.ListBlock(MutableStateFlow(rows.toList()), type) + + private fun Block.ListBlock.shape() = items.value.joinToString(" ") { "${it.value}${it.level}" } + + private fun Block.ListBlock.id(text: String) = items.value.first { it.value == text }.id + + private fun Block.ListBlock.at(text: String) = items.value.first { it.value == text } + + // a0 b1 c2 d1 e0 + private fun tree(type: BlockType = BlockType.LIST_BULLET) = + list(type, row("a"), row("b", 1), row("c", 2), row("d", 1), row("e")) + + // --- indent and outdent ----------------------------------------------------------------------- + + @Test + fun anItemIsIndentedUnderTheOneAboveAsOneUndoStep() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b"), row("c")) + editor.load(listOf(list)) + + assertTrue(editor.shiftListItem(list.id, list.id("b"), deeper = true)) + + assertEquals("a0 b1 c0", list.shape()) + editor.undo() + assertEquals("a0 b0 c0", list.shape()) + assertFalse(editor.canUndo.value) + editor.redo() + assertEquals("a0 b1 c0", list.shape()) + } + + @Test + fun theFirstItemCannotBeIndentedAndNothingIsRecorded() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b")) + editor.load(listOf(list)) + + assertFalse(editor.canShiftListItem(list.id, list.id("a"), deeper = true)) + assertFalse(editor.shiftListItem(list.id, list.id("a"), deeper = true)) + + assertEquals("a0 b0", list.shape()) + assertFalse(editor.canUndo.value) + } + + @Test + fun anItemIsIndentedAtMostThreeLevelsDeepAndOneDeeperThanTheItemAbove() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b"), row("c"), row("d")) + editor.load(listOf(list)) + + assertTrue(editor.shiftListItem(list.id, list.id("b"), deeper = true)) + assertTrue(editor.shiftListItem(list.id, list.id("c"), deeper = true)) + assertTrue(editor.shiftListItem(list.id, list.id("c"), deeper = true)) + // c is now two levels in; a third level is not offered, and d may only follow c to level 2. + assertFalse(editor.shiftListItem(list.id, list.id("c"), deeper = true)) + assertTrue(editor.shiftListItem(list.id, list.id("d"), deeper = true)) + assertTrue(editor.shiftListItem(list.id, list.id("d"), deeper = true)) + assertFalse(editor.shiftListItem(list.id, list.id("d"), deeper = true)) + + assertEquals("a0 b1 c2 d2", list.shape()) + } + + @Test + fun anItemMovesWithItsChildren() { + val list = tree() + editor.load(listOf(list)) + + editor.shiftListItem(list.id, list.id("e"), deeper = true) + editor.shiftListItem(list.id, list.id("b"), deeper = false) + + assertEquals("a0 b0 c1 d1 e1", list.shape()) + } + + @Test + fun anItemAtTheTopCannotComeOut() { + val list = tree() + editor.load(listOf(list)) + + assertFalse(editor.canShiftListItem(list.id, list.id("a"), deeper = false)) + assertFalse(editor.shiftListItem(list.id, list.id("a"), deeper = false)) + assertEquals("a0 b1 c2 d1 e0", list.shape()) + } + + @Test + fun anUnknownListOrItemChangesNothing() { + val list = tree() + editor.load(listOf(list)) + + assertFalse(editor.shiftListItem("nope", list.id("b"), deeper = false)) + assertFalse(editor.shiftListItem(list.id, "nope", deeper = false)) + assertFalse(editor.moveListItem(list.id, "nope", 1)) + assertFalse(editor.setNestedListKind(list.id, "nope", BlockType.LIST_CHECK)) + assertFalse(editor.canMoveListItem(list.id, "nope", 1)) + assertFalse(editor.canShiftListItem(list.id, "nope", deeper = true)) + assertFalse(editor.canUndo.value) + } + + @Test + fun theFocusedItemKeepsItsFocusAndCursorWhenItIsShifted() { + val list = list(BlockType.LIST_BULLET, row("a"), row("hello")) + editor.load(listOf(list)) + val item = list.id("hello") + editor.selection.onFocused(list.id) + editor.selection.onItemCaret(list.id, item, 3) + + editor.shiftListItem(list.id, item, deeper = true) + + assertEquals(item, editor.selection.itemOf(list.id)) + assertEquals(3, editor.selection.caretOf(list.id)) + } + + @Test + fun anItemThatIsNotBeingWrittenInIsShiftedWithoutTakingFocus() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b")) + editor.load(listOf(list)) + + editor.shiftListItem(list.id, list.id("b"), deeper = true) + + assertNull(editor.selection.focusedBlockId.value) + assertNull(editor.selection.itemOf(list.id)) + } + + @Test + fun undoingAShiftPutsTheCursorBackInTheItem() { + val list = list(BlockType.LIST_BULLET, row("a"), row("hello")) + editor.load(listOf(list)) + val item = list.id("hello") + editor.selection.onFocused(list.id) + editor.selection.onItemCaret(list.id, item, 2) + editor.shiftListItem(list.id, item, deeper = true) + + editor.undo() + + assertEquals(item, editor.selection.itemOf(list.id)) + assertEquals("a0 hello0", list.shape()) + } + + // --- moving ----------------------------------------------------------------------------------- + + @Test + fun anItemMovesDownAndUpPastItsSiblingsAsOneUndoStepEach() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b"), row("c")) + editor.load(listOf(list)) + + assertTrue(editor.canMoveListItem(list.id, list.id("a"), 1)) + assertTrue(editor.moveListItem(list.id, list.id("a"), 1)) + assertEquals(listOf("b", "a", "c"), list.items.value.map { it.value }) + assertTrue(editor.moveListItem(list.id, list.id("c"), -2)) + assertEquals(listOf("c", "b", "a"), list.items.value.map { it.value }) + + editor.undo() + assertEquals(listOf("b", "a", "c"), list.items.value.map { it.value }) + editor.undo() + assertEquals(listOf("a", "b", "c"), list.items.value.map { it.value }) + assertFalse(editor.canUndo.value) + editor.redo() + editor.redo() + assertEquals(listOf("c", "b", "a"), list.items.value.map { it.value }) + } + + @Test + fun aMovedItemTakesItsChildrenAlong() { + val list = tree() + editor.load(listOf(list)) + + assertTrue(editor.moveListItem(list.id, list.id("a"), 1)) + + assertEquals("e0 a0 b1 c2 d1", list.shape()) + editor.undo() + assertEquals("a0 b1 c2 d1 e0", list.shape()) + } + + @Test + fun anItemCannotMoveOutOfItsParentOrPastTheEnds() { + val list = tree() + editor.load(listOf(list)) + + assertFalse(editor.canMoveListItem(list.id, list.id("a"), -1)) + assertFalse(editor.moveListItem(list.id, list.id("a"), -1)) + assertFalse(editor.moveListItem(list.id, list.id("e"), 1)) + // c is the only child of b. + assertFalse(editor.moveListItem(list.id, list.id("c"), 1)) + assertFalse(editor.moveListItem(list.id, list.id("c"), -1)) + assertFalse(editor.moveListItem(list.id, list.id("b"), 0)) + assertFalse(editor.canUndo.value) + } + + @Test + fun theIdsOfMovedItemsAreKeptSoTheirFieldsFollowThem() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b")) + editor.load(listOf(list)) + val ids = list.items.value.map { it.id } + + editor.moveListItem(list.id, ids[0], 1) + + assertEquals(listOf(ids[1], ids[0]), list.items.value.map { it.id }) + } + + // --- Enter ------------------------------------------------------------------------------------ + + @Test + fun enterInANestedItemStartsAnItemAtTheSameLevel() { + val list = tree() + editor.load(listOf(list)) + + editor.listLineBreak(list.id, list.id("b"), enterAt("b", 1)) + + assertEquals(listOf("a", "b", "", "c", "d", "e"), list.items.value.map { it.value }) + assertEquals(listOf(0, 1, 1, 2, 1, 0), list.items.value.map { it.level }) + } + + @Test + fun enterInTheMiddleOfANestedItemKeepsTheLevelOfBothHalves() { + val list = list(BlockType.LIST_BULLET, row("a"), row("hello", 1, BlockType.LIST_CHECK)) + editor.load(listOf(list)) + + editor.listLineBreak(list.id, list.id("hello"), enterAt("hello", 2)) + + assertEquals(listOf("a", "he", "llo"), list.items.value.map { it.value }) + assertEquals(listOf(0, 1, 1), list.items.value.map { it.level }) + assertEquals(listOf(null, BlockType.LIST_CHECK, BlockType.LIST_CHECK), list.items.value.map { it.kind }) + } + + @Test + fun enterAtTheStartOfANestedItemOpensAnItemAboveAtTheSameLevel() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b", 1)) + editor.load(listOf(list)) + + editor.listLineBreak(list.id, list.id("b"), enterAt("b", 0)) + + assertEquals(listOf("a", "", "b"), list.items.value.map { it.value }) + assertEquals(listOf(0, 1, 1), list.items.value.map { it.level }) + } + + @Test + fun enterInAnEmptyNestedItemBringsItOutOneLevelInsteadOfLeavingTheList() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b", 1), row("", 2)) + editor.load(listOf(list)) + + editor.listLineBreak(list.id, list.items.value[2].id, enter) + assertEquals(listOf(0, 1, 1), list.items.value.map { it.level }) + editor.listLineBreak(list.id, list.items.value[2].id, enter) + assertEquals(listOf(0, 1, 0), list.items.value.map { it.level }) + assertEquals(1, editor.blocks.size) + editor.undo() + assertEquals(listOf(0, 1, 1), list.items.value.map { it.level }) + } + + @Test + fun enterInAnEmptyItemAtTheTopStillLeavesTheListAndTheRestKeepsItsShape() { + val list = list(BlockType.LIST_BULLET, row("a"), row(""), row("b", 1), row("c", 2), row("d")) + editor.load(listOf(list)) + + editor.listLineBreak(list.id, list.items.value[1].id, enter) + + assertEquals(3, editor.blocks.size) + assertEquals("a0", (editor.blocks[0] as Block.ListBlock).shape()) + // The list that continues starts at the top: b was a child of the item that left. + assertEquals("b0 c1 d0", (editor.blocks[2] as Block.ListBlock).shape()) + } + + // --- Backspace -------------------------------------------------------------------------------- + + @Test + fun backspaceAtTheStartOfANestedItemBringsItOutBeforeAnythingIsJoined() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b", 1), row("c", 2)) + editor.load(listOf(list)) + + assertTrue(editor.listBackspaceAtStart(list.id, list.id("b"))) + assertEquals("a0 b0 c1", list.shape()) + assertEquals(listOf("a", "b", "c"), list.items.value.map { it.value }) + + // Now at the top, the next Backspace joins it to the item above. + assertTrue(editor.listBackspaceAtStart(list.id, list.id("b"))) + assertEquals(listOf("ab", "c"), list.items.value.map { it.value }) + assertEquals("ab0 c1", list.shape()) + } + + @Test + fun backspaceInANestedItemIsOneUndoStep() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b", 1)) + editor.load(listOf(list)) + + editor.listBackspaceAtStart(list.id, list.id("b")) + editor.undo() + + assertEquals("a0 b1", list.shape()) + assertFalse(editor.canUndo.value) + } + + @Test + fun anItemThatJoinsTheOneAboveTakesItsPlaceAndLevel() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b", 1), row("c", 2), row("d")) + editor.load(listOf(list)) + + editor.listBackspaceAtStart(list.id, list.id("d")) + + assertEquals(listOf("cd"), list.items.value.map { it.value }.drop(2)) + assertEquals("a0 b1 cd2", list.shape()) + } + + @Test + fun theFirstItemStillLeavesTheListAsAParagraphAndItsChildrenStartTheListAtTheTop() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b", 1), row("c", 2)) + editor.load(listOf(list)) + + editor.listBackspaceAtStart(list.id, list.id("a")) + + assertTrue(editor.blocks[0] is Block.TextBlock) + assertEquals("b0 c1", (editor.blocks[1] as Block.ListBlock).shape()) + } + + // --- paste ------------------------------------------------------------------------------------ + + @Test + fun pastedLinesJoinTheListAtTheLevelOfTheItemTheyAreIn() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b", 1)) + editor.load(listOf(list)) + + editor.listLineBreak(list.id, list.id("b"), LineBreak("b", listOf("", "- x", "- y"), "")) + + assertEquals("a0 b1 x1 y1", list.shape()) + } + + // --- kind of a nested list -------------------------------------------------------------------- + + @Test + fun aNestedItemAndItsSiblingsCanBecomeAListOfAnotherKindAsOneUndoStep() { + val list = tree() + editor.load(listOf(list)) + + assertTrue(editor.setNestedListKind(list.id, list.id("b"), BlockType.LIST_CHECK)) + + val check = BlockType.LIST_CHECK + assertEquals(listOf(null, check, null, check, null), list.items.value.map { it.kind }) + editor.undo() + assertEquals(listOf(null, null, null, null, null), list.items.value.map { it.kind }) + } + + @Test + fun theKindOfATopItemIsTheListsAndAnythingButAListKindChangesNothing() { + val list = tree() + editor.load(listOf(list)) + + assertFalse(editor.setNestedListKind(list.id, list.id("a"), BlockType.LIST_CHECK)) + assertFalse(editor.setNestedListKind(list.id, list.id("b"), BlockType.QUOTE)) + assertFalse(editor.setNestedListKind(list.id, list.id("b"), BlockType.LIST_BULLET)) + assertFalse(editor.canUndo.value) + } + + @Test + fun aListToolSetsTheKindOfTheNestedItemTheUserIsInAndNotOfTheWholeList() { + val list = tree() + editor.load(listOf(list)) + editor.selection.onFocused(list.id) + editor.selection.onItemCaret(list.id, list.id("d"), 0) + + editor.applyTool(BlockType.LIST_NUMBER) + + assertTrue(editor.blocks.single() === list) + assertEquals(BlockType.LIST_BULLET, list.blockType) + assertEquals(BlockType.LIST_NUMBER, list.at("d").kind) + assertEquals(BlockType.LIST_NUMBER, list.at("b").kind) + assertNull(list.at("e").kind) + } + + @Test + fun aListToolInAListWithoutAnItemTheUserWroteInConvertsTheWholeList() { + val list = tree() + editor.load(listOf(list)) + editor.selection.onFocused(list.id) + + editor.applyTool(BlockType.LIST_NUMBER) + + assertEquals(BlockType.LIST_NUMBER, (editor.blocks.single() as Block.ListBlock).blockType) + } + + @Test + fun aListToolInATopItemConvertsTheWholeListAndDropsTheNestedKinds() { + val list = list(BlockType.LIST_BULLET, row("a"), row("b", 1, BlockType.LIST_CHECK)) + editor.load(listOf(list)) + editor.selection.onFocused(list.id) + editor.selection.onItemCaret(list.id, list.id("a"), 0) + + editor.applyTool(BlockType.LIST_NUMBER) + + val converted = editor.blocks.single() as Block.ListBlock + assertEquals(BlockType.LIST_NUMBER, converted.blockType) + assertEquals(listOf(0, 1), converted.items.value.map { it.level }) + assertEquals(listOf(null, null), converted.items.value.map { it.kind }) + } + + // --- the item the toolbar acts on ------------------------------------------------------------- + + @Test + fun theToolbarActsOnTheItemTheUserLastWroteInOfTheBlockTheyWorkOn() { + val list = tree() + val paragraph = Block.TextBlock(MutableStateFlow("p")) + editor.load(listOf(paragraph, list)) + + editor.selection.onFocused(paragraph.id) + assertNull(editor.interactedListItem()) + + editor.selection.onFocused(list.id) + assertNull(editor.interactedListItem()) + editor.selection.onItemCaret(list.id, list.id("c"), 0) + assertEquals(list.id to list.id("c"), editor.interactedListItem()) + + // An item that is gone is not offered. + editor.selection.onItemCaret(list.id, "gone", 0) + assertNull(editor.interactedListItem()) + } + + // --- levels in storage that are not valid ----------------------------------------------------- + + @Test + fun anItemMovesByTheLevelsItIsShownAtWhenStoredOnesAreNotValid() { + val list = list(BlockType.LIST_BULLET, row("a", 2), row("b", 2)) + editor.load(listOf(list)) + + // Shown as a0 b1: b is a child of a, so it can come out but not go deeper. + assertTrue(editor.canShiftListItem(list.id, list.id("b"), deeper = false)) + assertFalse(editor.canShiftListItem(list.id, list.id("b"), deeper = true)) + assertTrue(editor.shiftListItem(list.id, list.id("b"), deeper = false)) + assertEquals("a0 b0", list.shape()) + } + + @Test + fun backspaceInTheFirstItemOfAListWithAStoredLevelStillLeavesTheList() { + val list = list(BlockType.LIST_BULLET, row("a", 1), row("b")) + editor.load(listOf(list)) + + assertTrue(editor.listBackspaceAtStart(list.id, list.id("a"))) + + assertTrue(editor.blocks[0] is Block.TextBlock) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/MarkdownLinesTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/MarkdownLinesTest.kt similarity index 87% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/MarkdownLinesTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/MarkdownLinesTest.kt index 262e3c2f..244056e0 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/MarkdownLinesTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/MarkdownLinesTest.kt @@ -1,12 +1,12 @@ -package com.pasich.encly.presentation.editor.state - -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class MarkdownLinesTest { @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteFindTest.kt similarity index 90% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteFindTest.kt index 62ee1508..29d9aaae 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteFindTest.kt @@ -1,16 +1,16 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock 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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue /** Find in this note: where it looks, how it moves, and that it never touches the note. */ class NoteFindTest { diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteJsonGoldenTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteJsonGoldenTest.kt index e862aaca..7394d31a 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteJsonGoldenTest.kt @@ -1,8 +1,8 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.core.serialization.BlockConverter -import org.junit.Assert.assertEquals -import org.junit.Test +import com.pasich.encly.editor.codec.BlockConverter +import kotlin.test.Test +import kotlin.test.assertEquals /** * The stored format is untouched by Writing Comfort: a note written by an earlier version diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteTemplatesTest.kt similarity index 78% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteTemplatesTest.kt index 7ff47bba..f6a5378e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/NoteTemplatesTest.kt @@ -1,23 +1,24 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.core.serialization.BlockConverter -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue /** 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" + /** A stand-in for the UI's string lookup: every key reads as its own text. */ + private fun resolve(key: EditorString) = "text#${key.name}" @Test fun blankIsWhatANewNoteAlreadyIsAndMakesNoBlocks() { @@ -37,8 +38,8 @@ class NoteTemplatesTest { ) 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) + assertTrue(blocks.isNotEmpty(), template.name) + assertTrue(blocks.first() is Block.HBlock, template.name) blocks.forEach { block -> val type = when (block) { is Block.HBlock -> block.blockType @@ -46,7 +47,7 @@ class NoteTemplatesTest { is Block.TextBlock -> BlockType.TEXT else -> error("${template.name} uses ${block::class.simpleName}") } - assertTrue("${template.name}: $type", type in allowed) + assertTrue(type in allowed, "${template.name}: $type") } // Headings carry the localized text; every heading is filled in. assertTrue(blocks.filterIsInstance().all { it.text.value.startsWith("text#") }) @@ -60,10 +61,10 @@ class NoteTemplatesTest { val back = BlockConverter.jsonToBlocks(json) - assertEquals(template.name, json, BlockConverter.blocksToJson(back)) + assertEquals(json, BlockConverter.blocksToJson(back), template.name) // Empty paragraphs and lists are not stored; the headings are. val headings = template.parts.filterIsInstance().size - assertEquals(template.name, headings, back.filterIsInstance().size) + assertEquals(headings, back.filterIsInstance().size, template.name) } } diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SelectedBlocksEditsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SelectedBlocksEditsTest.kt new file mode 100644 index 00000000..90a8d74a --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SelectedBlocksEditsTest.kt @@ -0,0 +1,200 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SelectedBlocksEditsTest { + private val editor = BlockEditorState { 0L } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun load(vararg blocks: Block) = editor.load(blocks.toList()) + + private fun textsOf() = editor.blocks.map { + (it as? Block.TextBlock)?.text?.value ?: it::class.simpleName.orEmpty() + } + + // --- deleting ---------------------------------------------------------------------------------- + + @Test + fun deletingPickedBlocksIsOneUndoStepThatBringsThemBackInPlace() { + load(text("a"), text("b"), text("c"), text("d")) + editor.pickedBlocks.select(editor.blocks[1].id) + editor.pickedBlocks.toggle(editor.blocks[3].id) + + assertTrue(editor.deleteSelectedBlocks()) + assertEquals(listOf("a", "c"), textsOf()) + assertFalse(editor.pickedBlocks.isActive) + + editor.undo() + assertEquals(listOf("a", "b", "c", "d"), textsOf()) + assertFalse(editor.canUndo.value) + + editor.redo() + assertEquals(listOf("a", "c"), textsOf()) + } + + @Test + fun deletingEveryBlockLeavesOneEmptyParagraphAndUndoBringsAllBack() { + load(text("a"), Block.SeparatorBlock(), text("c")) + editor.pickedBlocks.selectAll() + + assertTrue(editor.deleteSelectedBlocks()) + assertEquals(listOf(""), textsOf()) + + editor.undo() + assertEquals(listOf("a", "SeparatorBlock", "c"), textsOf()) + // One step: nothing is left to undo, and no stray paragraph came back with it. + assertFalse(editor.canUndo.value) + + editor.redo() + assertEquals(listOf(""), textsOf()) + } + + @Test + fun deletingTheOnlyPickedBlockOfAnEmptyNoteKeepsAParagraph() { + editor.pickedBlocks.selectAll() + + assertTrue(editor.deleteSelectedBlocks()) + + assertEquals(1, editor.blocks.size) + } + + @Test + fun deletingNothingPickedChangesNothing() { + load(text("a")) + + assertFalse(editor.deleteSelectedBlocks()) + assertEquals(listOf("a"), textsOf()) + assertFalse(editor.canUndo.value) + } + + @Test + fun aDeleteMovesTheCursorToTheEndOfTheBlockAboveTheGap() = runTest { + load(text("a"), text("b"), text("c")) + editor.pickedBlocks.select(editor.blocks[1].id) + editor.pickedBlocks.toggle(editor.blocks[2].id) + + editor.deleteSelectedBlocks() + + assertEquals(FocusRequest(editor.blocks[0].id, cursorToEnd = true), editor.selection.requests.first()) + assertEquals(editor.blocks[0].id, editor.selection.focusedBlockId.value) + } + + @Test + fun aDeleteAtTheTopFocusesTheFirstBlockThatIsLeft() = runTest { + load(text("a"), text("b"), text("c")) + editor.pickedBlocks.select(editor.blocks[0].id) + + editor.deleteSelectedBlocks() + + assertEquals(FocusRequest(editor.blocks[0].id, cursorToEnd = true), editor.selection.requests.first()) + } + + // --- the mode ends ----------------------------------------------------------------------------- + + @Test + fun loadingANoteEndsTheMode() { + load(text("a"), text("b")) + editor.pickedBlocks.selectAll() + + load(text("c")) + + assertFalse(editor.pickedBlocks.isActive) + } + + @Test + fun undoAndRedoEndTheMode() { + load(text("a")) + editor.addBlock(text("b")) + + editor.pickedBlocks.selectAll() + editor.undo() + assertFalse(editor.pickedBlocks.isActive) + + editor.pickedBlocks.selectAll() + editor.redo() + assertFalse(editor.pickedBlocks.isActive) + } + + @Test + fun aFieldTakingFocusEndsTheMode() { + load(text("a"), text("b")) + editor.pickedBlocks.select(editor.blocks[0].id) + + editor.selection.onFocused(editor.blocks[1].id) + + assertFalse(editor.pickedBlocks.isActive) + } + + @Test + fun aPickedBlockThatIsRemovedLeavesTheSelection() { + load(text("a"), text("b"), text("c")) + editor.pickedBlocks.select(editor.blocks[1].id) + editor.pickedBlocks.toggle(editor.blocks[2].id) + + editor.removeBlock(editor.blocks[2], BlockRemoveAction.REMOVE, refocus = false) + + assertEquals(setOf(editor.blocks[1].id), editor.pickedBlocks.ids.value) + } + + // --- plain text -------------------------------------------------------------------------------- + + @Test + fun pickedBlocksReadAsPlainTextAParagraphEachWithListsOneItemToALine() { + val bullets = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("one"), ItemListBlock(" "), ItemListBlock("two"))), + BlockType.LIST_BULLET, + ) + val numbers = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("first"), ItemListBlock("second"))), + BlockType.LIST_NUMBER, + ) + val checks = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("done", isCheck = true), ItemListBlock("todo"))), + BlockType.LIST_CHECK, + ) + load( + Block.HBlock(MutableStateFlow("Title"), BlockType.H1), + text("para"), + text(""), + Block.QuoteBlock(MutableStateFlow("quoted")), + Block.SeparatorBlock(), + bullets, + numbers, + checks, + Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "Site", url = "https://a.example"))), + Block.LinkBlock(MutableStateFlow(LinkDataBlock(url = "https://b.example"))), + ) + editor.pickedBlocks.selectAll() + + assertEquals( + "Title\n\npara\n\nquoted\n\n---\n\n- one\n- two\n\n1. first\n2. second\n\n[x] done\n[ ] todo\n\n" + + "Site (https://a.example)\n\nhttps://b.example", + editor.pickedPlainText(), + ) + } + + @Test + fun onlyThePickedBlocksAreRead() { + load(text("a"), text("b"), text("c")) + editor.pickedBlocks.select(editor.blocks[2].id) + editor.pickedBlocks.toggle(editor.blocks[0].id) + + assertEquals("a\n\nc", editor.pickedPlainText()) + } + + @Test + fun nothingPickedReadsAsNothing() { + assertEquals("", editor.pickedPlainText()) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SelectedBlocksTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SelectedBlocksTest.kt new file mode 100644 index 00000000..04cbc0d8 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SelectedBlocksTest.kt @@ -0,0 +1,212 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SelectedBlocksTest { + private val blocks = mutableListOf( + text("a"), + text("b"), + text("c"), + text("d"), + text("e"), + ) + private val picks = SelectedBlocks(blocks) + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun ids(vararg at: Int) = at.map { blocks[it].id }.toSet() + + @Test + fun nothingIsPickedAtFirst() { + assertFalse(picks.isActive) + assertEquals(0, picks.count) + assertTrue(picks.picked().isEmpty()) + } + + @Test + fun selectingABlockStartsTheModeWithOnlyThatBlock() { + picks.select(blocks[1].id) + picks.select(blocks[3].id) + + assertTrue(picks.isActive) + assertEquals(ids(3), picks.ids.value) + assertEquals(1, picks.count) + } + + @Test + fun aBlockThatIsNotInTheNoteCannotBePicked() { + picks.select("gone") + picks.toggle("gone") + + assertFalse(picks.isActive) + } + + @Test + fun togglingPicksAndReleasesAndTheModeEndsWithTheLastPick() { + picks.select(blocks[0].id) + picks.toggle(blocks[2].id) + assertEquals(ids(0, 2), picks.ids.value) + + picks.toggle(blocks[0].id) + assertEquals(ids(2), picks.ids.value) + + picks.toggle(blocks[2].id) + assertFalse(picks.isActive) + } + + @Test + fun togglingABlockOfAnEmptySelectionStartsARangeThere() { + picks.toggle(blocks[2].id) + picks.extend(up = false) + + assertEquals(ids(2, 3), picks.ids.value) + } + + @Test + fun theRangeKeepsItsEndsWhenAnInnerPickIsReleased() { + picks.select(blocks[0].id) + picks.toggle(blocks[1].id) + picks.toggle(blocks[2].id) + picks.toggle(blocks[1].id) + + // The head is still the last pick: extending goes on from it. + picks.extend(up = false) + + assertEquals(ids(0, 2, 3), picks.ids.value) + } + + @Test + fun releasingTheAnchorMovesTheAnchorToAPick() { + picks.select(blocks[0].id) + picks.toggle(blocks[2].id) + picks.toggle(blocks[0].id) + + // The anchor is now block 2 (the only pick): shrinking up from head 2 is not possible, extending is. + picks.extend(up = true) + + assertEquals(ids(1, 2), picks.ids.value) + } + + @Test + fun selectAllPicksEveryBlockInOrder() { + picks.selectAll() + + assertEquals(blocks.map { it.id }, picks.picked().map { it.id }) + assertEquals(5, picks.count) + } + + @Test + fun extendingGrowsTheRangeBothWaysAndShrinksItBack() { + picks.select(blocks[2].id) + + picks.extend(up = false) + picks.extend(up = false) + assertEquals(ids(2, 3, 4), picks.ids.value) + + picks.extend(up = true) + assertEquals(ids(2, 3), picks.ids.value) + + picks.extend(up = true) + picks.extend(up = true) + assertEquals(ids(1, 2), picks.ids.value) + + picks.extend(up = true) + picks.extend(up = true) + assertEquals(ids(0, 1, 2), picks.ids.value) + } + + @Test + fun extendingStopsAtTheEndsOfTheNote() { + picks.select(blocks[0].id) + picks.extend(up = true) + assertEquals(ids(0), picks.ids.value) + + picks.select(blocks[4].id) + picks.extend(up = false) + assertEquals(ids(4), picks.ids.value) + } + + @Test + fun extendingDoesNothingOutsideTheMode() { + picks.extend(up = false) + + assertFalse(picks.isActive) + } + + @Test + fun clearingEndsTheModeAndForgetsTheRange() { + picks.select(blocks[1].id) + picks.clear() + assertFalse(picks.isActive) + + picks.extend(up = false) + assertFalse(picks.isActive) + } + + @Test + fun blocksThatLeftTheNoteDropOutAndTheRangeFollows() { + picks.select(blocks[1].id) + picks.extend(up = false) + picks.extend(up = false) + + // Blocks 1 and 3 go away (an undo, a delete); 2 is still picked. + val gone = blocks.removeAt(3) + blocks.removeAt(1) + picks.prune() + + assertEquals(setOf(blocks[1].id), picks.ids.value) + assertFalse(gone.id in picks.ids.value) + picks.extend(up = false) + assertEquals(ids(1, 2), picks.ids.value) + } + + @Test + fun pruningWithEveryPickGoneEndsTheMode() { + picks.select(blocks[1].id) + blocks.removeAt(1) + + picks.prune() + + assertFalse(picks.isActive) + } + + @Test + fun pruningWhenNothingLeftLeavesTheRangeAlone() { + picks.select(blocks[1].id) + picks.extend(up = false) + + picks.prune() + picks.prune() + + assertEquals(ids(1, 2), picks.ids.value) + } + + @Test + fun pruningOutsideTheModeDoesNothing() { + picks.prune() + + assertFalse(picks.isActive) + } + + @Test + fun releasingTheHeadMovesTheHeadToAPickAndMovingBackShrinksDownward() { + picks.select(blocks[0].id) + picks.toggle(blocks[1].id) + picks.toggle(blocks[1].id) + + // The head went with the released pick; the range goes on from the one left. + picks.extend(up = false) + assertEquals(ids(0, 1), picks.ids.value) + + picks.select(blocks[3].id) + picks.extend(up = true) + picks.extend(up = false) + // Moving back towards the anchor lets go of the block it leaves. + assertEquals(ids(3), picks.ids.value) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SlashCommandsTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SlashCommandsTest.kt index 89a49b2b..d6789f91 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SlashCommandsTest.kt @@ -1,15 +1,15 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue /** The slash picker: which commands a query leaves, when it opens and closes, and what picking does. */ class SlashCommandsTest { @@ -36,6 +36,7 @@ class SlashCommandsTest { 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, + BlockType.CODE, BlockType.TABLE, ), slashMatches("", labels).map { it.type }, ) @@ -113,7 +114,7 @@ class SlashCommandsTest { picker.commit() picker.moveHighlight(-1) - assertEquals(SlashCommand.DIVIDER, picker.menu.value?.highlightedCommand) + assertEquals(SlashCommand.TABLE, picker.menu.value?.highlightedCommand) picker.moveHighlight(1) assertEquals(SlashCommand.TEXT, picker.menu.value?.highlightedCommand) picker.moveHighlight(1) diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SmartPasteTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SmartPasteTest.kt index 1c15d6f9..17f9aa16 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SmartPasteTest.kt @@ -1,13 +1,13 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * Smart Paste: what pasted text becomes, as explicit fixtures. Rules under test: a blank line (any @@ -302,6 +302,10 @@ class SmartPasteTest { is Block.SeparatorBlock -> "---" + is Block.CodeBlock -> "CODE:${block.text.value}" + + is Block.TableBlock -> "TABLE" + 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/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/StateGapsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/StateGapsTest.kt new file mode 100644 index 00000000..67799eaa --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/StateGapsTest.kt @@ -0,0 +1,227 @@ +package com.pasich.encly.editor.state + +import androidx.compose.ui.unit.dp +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.theme.EditorLineSpacing +import com.pasich.encly.editor.theme.blockGap +import com.pasich.encly.editor.theme.frameInset +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The corners of the editor's state operations that the scenario tests reach only by accident. */ +class StateGapsTest { + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun list(vararg values: String, type: BlockType = BlockType.LIST_BULLET) = + Block.ListBlock(MutableStateFlow(values.map { ItemListBlock(it) }), type) + + private fun editorOf(vararg blocks: Block) = BlockEditorState { 0L }.also { it.load(blocks.toList()) } + + private fun enter(head: String = "", tail: String = "") = LineBreak(head, listOf("", ""), tail) + + // --- marks ------------------------------------------------------------------------------- + + @Test + fun everyMarkMakesItsBlock() { + assertTrue(shortcutBlock(Shortcut(BlockType.SEPARATOR, "---"), "") is Block.SeparatorBlock) + assertEquals( + "https://x.org", + (shortcutBlock(Shortcut(BlockType.LINK, ""), "https://x.org") as Block.LinkBlock).block.value.url, + ) + assertEquals("plain", (shortcutBlock(Shortcut(BlockType.TEXT, ""), "plain") as Block.TextBlock).text.value) + } + + @Test + fun aPastedBlockEndingInAFieldNeedsNoTrailingParagraph() { + val list = list("a") + assertEquals(listOf(list), withTrailingParagraph(listOf(list))) + assertTrue(withTrailingParagraph(listOf(Block.SeparatorBlock())).last() is Block.TextBlock) + assertEquals(1, withTrailingParagraph(emptyList()).size) + } + + @Test + fun theTypedRuleNeedsTheCursorAtItsEnd() { + assertNull(typedShortcut("---", cursor = 1)) + assertEquals(BlockType.SEPARATOR, typedShortcut("---", cursor = 3)?.type) + } + + // --- text edits and the cursor ----------------------------------------------------------- + + @Test + fun aDiffKnowsWhereItsInsertionEnds() { + assertEquals(4, TextDiff(start = 2, removed = "", inserted = "ab").end) + } + + @Test + fun equalTextsMoveNoCursor() { + assertNull(changedCaret("same", "same")) + assertNull(caretTargetOf("b", 1, 2)) + } + + @Test + fun aListThatLostItsOnlyItemsMovesNoCursor() { + assertNull(caretTargetOf("b", listOf(ItemListBlock("a")), emptyList())) + } + + @Test + fun aConvertedBlockCarriesItsCursorOnlyWhereTheTextGoes() { + val paragraph = text("ab\ncd") + val items = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("ab"), ItemListBlock("cd"))), + BlockType.LIST_BULLET, + ) + + assertNull(carriedCaret(Block.SeparatorBlock(), paragraph, 1, null)) + assertNull(carriedCaret(items, paragraph, 1, "no such item")) + assertEquals(items.items.value.last().id, carriedCaret(paragraph, items, 100, null)?.itemId) + val empty = Block.ListBlock(MutableStateFlow(emptyList()), BlockType.LIST_BULLET) + assertNull(carriedCaret(paragraph, empty, 1, null)) + } + + // --- the toolbar's tools ----------------------------------------------------------------- + + @Test + fun linkAndSeparatorShowTheirOwnTool() { + assertEquals(BlockType.LINK, Block.LinkBlock().toolType()) + assertEquals(BlockType.SEPARATOR, Block.SeparatorBlock().toolType()) + } + + @Test + fun aNoteWithNothingToWriteInGetsAParagraphWhenTheKeyboardIsAskedFor() { + val editor = editorOf(Block.SeparatorBlock()) + + editor.focusWorkingBlock() + + assertEquals(2, editor.blocks.size) + assertTrue(editor.blocks.last() is Block.TextBlock) + } + + @Test + fun conversionsKeepTextOrRefuse() { + val heading = Block.HBlock(MutableStateFlow("h"), BlockType.H1) + assertEquals("h", (convertBlock(heading, BlockType.TEXT) as Block.TextBlock).text.value) + assertNull(convertBlock(text("not empty"), BlockType.LINK)) + assertNull(convertBlock(text("x"), BlockType.SEPARATOR)) + assertNull(convertBlock(Block.SeparatorBlock(), BlockType.H1)) + assertTrue( + convertBlock(Block.QuoteBlock(MutableStateFlow("q")), BlockType.QUOTE, exact = true) is Block.TextBlock, + ) + assertTrue(convertBlock(Block.LinkBlock(), BlockType.TEXT) == null) + assertTrue(convertBlock(text(""), BlockType.LINK) is Block.LinkBlock) + } + + @Test + fun aListConvertsKeepingItsItems() { + val bullets = list("a", "b") + val converted = convertBlock(bullets, BlockType.LIST_NUMBER) as Block.ListBlock + assertEquals(bullets.items.value, converted.items.value) + } + + @Test + fun thePositionOfTheWorkedOnBlockIsReported() { + val editor = editorOf(text("a"), text("b"), text("c")) + editor.selection.onInteraction(editor.blocks[1].id) + assertEquals(BLOCK_IN_MIDDLE, editor.interactedBlockPosition()) + editor.selection.onInteraction(editor.blocks[0].id) + assertEquals(BLOCK_AT_TOP, editor.interactedBlockPosition()) + editor.selection.onInteraction(editor.blocks[2].id) + assertEquals(BLOCK_AT_BOTTOM, editor.interactedBlockPosition()) + } + + @Test + fun addingAtTheEndAfterANonParagraphAddsOne() { + val editor = editorOf(text("a"), Block.SeparatorBlock()) + + editor.addBlockToEnd() + assertEquals(3, editor.blocks.size) + editor.addBlockToEnd() + assertEquals(3, editor.blocks.size) + } + + // --- Enter and Backspace ----------------------------------------------------------------- + + @Test + fun enterAtTheStartOfAnItemOpensAnEmptyOneAbove() { + val list = list("a", "b") + val editor = editorOf(list) + val second = list.items.value[1] + + val kept = editor.listLineBreak(list.id, second.id, enter(head = "", tail = "b")) + + assertEquals("b", kept) + assertEquals(listOf("a", "", "b"), list.items.value.map { it.value }) + } + + @Test + fun enterInAnEmptyFirstItemLeavesTheListUpward() { + val list = list("", "x") + val editor = editorOf(list) + + editor.listLineBreak(list.id, list.items.value[0].id, enter()) + + assertEquals(listOf("x"), list.items.value.map { it.value }) + assertTrue(editor.blocks.first() is Block.TextBlock) + } + + @Test + fun enterInAnEmptyMiddleItemSplitsTheList() { + val list = list("a", "", "c") + val editor = editorOf(list) + + editor.listLineBreak(list.id, list.items.value[1].id, enter()) + + assertEquals(3, editor.blocks.size) + assertTrue(editor.blocks[1] is Block.TextBlock) + } + + @Test + fun backspaceInTheOnlyItemTurnsTheListIntoAParagraph() { + val list = list("solo") + val editor = editorOf(list) + + assertTrue(editor.listBackspaceAtStart(list.id, list.items.value[0].id)) + + assertEquals("solo", (editor.blocks.single() as Block.TextBlock).text.value) + assertFalse(editor.listBackspaceAtStart("gone", "gone")) + } + + @Test + fun backspaceAtTheStartOfNothingToJoinDoesNothing() { + val separator = Block.SeparatorBlock() + val link = Block.LinkBlock() + val editor = editorOf(text("first"), separator, text("t"), link, text("after link")) + + assertFalse(editor.backspaceAtStart(separator.id)) + assertFalse(editor.backspaceAtStart("gone")) + assertFalse(editor.backspaceAtStart(editor.blocks[0].id)) + assertFalse(editor.backspaceAtStart(editor.blocks.last().id)) + } + + @Test + fun backspaceAfterAQuoteJoinsTheParagraphToIt() { + val quote = Block.QuoteBlock(MutableStateFlow("q")) + val paragraph = text("p") + val editor = editorOf(quote, paragraph) + + assertTrue(editor.backspaceAtStart(paragraph.id)) + + assertEquals("qp", quote.text.value) + assertEquals(1, editor.blocks.size) + } + + // --- spacing ----------------------------------------------------------------------------- + + @Test + fun comfortableSpacingAddsToTheDefaults() { + assertEquals(6.dp, EditorLineSpacing.COMFORTABLE.frameInset) + assertEquals(12.dp, EditorLineSpacing.COMFORTABLE.blockGap(8.dp)) + assertEquals(0.dp, EditorLineSpacing.COMPACT.blockGap(8.dp)) + assertEquals(8.dp, EditorLineSpacing.DEFAULT.blockGap(8.dp)) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt new file mode 100644 index 00000000..a9e0525b --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt @@ -0,0 +1,439 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.model.TableRow +import com.pasich.encly.editor.ui.sheets.describe +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Tables and code in the editor's state: moving through cells, the cell menu, undo, find, conversion and ranges. */ +class TablesAndCodeStateTest { + private var now = 0L + private val editor = BlockEditorState { now } + + private fun table(vararg rows: List, header: Boolean = true) = + Block.TableBlock(MutableStateFlow(TableData.of(rows.toList(), header))) + + private fun code(value: String) = Block.CodeBlock(MutableStateFlow(value)) + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun grid(data: TableData) = data.rows.map { row -> row.cells.map { it.value } } + + private fun idAt(data: TableData, row: Int, column: Int) = data.rows[row].cells[column].id + + private val sample = TableData.of(listOf(listOf("a", "b", "c"), listOf("d", "e", "f"))) + + // --- moving through cells --------------------------------------------------------------------------- + + @Test + fun theCursorStepsAlongReadingOrderAndColumns() { + val a = idAt(sample, 0, 0) + val c = idAt(sample, 0, 2) + val d = idAt(sample, 1, 0) + val e = idAt(sample, 1, 1) + + assertEquals(idAt(sample, 0, 1), sample.neighborOf(a, CellStep.NEXT)) + assertEquals(d, sample.neighborOf(c, CellStep.NEXT)) + assertEquals(c, sample.neighborOf(d, CellStep.PREVIOUS)) + assertEquals(a, sample.neighborOf(idAt(sample, 0, 1), CellStep.PREVIOUS)) + assertEquals(e, sample.neighborOf(idAt(sample, 0, 1), CellStep.DOWN)) + assertEquals(idAt(sample, 0, 1), sample.neighborOf(e, CellStep.UP)) + } + + @Test + fun theTablesEdgesAndUnknownCellsHaveNoNeighbor() { + assertNull(sample.neighborOf(idAt(sample, 0, 0), CellStep.PREVIOUS)) + assertNull(sample.neighborOf(idAt(sample, 1, 2), CellStep.NEXT)) + assertNull(sample.neighborOf(idAt(sample, 0, 1), CellStep.UP)) + assertNull(sample.neighborOf(idAt(sample, 1, 1), CellStep.DOWN)) + assertNull(sample.neighborOf("nope", CellStep.NEXT)) + } + + @Test + fun enterGoesDownAndAddsARowAtTheEnd() { + val inFirst = sample.enterFrom(idAt(sample, 0, 1))!! + assertEquals(idAt(sample, 1, 1), inFirst.focusCellId) + assertEquals(grid(sample), grid(inFirst.table)) + + val inLast = sample.enterFrom(idAt(sample, 1, 1))!! + assertEquals(3, inLast.table.rowCount) + assertEquals(idAt(inLast.table, 2, 1), inLast.focusCellId) + assertNull(sample.enterFrom("nope")) + } + + @Test + fun tabGoesOnAndAddsARowAfterTheLastCell() { + val next = sample.tabFrom(idAt(sample, 0, 0))!! + assertEquals(idAt(sample, 0, 1), next.focusCellId) + + val last = sample.tabFrom(idAt(sample, 1, 2))!! + assertEquals(3, last.table.rowCount) + assertEquals(idAt(last.table, 2, 0), last.focusCellId) + assertNull(sample.tabFrom("nope")) + } + + // --- the cell menu --------------------------------------------------------------------------------- + + @Test + fun aRowAndAColumnAreAddedAfterTheCellsOwn() { + val row = sample.addRowBelow(idAt(sample, 0, 2))!! + assertEquals(listOf(listOf("a", "b", "c"), listOf("", "", ""), listOf("d", "e", "f")), grid(row.table)) + assertEquals(idAt(row.table, 1, 2), row.focusCellId) + + val column = sample.addColumnAfter(idAt(sample, 1, 0))!! + assertEquals(listOf(listOf("a", "", "b", "c"), listOf("d", "", "e", "f")), grid(column.table)) + assertEquals(idAt(column.table, 1, 1), column.focusCellId) + assertNull(sample.addRowBelow("nope")) + assertNull(sample.addColumnAfter("nope")) + } + + @Test + fun aRowAndAColumnAreDeletedWithTheCursorLeftInTheNeighbor() { + val row = sample.removeRowOf(idAt(sample, 0, 1))!! + assertEquals(listOf(listOf("d", "e", "f")), grid(row.table)) + assertEquals(idAt(row.table, 0, 1), row.focusCellId) + val lastRow = sample.removeRowOf(idAt(sample, 1, 2))!! + assertEquals(idAt(lastRow.table, 0, 2), lastRow.focusCellId) + + val column = sample.removeColumnOf(idAt(sample, 0, 0))!! + assertEquals(listOf(listOf("b", "c"), listOf("e", "f")), grid(column.table)) + assertEquals(idAt(column.table, 0, 0), column.focusCellId) + val lastColumn = sample.removeColumnOf(idAt(sample, 1, 2))!! + assertEquals(idAt(lastColumn.table, 1, 1), lastColumn.focusCellId) + } + + @Test + fun theOnlyRowAndColumnCannotBeDeleted() { + val single = TableData.of(listOf(listOf("x"))) + + assertNull(single.removeRowOf(idAt(single, 0, 0))) + assertNull(single.removeColumnOf(idAt(single, 0, 0))) + assertNull(sample.removeRowOf("nope")) + assertNull(sample.removeColumnOf("nope")) + } + + @Test + fun theHeaderSwitchLeavesTheCursorAlone() { + val off = sample.toggleHeader() + assertFalse(off.table.header) + assertNull(off.focusCellId) + assertTrue(off.table.toggleHeader().table.header) + } + + // --- typing steps and the cursor after undo ----------------------------------------------------------- + + @Test + fun typingInOneCellIsATypingStepAndNothingElseIs() { + val a = idAt(sample, 0, 0) + val typed = sample.withCell(a, "ax") + + val step = tableTypingStep(sample, typed) + assertNotNull(step) + assertEquals(a, step.field) + assertEquals(TypingKind.INSERT, step.kind) + + assertNull(tableTypingStep(sample, sample.addRow(1))) + assertNull(tableTypingStep(sample, sample.addColumn(1))) + assertNull(tableTypingStep(sample, sample.withHeader(false))) + assertNull(tableTypingStep(sample, sample.withCell(a, "x").withCell(idAt(sample, 1, 1), "y"))) + assertNull(tableTypingStep(sample, sample)) + // A cell swapped for another (same shape, new id) is not typing. + assertNull(tableTypingStep(sample, sample.duplicate())) + val first = sample.rows[0] + val swapped = sample.copy( + rows = + listOf(TableRow(listOf(first.cells[0].copy(id = "other")) + first.cells.drop(1))) + sample.rows.drop(1), + ) + assertNull(tableTypingStep(sample, swapped)) + } + + @Test + fun cellTextIsOneLine() { + assertEquals("a b c d", cellText("a\nb\r\nc
\n\nd")) + assertEquals("plain", cellText("plain")) + } + + @Test + fun undoAndRedoBringBackTheTableAndPutTheCursorInTheCellThatChanged() = runTest { + val block = table(listOf("hello", "b")) + editor.load(listOf(block)) + editor.selection.onFocused(block.id) + val cell = idAt(block.table.value, 0, 0) + + now += BlockOperations.MERGE_WINDOW_MS + 1 + val typed = block.table.value.withCell(cell, "hello!") + editor.changeValue( + block.id, + block.table, + typed, + mergeable = true, + step = tableTypingStep(block.table.value, typed), + ) + + editor.undo() + val undone = editor.selection.requests.first() + assertEquals("hello", block.table.value.valueAt(0, 0)) + assertEquals(cell, undone.itemId) + assertEquals(5, undone.caret) + + editor.redo() + val redone = editor.selection.requests.first() + assertEquals("hello!", block.table.value.valueAt(0, 0)) + assertEquals(cell, redone.itemId) + assertEquals(6, redone.caret) + } + + @Test + fun aRowAddedIsOneUndoStepAndTheCursorStaysInTheTable() = runTest { + val block = table(listOf("a")) + editor.load(listOf(block)) + editor.selection.onFocused(block.id) + + editor.changeValue(block.id, block.table, block.table.value.addRow(1), mergeable = false) + assertEquals(2, block.table.value.rowCount) + + editor.undo() + assertEquals(1, block.table.value.rowCount) + assertEquals(block.id, editor.selection.requests.first().blockId) + editor.redo() + assertEquals(2, block.table.value.rowCount) + } + + @Test + fun aCaretOfATableThatDidNotChangeTextIsNone() { + assertNull(tableCaret("b", sample, sample)) + assertNull(tableCaret("b", sample.addRow(1), sample.addRow(1))) + val typed = sample.withCell(idAt(sample, 1, 2), "fx") + assertEquals(CaretTarget("b", idAt(sample, 1, 2), 2), tableCaret("b", sample, typed)) + // A cell that was not there before has no earlier text to compare. + assertNull(tableCaret("b", sample.removeRow(1), sample)) + } + + @Test + fun caretTargetOfKnowsTablesAndStillIgnoresOtherValues() { + val typed = sample.withCell(idAt(sample, 0, 0), "ab") + assertEquals(1, caretTargetOf("b", typed, sample)?.caret) + assertNull(caretTargetOf("b", sample, 5)) + } + + // --- code blocks --------------------------------------------------------------------------------------- + + @Test + fun enterAndPastesNeverSplitCode() { + val block = code("fun a() {") + editor.load(listOf(block)) + + assertNull(editor.breakLine(block.id, LineBreak("fun a() {", listOf("", ""), ""))) + assertNull(editor.breakLine(block.id, LineBreak("x", listOf("a", "b"), ""))) + assertEquals(1, editor.blocks.size) + } + + @Test + fun backspaceAtTheStartOfCodeMakesItAParagraphWithTheSameText() { + val block = code(" indented") + editor.load(listOf(block)) + + assertTrue(editor.backspaceAtStart(block.id)) + + val paragraph = editor.blocks.single() as Block.TextBlock + assertEquals(" indented", paragraph.text.value) + } + + @Test + fun aParagraphAfterCodeJoinsItAndOneAfterATableDoesNot() { + val first = code("a") + val second = text("b") + editor.load(listOf(first, second)) + assertTrue(editor.backspaceAtStart(second.id)) + assertEquals("ab", first.text.value) + assertEquals(1, editor.blocks.size) + + val grid = table(listOf("x")) + val below = text("y") + editor.load(listOf(grid, below)) + assertFalse(editor.backspaceAtStart(below.id)) + assertEquals(2, editor.blocks.size) + } + + @Test + fun theCodeToolTurnsTextIntoCodeAndBackAndMakesCodeOfAList() { + val paragraph = text("val x = 1") + editor.load(listOf(paragraph)) + editor.selection.onFocused(paragraph.id) + + editor.applyTool(BlockType.CODE) + assertEquals("val x = 1", (editor.blocks.single() as Block.CodeBlock).text.value) + editor.applyTool(BlockType.CODE) + assertEquals("val x = 1", (editor.blocks.single() as Block.TextBlock).text.value) + + val list = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("a"), ItemListBlock("b"))), + BlockType.LIST_BULLET, + ) + editor.load(listOf(list)) + editor.selection.onFocused(list.id) + editor.applyTool(BlockType.CODE) + assertEquals("a\nb", (editor.blocks.single() as Block.CodeBlock).text.value) + } + + @Test + fun theTableToolAddsATableAfterTheBlockInsteadOfConvertingIt() { + val paragraph = text("keep me") + editor.load(listOf(paragraph)) + editor.selection.onFocused(paragraph.id) + + editor.applyTool(BlockType.TABLE) + + assertEquals(2, editor.blocks.size) + assertEquals("keep me", (editor.blocks[0] as Block.TextBlock).text.value) + assertTrue(editor.blocks[1] is Block.TableBlock) + assertEquals(BlockType.TABLE, editor.blocks[1].toolType()) + assertEquals(BlockType.CODE, code("x").toolType()) + } + + @Test + fun theBlockSheetsNameCodeAndTables() { + assertEquals(EditorString.BLOCK_CODE, code("x").describe()) + assertEquals(EditorString.BLOCK_TABLE, table(listOf("x")).describe()) + } + + @Test + fun theSlashPickerOffersCodeAndTables() { + val paragraph = text("/") + editor.load(listOf(paragraph)) + assertTrue(editor.applySlash(paragraph.id, SlashCommand.CODE)) + assertTrue(editor.blocks.single() is Block.CodeBlock) + + val another = text("/") + editor.load(listOf(another)) + assertTrue(editor.applySlash(another.id, SlashCommand.TABLE)) + assertTrue(editor.blocks.single() is Block.TableBlock) + + val labels = SlashCommand.entries.associateWith { it.name.lowercase() } + assertEquals(listOf(SlashCommand.CODE), slashMatches("snip", labels)) + assertEquals(listOf(SlashCommand.TABLE), slashMatches("grid", labels)) + } + + @Test + fun aMarkIsNeverMadeOfCodeOrATable() { + val made = shortcutBlock(Shortcut(BlockType.CODE, "x"), "rest") + assertEquals("rest", (made as Block.TextBlock).text.value) + assertTrue(shortcutBlock(Shortcut(BlockType.TABLE, "x"), "") is Block.TextBlock) + } + + // --- every kind of editing that meets them ---------------------------------------------------------------- + + @Test + fun bothTakeFocusAndDuplicateWithoutSharingAnything() { + val grid = table(listOf("a", "b")) + val snippet = code("x") + editor.load(listOf(grid, snippet)) + + assertTrue(grid.canTakeFocus()) + assertTrue(snippet.canTakeFocus()) + assertTrue(editor.duplicateBlockOf(grid.id)) + assertTrue(editor.duplicateBlockOf(snippet.id)) + + val copy = editor.blocks[1] as Block.TableBlock + assertEquals(grid(grid.table.value), grid(copy.table.value)) + assertTrue(copy.table.value.rows[0].cells[0].id != grid.table.value.rows[0].cells[0].id) + copy.table.value = copy.table.value.withCell(idAt(copy.table.value, 0, 0), "changed") + assertEquals("a", grid.table.value.valueAt(0, 0)) + val codeCopy = editor.blocks[3] as Block.CodeBlock + codeCopy.text.value = "y" + assertEquals("x", snippet.text.value) + } + + @Test + fun findSearchesCodeAndEveryCellOfATable() { + val grid = table(listOf("Alpha", "beta"), listOf("gamma", "alpha again")) + val snippet = code("val alpha = 1") + editor.load(listOf(grid, snippet)) + + val matches = findMatches(editor.blocks, "alpha") + + assertEquals(3, matches.size) + assertEquals(idAt(grid.table.value, 0, 0), matches[0].itemId) + assertEquals(idAt(grid.table.value, 1, 1), matches[1].itemId) + assertEquals(snippet.id to null, matches[2].blockId to matches[2].itemId) + } + + @Test + fun selectedBlocksAreCopiedAsPlainTextWithATableAsRowsOfCells() { + val grid = table(listOf("a", "b"), listOf("c", "d")) + val snippet = code("x = 1\n y = 2") + editor.load(listOf(grid, snippet)) + + assertEquals("a | b\nc | d\n\nx = 1\n y = 2", editor.blocks.plainText()) + } + + @Test + fun aRangeTakesATableWholeWhenItCoversItAndLeavesItAloneWhenItEndsInsideIt() { + val before = text("before") + val grid = table(listOf("a", "b"), listOf("c", "d")) + val after = text("after") + + // Across: from the middle of the paragraph above to the middle of the one below. + editor.load(listOf(before, grid, after)) + editor.textRange.select(TextPoint(before.id, 3), TextPoint(after.id, 2)) + assertTrue(editor.textRangeBlocks().any { it is Block.TableBlock }) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf("befter"), editor.blocks.map { (it as Block.TextBlock).text.value }) + + // A start inside a cell: the table is not covered, so it stays and the text on the other side is cut. + val second = table(listOf("a", "b")) + val tail = text("tail text") + editor.load(listOf(second, tail)) + editor.textRange.select(TextPoint(second.id, 1, idAt(second.table.value, 0, 1)), TextPoint(tail.id, 5)) + assertEquals(0, editor.textRangeBlocks().count { it is Block.TableBlock }) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf(second.id, tail.id), editor.blocks.map { it.id }) + assertEquals("text", tail.text.value) + + // An end inside a cell, at its start: the same. + val first = text("head text") + val third = table(listOf("a")) + editor.load(listOf(first, third)) + editor.textRange.select(TextPoint(first.id, 5), TextPoint(third.id, 0, idAt(third.table.value, 0, 0))) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf(first.id, third.id), editor.blocks.map { it.id }) + assertEquals("head ", first.text.value) + } + + @Test + fun aRangeCrossingACodeBlockKeepsTheFirstBlocksKind() { + val first = code("one two") + val last = text("three four") + editor.load(listOf(first, last)) + editor.textRange.select(TextPoint(first.id, 4), TextPoint(last.id, 6)) + + assertTrue(editor.replaceTextRange("")) + + assertEquals("one four", (editor.blocks.single() as Block.CodeBlock).text.value) + } + + @Test + fun theDocumentCursorMovesPastTablesAndCodeLikeAnyOtherField() { + val snippet = code("ab") + val grid = table(listOf("x")) + val last = text("cd") + editor.load(listOf(snippet, grid, last)) + editor.textRange.select(TextPoint(snippet.id, 1), TextPoint(snippet.id, 1)) + + editor.textRange.moveHead(HeadMove.DOWN) + + assertEquals(last.id, editor.textRange.ends.value?.head?.blockId) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/TextEditsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextEditsTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/TextEditsTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextEditsTest.kt index 53346166..7aa82368 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/TextEditsTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextEditsTest.kt @@ -1,11 +1,11 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.domain.model.ItemListBlock -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import com.pasich.encly.editor.model.ItemListBlock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class TextEditsTest { @Test diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeListsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeListsTest.kt new file mode 100644 index 00000000..b3747db4 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeListsTest.kt @@ -0,0 +1,421 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** A text selection that starts or ends inside a list item, where it covers, moves and is replaced. */ +class TextRangeListsTest { + 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_BULLET) + + private fun load(vararg blocks: Block) = editor.load(blocks.toList()) + + private fun item(list: Block.ListBlock, at: Int, offset: Int) = TextPoint(list.id, offset, list.items.value[at].id) + + private fun shown() = editor.blocks.map { block -> + when (block) { + is Block.TextBlock -> "p:" + block.text.value + is Block.ListBlock -> "l:" + block.items.value.joinToString("|") { it.value } + else -> block::class.simpleName.orEmpty() + } + } + + // --- replacing --------------------------------------------------------------------------------- + + @Test + fun aSelectionInsideOneListJoinsTheItemsItRunsOver() { + val bullets = list("one", "two", "three", "four") + load(bullets) + editor.textRange.select(item(bullets, 0, 2), item(bullets, 2, 3)) + + assertTrue(editor.replaceTextRange("-")) + + assertEquals(listOf("l:on-ee|four"), shown()) + editor.undo() + assertEquals(listOf("l:one|two|three|four"), shown()) + } + + @Test + fun aListThatTheSelectionStartsInKeepsItsEarlierItemsAndTakesTheRestOfTheLastBlock() { + val bullets = list("one", "two") + load(text("intro"), bullets, text("middle"), text("tail end")) + editor.textRange.select(item(bullets, 1, 1), TextPoint(editor.blocks[3].id, 5)) + + editor.replaceTextRange("") + + assertEquals(listOf("p:intro", "l:one|tend"), shown()) + } + + @Test + fun aListThatTheSelectionStartsInAbsorbsTheItemsAfterTheEndOfAnotherList() { + val first = list("one", "two") + val second = list("red", "green", "blue") + load(first, text("between"), second) + editor.textRange.select(item(first, 1, 1), item(second, 1, 2)) + + editor.replaceTextRange("") + + assertEquals(listOf("l:one|teen|blue"), shown()) + editor.undo() + assertEquals(listOf("l:one|two", "p:between", "l:red|green|blue"), shown()) + } + + @Test + fun aParagraphThatTheSelectionStartsInLeavesTheRestOfTheListItEndsIn() { + val bullets = list("one", "two", "three") + load(text("start here"), text("middle"), bullets) + editor.textRange.select(TextPoint(editor.blocks[0].id, 5), item(bullets, 0, 2)) + + editor.replaceTextRange("") + + assertEquals(listOf("p:starte", "l:two|three"), shown()) + editor.undo() + assertEquals(listOf("p:start here", "p:middle", "l:one|two|three"), shown()) + } + + @Test + fun aParagraphThatTheSelectionStartsInTakesAListEndedOnItsLastItem() { + val bullets = list("one", "two") + load(text("abc"), bullets) + editor.textRange.select(TextPoint(editor.blocks[0].id, 1), item(bullets, 1, 1)) + + editor.replaceTextRange("X") + + assertEquals(listOf("p:aXwo"), shown()) + } + + @Test + fun aListEndedOnWithNoParagraphBeforeItKeepsItsKind() { + val bullets = list("one", "two", "three") + load(Block.SeparatorBlock(), bullets) + editor.textRange.select(TextPoint(editor.blocks[0].id, 0), item(bullets, 1, 1)) + + editor.replaceTextRange("Q") + + assertEquals(listOf("l:Qwo|three"), shown()) + } + + @Test + fun theCursorGoesIntoTheItemThatHoldsTheResult() { + val bullets = list("one", "two") + load(bullets, text("tail")) + editor.textRange.select(item(bullets, 1, 1), TextPoint(editor.blocks[1].id, 2)) + + editor.replaceTextRange("") + + assertEquals(bullets.id, editor.selection.focusedBlockId.value) + assertEquals(bullets.items.value[1].id, editor.selection.itemOf(bullets.id)) + } + + // --- what it covers ---------------------------------------------------------------------------- + + @Test + fun theCoverOfAFieldIsTheWholeOfItsMiddleAndTheCutOfItsEnds() { + val bullets = list("one", "two", "three") + load(text("abc"), bullets, text("def")) + editor.textRange.select(TextPoint(editor.blocks[0].id, 1), TextPoint(editor.blocks[2].id, 2)) + + assertEquals(1 to 3, editor.textRange.coverOf(editor.blocks[0].id, null, 3)) + assertEquals(0 to 3, editor.textRange.coverOf(bullets.id, bullets.items.value[0].id, 3)) + assertEquals(0 to 5, editor.textRange.coverOf(bullets.id, bullets.items.value[2].id, 5)) + assertEquals(0 to 2, editor.textRange.coverOf(editor.blocks[2].id, null, 3)) + } + + @Test + fun aListItemOutsideTheSelectionIsNotCovered() { + val bullets = list("one", "two", "three") + load(bullets, text("tail")) + editor.textRange.select(item(bullets, 1, 1), TextPoint(editor.blocks[1].id, 2)) + + assertNull(editor.textRange.coverOf(bullets.id, bullets.items.value[0].id, 3)) + assertEquals(1 to 3, editor.textRange.coverOf(bullets.id, bullets.items.value[1].id, 3)) + assertNull(editor.textRange.coverOf("gone", null, 3)) + } + + @Test + fun withoutASelectionNothingIsCovered() { + load(text("abc")) + + assertNull(editor.textRange.coverOf(editor.blocks[0].id, null, 3)) + } + + @Test + fun theCoverStaysInsideTheFieldAndAnEmptyOneHasNone() { + load(text("abc"), text("")) + editor.textRange.select(TextPoint(editor.blocks[0].id, 1), TextPoint(editor.blocks[1].id, 0)) + + assertEquals(0 to 0, editor.textRange.coverOf(editor.blocks[1].id, null, 0)) + assertEquals(1 to 3, editor.textRange.coverOf(editor.blocks[0].id, null, 3)) + } + + @Test + fun theEndsOfASelectionAreOrderedPointsWithTheirItems() { + val bullets = list("one", "two") + load(bullets, text("tail")) + editor.textRange.select(TextPoint(editor.blocks[1].id, 2), item(bullets, 1, 1)) + + val (from, to) = editor.textRange.orderedPoints()!! + + assertEquals(item(bullets, 1, 1), from) + assertEquals(TextPoint(editor.blocks[1].id, 2), to) + } + + @Test + fun anUnnamedListPointStartsInTheFirstItemAndEndsInTheLast() { + val bullets = list("one", "two") + load(bullets) + editor.textRange.select(TextPoint(bullets.id, 1), TextPoint(bullets.id, 99)) + + val (from, to) = editor.textRange.orderedPoints()!! + + assertEquals(item(bullets, 0, 1), from) + assertEquals(item(bullets, 1, 3), to) + } + + @Test + fun theEndsAreNotPointsWhereTheSelectionEndsInABlockWithNoText() { + load(text("a"), Block.SeparatorBlock()) + assertNull(editor.textRange.orderedPoints()) + + editor.textRange.select(TextPoint(editor.blocks[0].id, 0), TextPoint(editor.blocks[1].id, 0)) + assertNull(editor.textRange.orderedPoints()) + + editor.textRange.select(TextPoint(editor.blocks[1].id, 0), TextPoint(editor.blocks[0].id, 1)) + assertNull(editor.textRange.orderedPoints()) + } + + // --- moving the end ---------------------------------------------------------------------------- + + private fun head() = editor.textRange.ends.value!!.head + + private fun startAt(index: Int, offset: Int) = editor.textRange.select( + TextPoint(editor.blocks[index].id, offset), + TextPoint(editor.blocks[index].id, offset), + ) + + @Test + fun theEndMovesByCharactersAndCrossesFromFieldToField() { + load(text("ab"), text("cd")) + startAt(0, 1) + + editor.textRange.moveHead(HeadMove.NEXT) + assertEquals(TextPoint(editor.blocks[0].id, 2), head()) + editor.textRange.moveHead(HeadMove.NEXT) + assertEquals(TextPoint(editor.blocks[1].id, 0), head()) + editor.textRange.moveHead(HeadMove.PREVIOUS) + assertEquals(TextPoint(editor.blocks[0].id, 2), head()) + editor.textRange.moveHead(HeadMove.PREVIOUS) + assertEquals(TextPoint(editor.blocks[0].id, 1), head()) + } + + @Test + fun theEndStopsAtTheEdgesOfTheNote() { + load(text("ab"), text("cd")) + startAt(0, 0) + editor.textRange.moveHead(HeadMove.PREVIOUS) + assertEquals(TextPoint(editor.blocks[0].id, 0), head()) + + startAt(1, 2) + editor.textRange.moveHead(HeadMove.NEXT) + assertEquals(TextPoint(editor.blocks[1].id, 2), head()) + } + + @Test + fun aSurrogatePairIsOneStep() { + load(text("a😀b")) + startAt(0, 1) + + editor.textRange.moveHead(HeadMove.NEXT) + assertEquals(3, head().offset) + editor.textRange.moveHead(HeadMove.PREVIOUS) + assertEquals(1, head().offset) + } + + @Test + fun upAndDownKeepTheOffsetAndStopAtTheEnds() { + load(text("abcdef"), text("xy"), text("klmnop")) + startAt(1, 2) + + editor.textRange.moveHead(HeadMove.UP) + assertEquals(TextPoint(editor.blocks[0].id, 2), head()) + editor.textRange.moveHead(HeadMove.UP) + assertEquals(TextPoint(editor.blocks[0].id, 0), head()) + + startAt(1, 1) + editor.textRange.moveHead(HeadMove.DOWN) + assertEquals(TextPoint(editor.blocks[2].id, 1), head()) + editor.textRange.moveHead(HeadMove.DOWN) + assertEquals(TextPoint(editor.blocks[2].id, 6), head()) + } + + @Test + fun homeEndAndThePageAndNoteJumps() { + val blocks = List(8) { text("line$it") } + load(*blocks.toTypedArray()) + startAt(3, 2) + + editor.textRange.moveHead(HeadMove.FIELD_START) + assertEquals(TextPoint(editor.blocks[3].id, 0), head()) + editor.textRange.moveHead(HeadMove.FIELD_END) + assertEquals(TextPoint(editor.blocks[3].id, 5), head()) + editor.textRange.moveHead(HeadMove.PAGE_DOWN) + assertEquals(TextPoint(editor.blocks[7].id, 5), head()) + editor.textRange.moveHead(HeadMove.PAGE_UP) + assertEquals(TextPoint(editor.blocks[2].id, 5), head()) + editor.textRange.moveHead(HeadMove.NOTE_END) + assertEquals(TextPoint(editor.blocks[7].id, 5), head()) + editor.textRange.moveHead(HeadMove.NOTE_START) + assertEquals(TextPoint(editor.blocks[0].id, 0), head()) + } + + @Test + fun theEndMovesAcrossListItemsAndPastBlocksWithNoText() { + val bullets = list("one", "two") + load(text("a"), Block.SeparatorBlock(), bullets) + startAt(0, 1) + + editor.textRange.moveHead(HeadMove.NEXT) + assertEquals(item(bullets, 0, 0), head()) + editor.textRange.moveHead(HeadMove.DOWN) + assertEquals(item(bullets, 1, 0), head()) + editor.textRange.moveHead(HeadMove.UP) + editor.textRange.moveHead(HeadMove.UP) + assertEquals(TextPoint(editor.blocks[0].id, 0), head()) + } + + @Test + fun anUnnamedListEndMovesFromItsLastItem() { + val bullets = list("one", "two") + load(text("a"), bullets) + editor.textRange.select(TextPoint(editor.blocks[0].id, 0), TextPoint(bullets.id, 1)) + + editor.textRange.moveHead(HeadMove.PREVIOUS) + + assertEquals(item(bullets, 1, 0), head()) + } + + @Test + fun movingWithoutASelectionOrFromABlockWithNoTextDoesNothing() { + load(text("a"), Block.SeparatorBlock()) + editor.textRange.moveHead(HeadMove.NEXT) + assertFalse(editor.textRange.isActive) + + editor.textRange.select(TextPoint(editor.blocks[0].id, 0), TextPoint(editor.blocks[1].id, 0)) + editor.textRange.moveHead(HeadMove.NEXT) + assertEquals(TextPoint(editor.blocks[1].id, 0), head()) + } + + // --- Shift plus a key in a field --------------------------------------------------------------- + + private fun extend(move: HeadMove, index: Int, from: Int, to: Int, edge: Boolean = false): Boolean { + val id = editor.blocks[index].id + return editor.textRange.extend(move, TextPoint(id, from), TextPoint(id, to), edge) + } + + @Test + fun aKeyThatStaysInTheFieldIsLeftToTheField() { + load(text("abc"), text("def")) + + assertFalse(extend(HeadMove.PREVIOUS, 1, 2, 2)) + assertFalse(extend(HeadMove.NEXT, 0, 0, 1)) + assertFalse(extend(HeadMove.UP, 1, 0, 0, edge = false)) + assertFalse(extend(HeadMove.DOWN, 0, 0, 0, edge = false)) + assertFalse(extend(HeadMove.FIELD_START, 0, 1, 1)) + assertFalse(extend(HeadMove.FIELD_END, 0, 1, 1)) + assertFalse(editor.textRange.isActive) + } + + @Test + fun aKeyThatLeavesTheFieldStartsASelectionFromTheFieldsOwn() { + load(text("abc"), text("def")) + + assertTrue(extend(HeadMove.NEXT, 0, 1, 3)) + + assertEquals(TextPoint(editor.blocks[0].id, 1), editor.textRange.ends.value!!.anchor) + assertEquals(TextPoint(editor.blocks[1].id, 0), head()) + assertTrue(editor.textRange.isCrossField) + } + + @Test + fun theKeysAtTheStartAndTheEdgeLinesLeaveToo() { + load(text("abc"), text("def"), text("ghi")) + + assertTrue(extend(HeadMove.PREVIOUS, 1, 0, 0)) + editor.textRange.clear() + assertTrue(extend(HeadMove.UP, 1, 1, 1, edge = true)) + editor.textRange.clear() + assertTrue(extend(HeadMove.DOWN, 1, 1, 1, edge = true)) + editor.textRange.clear() + assertTrue(extend(HeadMove.PAGE_UP, 1, 1, 1)) + editor.textRange.clear() + assertTrue(extend(HeadMove.NOTE_END, 1, 1, 1)) + } + + @Test + fun theFirstAndLastFieldOfTheNoteHaveNowhereToGo() { + load(text("abc"), text("def")) + + assertFalse(extend(HeadMove.PREVIOUS, 0, 0, 0)) + assertFalse(extend(HeadMove.UP, 0, 0, 0, edge = true)) + assertFalse(extend(HeadMove.NEXT, 1, 3, 3)) + assertFalse(extend(HeadMove.DOWN, 1, 3, 3, edge = true)) + } + + @Test + fun onceTheSelectionRunsAcrossFieldsEveryKeyMovesItsEnd() { + load(text("abc"), text("def")) + assertTrue(extend(HeadMove.NEXT, 0, 3, 3)) + + assertTrue(extend(HeadMove.FIELD_END, 1, 0, 0)) + + assertEquals(TextPoint(editor.blocks[1].id, 3), head()) + } + + @Test + fun aCaretInABlockThatIsNotInTheNoteLeavesNothing() { + load(text("a")) + val gone = TextPoint("gone", 0) + + for (move in HeadMove.entries) { + assertFalse(editor.textRange.extend(move, gone, gone, onEdgeLine = true), move.name) + } + assertFalse(editor.textRange.isActive) + } + + // --- a list with no items ---------------------------------------------------------------------------- + + @Test + fun aListWithNoItemsHasNothingToSelectInAndIsLeftWhole() { + val empty = Block.ListBlock(MutableStateFlow(emptyList()), BlockType.LIST_BULLET) + load(text("abc"), empty, text("def")) + editor.textRange.select(TextPoint(editor.blocks[0].id, 1), TextPoint(empty.id, 0)) + + assertEquals(TextPoint(empty.id, 0), editor.textRange.orderedPoints()!!.second) + assertEquals("bc", editor.textRangePlainText()) + assertEquals(0 to 0, editor.textRange.coverOf(empty.id, "none", 0)) + + editor.replaceTextRange("X") + assertEquals(listOf("p:aX", "p:def"), shown()) + } + + @Test + fun aSelectionThatStartsInAListWithNoItemsIsReplacedByTheParagraphAfterIt() { + val empty = Block.ListBlock(MutableStateFlow(emptyList()), BlockType.LIST_BULLET) + load(empty, text("def")) + editor.textRange.select(TextPoint(empty.id, 0), TextPoint(editor.blocks[1].id, 1)) + + assertEquals("d", editor.textRangePlainText()) + assertEquals(0 to 1, editor.textRange.coverOf(editor.blocks[1].id, null, 3)) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeNestedTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeNestedTest.kt new file mode 100644 index 00000000..79c2816d --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeNestedTest.kt @@ -0,0 +1,187 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Text selection across blocks and block selection when a list has nested items. */ +class TextRangeNestedTest { + private val editor = BlockEditorState { 0L } + + private fun row(text: String, level: Int = 0, kind: BlockType? = null, check: Boolean = false) = + ItemListBlock(text, isCheck = check, level = level, kind = kind) + + private fun list(type: BlockType, vararg rows: ItemListBlock) = + Block.ListBlock(MutableStateFlow(rows.toList()), type) + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun point(list: Block.ListBlock, text: String, offset: Int) = + TextPoint(list.id, offset, list.items.value.first { it.value == text }.id) + + private fun Block.ListBlock.shape() = items.value.joinToString(" ") { "${it.value}${it.level}" } + + // a0 b1 c2 d1 e0 + private fun tree(type: BlockType = BlockType.LIST_BULLET) = + list(type, row("a"), row("b", 1), row("c", 2), row("d", 1), row("e")) + + @Test + fun aSelectionRunsOverNestedItemsAsItDoesOverFlatOnes() { + val list = tree() + editor.load(listOf(list)) + editor.textRange.select(point(list, "b", 0), point(list, "d", 1)) + + assertTrue(editor.textRange.isCrossField) + val (from, to) = editor.textRange.orderedPoints() ?: error("selected") + assertEquals(list.items.value[1].id, from.itemId) + assertEquals(list.items.value[3].id, to.itemId) + assertEquals(0 to 1, editor.textRange.coverOf(list.id, list.items.value[1].id, 1)) + assertEquals(0 to 1, editor.textRange.coverOf(list.id, list.items.value[2].id, 1)) + assertEquals(0 to 1, editor.textRange.coverOf(list.id, list.items.value[3].id, 1)) + assertNull(editor.textRange.coverOf(list.id, list.items.value[4].id, 1)) + } + + @Test + fun theHeadMovesFromItemToItemThroughNestedOnes() { + val list = tree() + editor.load(listOf(list)) + editor.textRange.select(point(list, "a", 0), point(list, "a", 1)) + + editor.textRange.moveHead(HeadMove.DOWN) + editor.textRange.moveHead(HeadMove.DOWN) + + assertEquals(list.items.value[2].id, editor.textRange.ends.value?.head?.itemId) + editor.textRange.moveHead(HeadMove.NOTE_END) + assertEquals(list.items.value[4].id, editor.textRange.ends.value?.head?.itemId) + } + + @Test + fun theCopiedPartStartsAtTheTopLevelAndKeepsTheRestOfTheShape() { + val list = tree() + editor.load(listOf(list)) + editor.textRange.select(point(list, "c", 0), point(list, "e", 1)) + + val copied = editor.textRangeBlocks().single() as Block.ListBlock + + assertEquals("c0 d1 e0", copied.shape()) + // Nothing is shared with the note. + assertTrue(copied.items.value.none { copy -> list.items.value.any { it.id == copy.id } }) + } + + @Test + fun theCopiedTextIndentsNestedItemsAndNumbersEachLevel() { + val numbered = list(BlockType.LIST_NUMBER, row("a"), row("b", 1), row("c", 1), row("d")) + editor.load(listOf(numbered)) + editor.textRange.select(point(numbered, "a", 0), point(numbered, "d", 1)) + + assertEquals("1. a\n 1. b\n 2. c\n2. d", editor.textRangePlainText()) + } + + @Test + fun theCopiedTextShowsTheKindOfANestedItem() { + val bullets = list( + BlockType.LIST_BULLET, + row("a"), + row("b", 1, BlockType.LIST_CHECK, check = true), + row("c", 1, BlockType.LIST_CHECK), + row("d", 1, BlockType.LIST_NUMBER), + ) + editor.load(listOf(bullets)) + editor.textRange.selectAll() + + assertEquals("- a\n [x] b\n [ ] c\n 1. d", editor.textRangePlainText()) + } + + @Test + fun replacingASelectionKeepsTheLevelOfTheItemItStartsIn() { + val list = tree() + editor.load(listOf(list)) + editor.textRange.select(point(list, "b", 1), point(list, "d", 0)) + + assertTrue(editor.replaceTextRange("-")) + + assertEquals("a0 b-d1 e0", list.shape()) + assertEquals(listOf("a", "b-d", "e"), list.items.value.map { it.value }) + assertEquals(listOf(0, 1, 0), list.items.value.map { it.level }) + editor.undo() + assertEquals("a0 b1 c2 d1 e0", list.shape()) + editor.redo() + assertEquals(listOf(0, 1, 0), list.items.value.map { it.level }) + } + + @Test + fun aSelectionThatEndsInTheMiddleOfNestedItemsLeavesTheRestAtValidLevels() { + val list = tree() + val intro = text("intro") + editor.load(listOf(intro, list)) + // From the paragraph to the end of b: the rest (c, d, e) now follows a paragraph, so c starts at the top and + // d stays its child. + editor.textRange.select(TextPoint(intro.id, 2), point(list, "b", 1)) + + assertTrue(editor.replaceTextRange("")) + + val rest = editor.blocks.last() as Block.ListBlock + assertEquals("c0 d1 e0", rest.shape()) + } + + @Test + fun aListThatTheSelectionEndsInKeepsItsTailAtValidLevels() { + val list = tree() + val intro = text("intro") + editor.load(listOf(intro, list)) + editor.textRange.select(TextPoint(intro.id, 5), point(list, "b", 1)) + + editor.replaceTextRange("!") + + // The paragraph's first block wins; the items after the selection stay as a list of their own. + assertEquals("intro!", (editor.blocks.first() as Block.TextBlock).text.value) + assertEquals("c0 d1 e0", (editor.blocks.last() as Block.ListBlock).shape()) + } + + @Test + fun cuttingAWholeNestedListLeavesOneEmptyItem() { + val list = tree() + editor.load(listOf(list)) + editor.textRange.selectAll() + + assertTrue(editor.replaceTextRange("")) + + assertEquals(listOf(""), (editor.blocks.single() as Block.ListBlock).items.value.map { it.value }) + } + + // --- block selection -------------------------------------------------------------------------- + + @Test + fun aPickedNestedListIsDeletedDuplicatedAndCopiedWhole() { + val list = tree() + val after = text("after") + editor.load(listOf(list, after)) + + editor.duplicateBlockOf(list.id) + + val copy = editor.blocks[1] as Block.ListBlock + assertEquals("a0 b1 c2 d1 e0", copy.shape()) + assertTrue(copy.items.value.none { item -> list.items.value.any { it.id == item.id } }) + + editor.pickedBlocks.select(copy.id) + assertEquals("- a\n - b\n - c\n - d\n- e", listOf(copy).plainText()) + assertTrue(editor.deleteSelectedBlocks()) + assertEquals(2, editor.blocks.size) + } + + @Test + fun cuttingTheOnlyListOfANoteLeavesAnEmptyParagraph() { + val list = tree() + editor.load(listOf(list)) + editor.pickedBlocks.select(list.id) + + assertTrue(editor.deleteSelectedBlocks()) + + assertTrue(editor.blocks.single() is Block.TextBlock) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeSelectionTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeSelectionTest.kt new file mode 100644 index 00000000..82677c31 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeSelectionTest.kt @@ -0,0 +1,356 @@ +package com.pasich.encly.editor.state + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TextRangeSelectionTest { + private val editor = BlockEditorState { 0L } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun heading(value: String) = Block.HBlock(MutableStateFlow(value), BlockType.H2) + + private fun quote(value: String) = Block.QuoteBlock(MutableStateFlow(value)) + + private fun load(vararg blocks: Block) = editor.load(blocks.toList()) + + private fun point(index: Int, offset: Int) = TextPoint(editor.blocks[index].id, offset) + + private fun select(from: TextPoint, to: TextPoint) = editor.textRange.select(from, to) + + private fun shown() = editor.blocks.map { block -> + when (block) { + is Block.TextBlock -> "p:" + block.text.value + is Block.HBlock -> "h:" + block.text.value + is Block.QuoteBlock -> "q:" + block.text.value + else -> block::class.simpleName.orEmpty() + } + } + + // --- the selection --------------------------------------------------------------------------- + + @Test + fun thereIsNoSelectionAtFirst() { + load(text("a")) + + assertFalse(editor.textRange.isActive) + assertEquals(0, editor.textRange.blockCount()) + assertTrue(editor.textRangeBlocks().isEmpty()) + assertEquals("", editor.textRangePlainText()) + assertFalse(editor.replaceTextRange("x")) + } + + @Test + fun blocksThatAreNotInTheNoteCannotBeSelected() { + load(text("a"), text("b")) + + select(TextPoint("gone", 0), point(1, 1)) + assertFalse(editor.textRange.isActive) + select(point(0, 0), TextPoint("gone", 0)) + assertFalse(editor.textRange.isActive) + editor.textRange.extendTo(point(1, 0)) + assertFalse(editor.textRange.isActive) + } + + @Test + fun extendingKeepsTheAnchorAndMovesTheHead() { + load(text("abc"), text("def"), text("ghi")) + select(point(0, 1), point(0, 2)) + assertTrue(editor.textRange.isActive) + + editor.textRange.extendTo(point(2, 2)) + + assertEquals(point(0, 1), editor.textRange.ends.value!!.anchor) + assertEquals(3, editor.textRange.blockCount()) + assertEquals("bc\n\ndef\n\ngh", editor.textRangePlainText()) + } + + @Test + fun aSelectionMadeBackwardsReadsTheSameAsOneMadeForward() { + load(text("abc"), text("def")) + + select(point(1, 2), point(0, 1)) + + assertEquals("bc\n\nde", editor.textRangePlainText()) + select(point(0, 2), point(0, 1)) + assertEquals("b", editor.textRangePlainText()) + } + + @Test + fun selectAllCoversEveryBlockFromStartToEnd() { + load(text("abc"), Block.SeparatorBlock(), text("def")) + + editor.textRange.selectAll() + + assertEquals(3, editor.textRange.blockCount()) + assertEquals("abc\n\n---\n\ndef", editor.textRangePlainText()) + } + + @Test + fun clearingForgetsIt() { + load(text("a")) + select(point(0, 0), point(0, 1)) + + editor.textRange.clear() + + assertFalse(editor.textRange.isActive) + editor.textRange.extendTo(point(0, 1)) + assertFalse(editor.textRange.isActive) + } + + @Test + fun aSelectionWhoseBlockWasRemovedResolvesToNothing() { + load(text("a"), text("b"), text("c")) + select(point(0, 0), point(2, 1)) + + editor.removeBlock(editor.blocks[2], BlockRemoveAction.REMOVE, refocus = false) + + assertEquals(0, editor.textRange.blockCount()) + assertFalse(editor.replaceTextRange("")) + } + + @Test + fun offsetsOutsideTheTextAreKeptInside() { + load(text("abc"), text("def")) + + select(point(0, -5), point(1, 99)) + + assertEquals("abc\n\ndef", editor.textRangePlainText()) + } + + @Test + fun theEmojiAtAnEdgeIsNeverCutInTwo() { + load(text("a😀b"), text("c😀d")) + + // The start is in the middle of the first emoji, the end in the middle of the second one. + select(point(0, 2), point(1, 2)) + + assertEquals("😀b\n\nc😀", editor.textRangePlainText()) + } + + @Test + fun anOffsetBetweenTwoUnrelatedSurrogatesIsLeftAlone() { + load(text("😀😀")) + + select(point(0, 2), point(0, 4)) + + assertEquals("😀", editor.textRangePlainText()) + } + + // --- what it covers ---------------------------------------------------------------------------- + + @Test + fun theMiddleOfTheSelectionIsWholeAndEveryKindOfBlockIsIncluded() { + val bullets = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("one"), ItemListBlock("two"))), + BlockType.LIST_BULLET, + ) + load(heading("Head"), quote("said"), Block.SeparatorBlock(), bullets, text("tail")) + + select(point(0, 2), point(4, 2)) + + assertEquals("ad\n\nsaid\n\n---\n\n- one\n- two\n\nta", editor.textRangePlainText()) + // The copies share nothing with the note. + assertEquals("Head", (editor.blocks[0] as Block.HBlock).text.value) + } + + @Test + fun aListIsSelectedFromTheItemAndOffsetAnEndIsIn() { + val checks = Block.ListBlock( + MutableStateFlow( + listOf(ItemListBlock("alpha", isCheck = true), ItemListBlock("beta"), ItemListBlock("gamma")), + ), + BlockType.LIST_CHECK, + ) + load(text("abc"), checks, text("def")) + + select(point(0, 1), TextPoint(checks.id, 2, checks.items.value[1].id)) + assertEquals("bc\n\n[x] alpha\n[ ] be", editor.textRangePlainText()) + + select(TextPoint(checks.id, 1, checks.items.value[1].id), point(2, 1)) + assertEquals("[ ] eta\n[ ] gamma\n\nd", editor.textRangePlainText()) + + select(TextPoint(checks.id, 1, checks.items.value[0].id), TextPoint(checks.id, 3, checks.items.value[2].id)) + assertEquals("[x] lpha\n[ ] beta\n[ ] gam", editor.textRangePlainText()) + } + + // --- replacing, deleting ----------------------------------------------------------------------- + + @Test + fun theRemainsOfTheFirstAndLastBlockJoinAndTheBlocksBetweenGoInOneStep() { + load(heading("Heading"), text("middle"), quote("quoted text")) + select(point(0, 3), point(2, 6)) + + assertTrue(editor.replaceTextRange("")) + + // The first block's type wins. + assertEquals(listOf("h:Hea text"), shown()) + assertFalse(editor.textRange.isActive) + + editor.undo() + assertEquals(listOf("h:Heading", "p:middle", "q:quoted text"), shown()) + assertFalse(editor.canUndo.value) + + editor.redo() + assertEquals(listOf("h:Hea text"), shown()) + } + + @Test + fun typedTextReplacesTheSelectionBetweenTheRemains() { + load(text("one two"), text("three four")) + select(point(0, 4), point(1, 6)) + + editor.replaceTextRange("X") + + assertEquals(listOf("p:one Xfour"), shown()) + } + + @Test + fun aSelectionInsideOneBlockIsAnOrdinaryReplace() { + load(text("hello world")) + select(point(0, 0), point(0, 6)) + + editor.replaceTextRange("") + + assertEquals(listOf("p:world"), shown()) + editor.undo() + assertEquals(listOf("p:hello world"), shown()) + } + + @Test + fun theEmojiAtTheEdgesIsReplacedWhole() { + load(text("a😀b"), text("c😀d")) + select(point(0, 2), point(1, 2)) + + editor.replaceTextRange("") + + assertEquals(listOf("p:ad"), shown()) + } + + @Test + fun aFirstBlockThatIsNotTextLetsTheLastBlocksTypeWin() { + load(Block.SeparatorBlock(), text("middle"), quote("quoted")) + select(point(0, 0), point(2, 3)) + + editor.replaceTextRange("") + + assertEquals(listOf("q:ted"), shown()) + editor.undo() + assertEquals(listOf("SeparatorBlock", "p:middle", "q:quoted"), shown()) + } + + @Test + fun aLastBlockThatIsNotTextLeavesTheFirstBlocksRemainder() { + load(text("keep this"), Block.SeparatorBlock()) + select(point(0, 4), point(1, 0)) + + editor.replaceTextRange("") + + assertEquals(listOf("p:keep"), shown()) + } + + @Test + fun aSelectionOfOnlyBlocksWithoutTextIsReplacedByTheLastBlocksList() { + val bullets = Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("one"))), BlockType.LIST_BULLET) + load(Block.SeparatorBlock(), bullets) + editor.textRange.selectAll() + + editor.replaceTextRange("new") + + // The last block is a list, so the result is a list. + assertEquals(listOf("ListBlock"), shown()) + assertEquals(listOf("new"), (editor.blocks.single() as Block.ListBlock).items.value.map { it.value }) + editor.undo() + assertEquals(listOf("SeparatorBlock", "ListBlock"), shown()) + assertFalse(editor.canUndo.value) + } + + @Test + fun deletingTheWholeNoteLeavesOneEmptyParagraphAndUndoBringsItAllBack() { + load(text("a"), heading("b"), text("c")) + editor.textRange.selectAll() + + editor.replaceTextRange("") + + assertEquals(listOf("p:"), shown()) + editor.undo() + assertEquals(listOf("p:a", "h:b", "p:c"), shown()) + } + + // --- the cursor -------------------------------------------------------------------------------- + + @Test + fun theCursorGoesAfterTheReplacementAndUndoPutsItAtTheHead() = runTest { + load(text("one two"), text("three four")) + select(point(0, 4), point(1, 6)) + val head = editor.textRange.ends.value!!.head + + editor.replaceTextRange("X") + assertEquals(FocusRequest(editor.blocks[0].id, caret = 5), editor.selection.requests.first()) + + val firstId = editor.blocks[0].id + editor.undo() + // Undone, the cursor is back at the end of the selection, in the block it was in. + assertEquals(FocusRequest(head.blockId, caret = head.offset), editor.selection.requests.first()) + assertEquals(2, editor.blocks.size) + editor.redo() + assertEquals(FocusRequest(firstId, caret = 5), editor.selection.requests.first()) + } + + @Test + fun theSelectionEndsWhenAFieldTakesFocusOrAnEditIsUndone() { + load(text("a"), text("b")) + + select(point(0, 0), point(1, 1)) + editor.selection.onFocused(editor.blocks[0].id) + assertFalse(editor.textRange.isActive) + + editor.addBlock(text("c")) + select(point(0, 0), point(1, 1)) + editor.undo() + assertFalse(editor.textRange.isActive) + + select(point(0, 0), point(1, 1)) + load(text("z")) + assertFalse(editor.textRange.isActive) + } + + @Test + fun aSelectionWhoseAnchorBlockWasRemovedResolvesToNothing() { + load(text("a"), text("b"), text("c")) + select(point(0, 0), point(2, 1)) + + editor.removeBlock(editor.blocks[0], BlockRemoveAction.REMOVE, refocus = false) + + assertEquals(0, editor.textRange.blockCount()) + } + + @Test + fun aLoneHighSurrogateIsNotTakenForHalfOfAPair() { + load(text("a\uD83Db")) + + select(point(0, 2), point(0, 3)) + + assertEquals("b", editor.textRangePlainText()) + } + + @Test + fun twoBlocksWithNoTextBecomeAParagraphOfTheReplacement() { + load(Block.SeparatorBlock(), Block.LinkBlock(), text("z")) + select(point(0, 0), point(1, 0)) + + editor.replaceTextRange("new") + + assertEquals(listOf("p:new", "p:z"), shown()) + editor.undo() + assertEquals(listOf("SeparatorBlock", "LinkBlock", "p:z"), shown()) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/UndoCaretTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt similarity index 77% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/UndoCaretTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt index 3fb491a5..283cf8f7 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/UndoCaretTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt @@ -1,13 +1,13 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals /** Where the cursor goes over rounds of undo and redo: the step's own change decides, never a stale position. */ class UndoCaretTest { @@ -40,12 +40,12 @@ class UndoCaretTest { } repeat(3) { round -> - assertEquals("round $round", 13, undone().caret) // before the "!" - assertEquals("round $round", 0, undone().caret) // before the "Y" - assertEquals("round $round", 5, undone().caret) // before the "X" - assertEquals("round $round", 6, redone().caret) // after the "X" - assertEquals("round $round", 1, redone().caret) // after the "Y" - assertEquals("round $round", 14, redone().caret) // after the "!" + assertEquals(13, undone().caret, "round $round") // before the "!" + assertEquals(0, undone().caret, "round $round") // before the "Y" + assertEquals(5, undone().caret, "round $round") // before the "X" + assertEquals(6, redone().caret, "round $round") // after the "X" + assertEquals(1, redone().caret, "round $round") // after the "Y" + assertEquals(14, redone().caret, "round $round") // after the "!" } } @@ -187,4 +187,44 @@ class UndoCaretTest { assertEquals(FocusRequest(paragraph.id, caret = 0), redone()) } } + + @Test + fun anItemJoinedIntoTheOneBelowUndoesToItsStartAndRedoesToTheJoin() = runTest { + val a = ItemListBlock("one") + val b = ItemListBlock("two") + val list = Block.ListBlock(MutableStateFlow(listOf(a, b)), BlockType.LIST_BULLET) + editor.load(listOf(list)) + editor.selection.onFocused(list.id) + step() + editor.listBackspaceAtStart(list.id, b.id) + + repeat(2) { + assertEquals(FocusRequest(list.id, caret = 0, itemId = b.id), undone()) + assertEquals(FocusRequest(list.id, caret = 3, itemId = b.id), redone()) + } + } + + @Test + fun undoAndRedoOfAnEmojiInFrontOfAnotherNeverPutTheCursorInsideAPair() = runTest { + val block = Block.TextBlock(MutableStateFlow("\uD83D\uDE03")) + editor.load(listOf(block)) + editor.selection.onFocused(block.id) + step() + editor.changeValue(block.id, block.text, "\uD83D\uDE00\uD83D\uDE03", mergeable = true) + + assertEquals(0, undone().caret) + assertEquals(2, redone().caret) + // Typing at the caret leaves both emoji whole. + val caret = undone().caret!! + val text = block.text.value + assertEquals("\uD83D\uDE03", text) + assertEquals("a\uD83D\uDE03", text.substring(0, caret) + "a" + text.substring(caret)) + } + + @Test + fun aCaretAtAPairBoundaryIsLeftAlone() { + assertEquals(2, "\uD83D\uDE03a".snappedToCodePoint(2)) + assertEquals(0, "\uD83D\uDE03a".snappedToCodePoint(0)) + assertEquals(1, "\uD83Da".snappedToCodePoint(1)) + } } diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/theme/NoteSpacingTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/theme/NoteSpacingTest.kt new file mode 100644 index 00000000..e282c895 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/theme/NoteSpacingTest.kt @@ -0,0 +1,56 @@ +package com.pasich.encly.editor.theme + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.test.Test +import kotlin.test.assertEquals + +class NoteSpacingTest { + + private val normal = 24.sp + + @Test + fun theDefaultSizeKeepsTheThemeLineHeight() { + assertEquals(24f, EditorLineSpacing.DEFAULT.lineHeight(16.sp, normal).value, 0.01f) + assertEquals(30f, EditorLineSpacing.COMFORTABLE.lineHeight(16.sp, normal).value, 0.01f) + assertEquals(16f, EditorLineSpacing.COMPACT.lineHeight(16.sp, normal).value, 0.01f) + } + + @Test + fun smallTextKeepsTheThemeLineHeight() { + assertEquals(24f, EditorLineSpacing.DEFAULT.lineHeight(12.sp, normal).value, 0.01f) + } + + @Test + fun largeTextGrowsItsLineWithIt() { + assertEquals(48f, EditorLineSpacing.DEFAULT.lineHeight(32.sp, normal).value, 0.01f) + assertEquals(60f, EditorLineSpacing.COMFORTABLE.lineHeight(32.sp, normal).value, 0.01f) + assertEquals(32f, EditorLineSpacing.COMPACT.lineHeight(32.sp, normal).value, 0.01f) + } + + @Test + fun aLineIsNeverShorterThanItsTextExceptWhenCompact() { + for (size in 10..32) { + assertEquals(true, EditorLineSpacing.DEFAULT.lineHeight(size.sp, normal).value >= size * 1.5f - 0.01f) + } + } + + @Test + fun aWholeListSitsInFromTheTextColumnByThreeQuartersOfItsFont() { + assertEquals(12f, listIndent(16.dp).value, 0.01f) + assertEquals(15f, listIndent(20.dp).value, 0.01f) + } + + @Test + fun theIndentOfAListHasALowestAndAHighestValue() { + assertEquals(10f, listIndent(8.dp).value, 0.01f) + assertEquals(18f, listIndent(48.dp).value, 0.01f) + } + + @Test + fun theGapAfterAMarkerFollowsTheFontWithinItsLimits() { + assertEquals(6f, markerGap(12.dp).value, 0.01f) + assertEquals(9f, markerGap(24.dp).value, 0.01f) + assertEquals(14f, markerGap(60.dp).value, 0.01f) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/BlockActionsImplTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/BlockActionsImplTest.kt new file mode 100644 index 00000000..09f69cbf --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/BlockActionsImplTest.kt @@ -0,0 +1,158 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.runtime.mutableStateOf +import com.pasich.encly.editor.focus.BlockFocusRegistry +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.CaretMove +import com.pasich.encly.editor.state.FindState +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The actions a block's fields call: each reaches the controller for that block, or does nothing when it can't. */ +class BlockActionsImplTest { + private val paragraph = Block.TextBlock() + private val next = Block.TextBlock() + private val list = Block.ListBlock(blockType = BlockType.LIST_BULLET) + private val link = Block.LinkBlock() + private val state = BlockEditorState { 0L }.also { it.load(listOf(paragraph, next, list, link)) } + private val controller = StateController(state) + private val registry = BlockFocusRegistry { state.blocks } + private val highlights = FindHighlights(mutableStateOf(FindState())) + + private fun actions(block: Block) = BlockActionsImpl(block, controller, registry, highlights) + + @Test + fun anAddedParagraphGoesAfterTheBlock() { + actions(paragraph).onAddParagraph() + assertEquals(5, state.blocks.size) + assertTrue(state.blocks[1] is Block.TextBlock) + } + + @Test + fun aBlockThatLeftTheNoteAddsAndReplacesNothing() { + val gone = Block.TextBlock() + actions(gone).onAddParagraph() + actions(gone).onReplaceBlock(Block.SeparatorBlock()) + assertEquals(4, state.blocks.size) + } + + @Test + fun replacingAndRemovingActOnTheBlock() { + actions(next).onReplaceBlock(Block.SeparatorBlock()) + assertTrue(state.blocks[1] is Block.SeparatorBlock) + actions(state.blocks[1]).onRemoveBlock(BlockRemoveAction.REMOVE) + assertEquals(3, state.blocks.size) + } + + @Test + fun editsOfTheWrongKindOfBlockAreIgnored() { + actions(paragraph).onListItemsChanged(listOf(ItemListBlock("x")), mergeable = false) + actions(paragraph).onLinkChanged(LinkDataBlock(url = "https://x.org")) + assertNull(actions(paragraph).onListLineBreak("id", LineBreak("", listOf("", ""), ""))) + assertFalse(actions(paragraph).onListBackspaceAtStart("id")) + assertEquals("", (state.blocks[0] as Block.TextBlock).text.value) + } + + @Test + fun editsOfTheRightKindReachTheModel() { + actions(paragraph).onTextChanged("typed") + actions(list).onListItemsChanged(listOf(ItemListBlock("item")), mergeable = false) + actions(link).onLinkChanged(LinkDataBlock(url = "https://x.org")) + actions(paragraph).onCaretMoved(2) + actions(list).onListItemCaret(list.items.value.first().id, 1) + + assertEquals("typed", paragraph.text.value) + assertEquals("item", list.items.value.single().value) + assertEquals("https://x.org", link.block.value.url) + assertEquals(2, state.selection.caretOf(paragraph.id)) + } + + @Test + fun theSlashPickerOfAParagraphIsTheEditorsOne() { + val slash = actions(paragraph).slash + assertEquals(paragraph.id, slash.blockId) + actions(paragraph).onSlashTyped("/", started = true) + assertEquals(paragraph.id, slash.menu.value?.blockId) + slash.setLabels(SlashCommand.entries.associateWith { it.name }) + slash.commit() + slash.moveHighlight(1) + slash.select(SlashCommand.QUOTE) + slash.dismiss() + assertTrue(state.blocks[0] is Block.QuoteBlock) + } + + @Test + fun registrationsAreForTheBlockAndCanBeUndone() { + val a = actions(paragraph) + var offset = -1 + var x = -1f + val undoCaret = a.registerCaret { offset = it } + val undoCaretX = a.registerCaretAtX { value, _ -> x = value } + val undoFields = a.registerFieldsFocusTarget { } + registry.registerFocusTarget(paragraph.id) { } + + registry.focus(com.pasich.encly.editor.state.FocusRequest(paragraph.id, caret = 4)) + registry.focus(com.pasich.encly.editor.state.FocusRequest(paragraph.id, caretX = 7f)) + undoCaret() + undoCaretX() + undoFields() + + assertEquals(4, offset) + assertEquals(7f, x, 0f) + } + + @Test + fun cursorKeysMoveToTheNextFieldOnlyWhereThereIsOne() { + val a = actions(paragraph) + assertFalse(a.navigate(CaretMove.Down(null))) + registry.registerFocusTarget(next.id) { } + assertTrue(a.navigate(CaretMove.Down(null))) + assertFalse(actions(paragraph).navigate(CaretMove.Up(null))) + registry.registerFocusTarget(paragraph.id) { } + assertTrue(actions(next).navigate(CaretMove.Left)) + } + + @Test + fun theFacadeForwardsBreaksShortcutsAndPastes() { + val a = actions(paragraph) + assertTrue(a.onLineBreak(LineBreak("", listOf("", ""), "")) != null) + assertFalse(a.onBackspaceAtStart()) + assertTrue(a.onShortcut("x", Shortcut(BlockType.QUOTE, "> "))) + assertFalse(a.onLinkPasted("https://x.org", LinkDataBlock(url = "https://x.org"))) + } + + @Test + fun selectAllSelectsTheNotesTextWhereTheControllerOffersItAndOtherwiseLeavesTheKey() { + assertTrue(actions(paragraph).selectAllText()) + assertTrue(state.textRange.isActive) + + state.textRange.clear() + controller.offersSelectAll = false + assertFalse(actions(paragraph).selectAllText()) + assertFalse(state.textRange.isActive) + } + + @Test + fun theTextSelectionAcrossBlocksIsTheControllersAndItsOperationsReachIt() { + val a = actions(paragraph) + assertEquals(paragraph.id, a.blockId) + assertEquals(state.textRange, a.textRange) + + state.textRange.selectAll() + assertTrue(a.copyTextRange(markdown = false)) + assertTrue(a.cutTextRange()) + assertEquals(listOf(false, true), controller.copies.map { it.first }) + assertFalse(a.replaceTextRange("x")) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/RangeKeysTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/RangeKeysTest.kt new file mode 100644 index 00000000..81705f98 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/RangeKeysTest.kt @@ -0,0 +1,37 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.ui.input.key.Key +import com.pasich.encly.editor.state.HeadMove +import com.pasich.encly.editor.ui.blocks.headMove +import com.pasich.encly.editor.ui.blocks.noteMove +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RangeKeysTest { + @Test + fun theArrowsMoveByLogicalDirectionSoRightToLeftTextSwapsLeftAndRight() { + assertEquals(HeadMove.PREVIOUS, Key.DirectionLeft.headMove(rtl = false)) + assertEquals(HeadMove.NEXT, Key.DirectionRight.headMove(rtl = false)) + assertEquals(HeadMove.NEXT, Key.DirectionLeft.headMove(rtl = true)) + assertEquals(HeadMove.PREVIOUS, Key.DirectionRight.headMove(rtl = true)) + } + + @Test + fun theOtherMovementKeys() { + assertEquals(HeadMove.UP, Key.DirectionUp.headMove(rtl = false)) + assertEquals(HeadMove.DOWN, Key.DirectionDown.headMove(rtl = false)) + assertEquals(HeadMove.FIELD_START, Key.MoveHome.headMove(rtl = false)) + assertEquals(HeadMove.FIELD_END, Key.MoveEnd.headMove(rtl = false)) + assertEquals(HeadMove.PAGE_UP, Key.PageUp.headMove(rtl = false)) + assertEquals(HeadMove.PAGE_DOWN, Key.PageDown.headMove(rtl = false)) + assertNull(Key.A.headMove(rtl = false)) + } + + @Test + fun homeAndEndWithControlGoToTheEndsOfTheNote() { + assertEquals(HeadMove.NOTE_START, Key.MoveHome.noteMove()) + assertEquals(HeadMove.NOTE_END, Key.MoveEnd.noteMove()) + assertNull(Key.DirectionLeft.noteMove()) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/SelectionKeysTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/SelectionKeysTest.kt new file mode 100644 index 00000000..f754ecd5 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/SelectionKeysTest.kt @@ -0,0 +1,111 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.text.TextRange +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.state.SelectedBlocks +import com.pasich.encly.editor.ui.blocks.coversWholeText +import com.pasich.encly.editor.ui.blocks.isSelectAllKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SelectionKeysTest { + private fun key( + key: Key, + command: Boolean = false, + shift: Boolean = false, + type: KeyEventType = KeyEventType.KeyDown, + ) = selectionKeyOf(key, type, command, shift) + + @Test + fun theKeysOfBlockSelectionAreRecognised() { + assertEquals(SelectionKey.LEAVE, key(Key.Escape)) + assertEquals(SelectionKey.EXTEND_UP, key(Key.DirectionUp, shift = true)) + assertEquals(SelectionKey.EXTEND_DOWN, key(Key.DirectionDown, shift = true)) + assertEquals(SelectionKey.DELETE, key(Key.Delete)) + assertEquals(SelectionKey.DELETE, key(Key.Backspace)) + assertEquals(SelectionKey.SELECT_ALL, key(Key.A, command = true)) + assertEquals(SelectionKey.COPY, key(Key.C, command = true)) + assertEquals(SelectionKey.COPY_MARKDOWN, key(Key.C, command = true, shift = true)) + assertEquals(SelectionKey.CUT, key(Key.X, command = true)) + } + + @Test + fun otherKeysAndKeysComingUpAreNotSelectionKeys() { + assertNull(key(Key.DirectionUp)) + assertNull(key(Key.A)) + assertNull(key(Key.C)) + assertNull(key(Key.X)) + assertNull(key(Key.V, command = true)) + assertNull(key(Key.Escape, type = KeyEventType.KeyUp)) + assertNull(key(Key.Delete, type = KeyEventType.KeyUp)) + } + + // --- Select all pressed twice ------------------------------------------------------------------ + + @Test + fun selectAllIsAWithControlOrCommandAndNoShiftGoingDown() { + assertTrue(isSelectAllKey(Key.A, KeyEventType.KeyDown, command = true, shift = false)) + assertFalse(isSelectAllKey(Key.A, KeyEventType.KeyDown, command = false, shift = false)) + assertFalse(isSelectAllKey(Key.A, KeyEventType.KeyDown, command = true, shift = true)) + assertFalse(isSelectAllKey(Key.A, KeyEventType.KeyUp, command = true, shift = false)) + assertFalse(isSelectAllKey(Key.B, KeyEventType.KeyDown, command = true, shift = false)) + } + + @Test + fun theFirstPressSelectsTheFieldAndTheSecondTheNote() { + assertTrue(coversWholeText(TextRange(0, 5), 5)) + assertTrue(coversWholeText(TextRange(5, 0), 5)) + assertFalse(coversWholeText(TextRange(1, 5), 5)) + assertFalse(coversWholeText(TextRange(0, 4), 5)) + assertFalse(coversWholeText(TextRange(2), 5)) + // An empty field has nothing of its own to select first. + assertTrue(coversWholeText(TextRange(0), 0)) + } + + // --- what a key does --------------------------------------------------------------------------- + + private val blocks = List(3) { Block.TextBlock(MutableStateFlow("b$it")) } + private val selected = SelectedBlocks(blocks) + private val calls = mutableListOf() + + private fun actions(canEdit: Boolean) = SelectedBlocksActions( + canEdit = canEdit, + onCopy = { calls += "copy" }, + onCopyMarkdown = { calls += "markdown" }, + onCut = { calls += "cut" }, + onDelete = { calls += "delete" }, + ) + + @Test + fun keysMoveAndLeaveTheSelection() { + selected.select(blocks[1].id) + + SelectionKey.EXTEND_DOWN.perform(selected, actions(true)) + assertEquals(setOf(blocks[1].id, blocks[2].id), selected.ids.value) + SelectionKey.EXTEND_UP.perform(selected, actions(true)) + assertEquals(setOf(blocks[1].id), selected.ids.value) + SelectionKey.SELECT_ALL.perform(selected, actions(true)) + assertEquals(3, selected.count) + SelectionKey.LEAVE.perform(selected, actions(true)) + assertFalse(selected.isActive) + } + + @Test + fun copyIsAlwaysOfferedAndCutAndDeleteOnlyWhereTheNoteCanChange() { + SelectionKey.COPY.perform(selected, actions(false)) + SelectionKey.COPY_MARKDOWN.perform(selected, actions(false)) + SelectionKey.CUT.perform(selected, actions(false)) + SelectionKey.DELETE.perform(selected, actions(false)) + assertEquals(listOf("copy", "markdown"), calls) + + SelectionKey.CUT.perform(selected, actions(true)) + SelectionKey.DELETE.perform(selected, actions(true)) + assertEquals(listOf("copy", "markdown", "cut", "delete"), calls) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt new file mode 100644 index 00000000..9e01c472 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt @@ -0,0 +1,238 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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 com.pasich.encly.editor.EditorProvider +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockFactory +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.model.TextualBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.SelectedBlocks +import com.pasich.encly.editor.state.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.SlashPicker +import com.pasich.encly.editor.state.TextRangeSelection +import com.pasich.encly.editor.state.addBlockAfter +import com.pasich.encly.editor.state.addBlockToEnd +import com.pasich.encly.editor.state.applyShortcut +import com.pasich.encly.editor.state.applySlash +import com.pasich.encly.editor.state.backspaceAtStart +import com.pasich.encly.editor.state.breakLine +import com.pasich.encly.editor.state.duplicateBlockOf +import com.pasich.encly.editor.state.interactedBlockPosition +import com.pasich.encly.editor.state.listBackspaceAtStart +import com.pasich.encly.editor.state.listLineBreak +import com.pasich.encly.editor.state.listTypingStep +import com.pasich.encly.editor.state.moveBlockOf +import com.pasich.encly.editor.state.moveInteracted +import com.pasich.encly.editor.state.moveListItem +import com.pasich.encly.editor.state.pasteCopied +import com.pasich.encly.editor.state.pasteLink +import com.pasich.encly.editor.state.replaceTextRange +import com.pasich.encly.editor.state.shiftListItem +import com.pasich.encly.editor.state.tableTypingStep +import com.pasich.encly.editor.state.textRangePlainText +import com.pasich.encly.editor.state.toggleChecklistLock +import com.pasich.encly.editor.ui.sheets.BlockSheetKind +import kotlinx.coroutines.flow.StateFlow + +/** + * An [EditorController] over a plain [BlockEditorState], doing what a note's view model does + * without the gating of a locked or read-only note: the editor's blocks can be driven on their own, + * with nothing of the app around them. + */ +internal class StateController(val state: BlockEditorState) : EditorController { + var editingLinks by mutableStateOf(emptySet()) + private set + + override val blocks: List get() = state.blocks + + override val slashPicker: SlashPicker get() = state.slash + + override val selectedBlocks: SelectedBlocks get() = state.pickedBlocks + + override val interactedBlockId: StateFlow get() = state.selection.interactedBlockId + + override val textRange: TextRangeSelection get() = state.textRange + + /** Whether Select all is offered (false: a note in simple editing). */ + var offersSelectAll = true + + /** What was copied or cut from a text selection: Markdown or not, and the plain text. */ + val copies = mutableListOf>() + + override fun selectAllText(): Boolean = offersSelectAll.also { if (it) state.textRange.selectAll() } + + override fun replaceTextRange(replacement: String): Boolean = state.replaceTextRange(replacement) + + override fun copyTextRange(markdown: Boolean): Boolean { + copies += markdown to state.textRangePlainText() + return true + } + + override fun cutTextRange(): Boolean = copyTextRange(markdown = true) && state.replaceTextRange("") + + override fun indexOfBlock(block: Block): Int = state.blocks.indexOfFirst { it.id == block.id } + + override fun onBlockFocused(block: Block) = state.selection.onFocused(block.id) + + override fun onBlockFocusLost(block: Block) { + state.selection.onFocusLost(block.id) + if (state.slash.menu.value?.blockId == block.id) state.slash.dismiss() + } + + override fun onBlockInteraction(block: Block) { + state.selection.onInteraction(block.id) + state.slash.dismiss() + } + + override fun addBlockAfter(afterIndex: Int, blockType: BlockType) = + state.addBlockAfter(afterIndex, BlockFactory.createBlock(blockType)) + + override fun addBlockToEnd() = state.addBlockToEnd() + + override fun removeBlock(block: Block, action: BlockRemoveAction) = state.removeBlock(block, action) + + override fun replaceBlock(index: Int, newBlock: Block): Boolean = state.replaceBlock(index, newBlock) + + override fun moveBlock(up: Boolean) = state.moveInteracted(up) + + override fun moveBlockOf(block: Block, up: Boolean) = state.moveBlockOf(block.id, up) + + override fun interactedBlockPosition(): Int = state.interactedBlockPosition() + + override fun duplicateBlock(block: Block) { + state.duplicateBlockOf(block.id) + } + + override fun toggleChecklistLock(block: Block) { + state.toggleChecklistLock(block.id) + } + + override fun editLink(block: Block.LinkBlock) { + editingLinks = editingLinks + block.id + } + + override fun onBlockTextChanged(block: Block, newText: String) { + if (block is TextualBlock) state.changeValue(block.id, block.text, newText, mergeable = true) + } + + override fun onListItemsChanged(block: Block.ListBlock, newItems: List, mergeable: Boolean) { + val step = if (mergeable) listTypingStep(block.items.value, newItems) else null + state.changeValue(block.id, block.items, newItems, mergeable, step) + } + + override fun onTableChanged(block: Block.TableBlock, newTable: TableData, mergeable: Boolean) { + val step = if (mergeable) tableTypingStep(block.table.value, newTable) else null + state.changeValue(block.id, block.table, newTable, mergeable, step) + } + + override fun onLinkChanged(block: Block.LinkBlock, newLink: LinkDataBlock) { + editingLinks = editingLinks - block.id + state.changeValue(block.id, block.block, newLink, mergeable = false) + } + + override fun onLineBreak(block: Block, lineBreak: LineBreak): String? { + val picked = state.slash.menu.value + ?.takeIf { it.blockId == block.id && lineBreak.isEnter && it.isShown } + ?.highlightedCommand + return if (picked != null && state.applySlash(block.id, picked)) "" else state.breakLine(block.id, lineBreak) + } + + override fun onPasteCopied(block: Block, itemId: String?, head: String, pasted: String, tail: String): String? = + state.pasteCopied(block.id, itemId, head, pasted, tail) + + override fun onBackspaceAtStart(block: Block): Boolean = state.backspaceAtStart(block.id) + + override fun onShortcut(block: Block, typed: String, shortcut: Shortcut): Boolean = + state.applyShortcut(block.id, typed, shortcut) + + override fun onLinkPasted(block: Block, pasted: String, link: LinkDataBlock): Boolean = + state.pasteLink(block.id, pasted, link) + + override fun onListItemShift(block: Block.ListBlock, itemId: String, deeper: Boolean): Boolean = + state.shiftListItem(block.id, itemId, deeper) + + override fun onListItemMove(block: Block.ListBlock, itemId: String, by: Int): Boolean = + state.moveListItem(block.id, itemId, by) + + override fun onListLineBreak(block: Block.ListBlock, itemId: String, lineBreak: LineBreak): String? = + state.listLineBreak(block.id, itemId, lineBreak) + + override fun onListBackspaceAtStart(block: Block.ListBlock, itemId: String): Boolean = + state.listBackspaceAtStart(block.id, itemId) + + override fun onCaretMoved(block: Block, offset: Int) = state.selection.onCaret(block.id, offset) + + override fun onListItemCaret(block: Block, itemId: String, offset: Int) = + state.selection.onItemCaret(block.id, itemId, offset) + + override fun onSlashTyped(block: Block, text: String, started: Boolean) { + if (started && block is Block.TextBlock) state.slash.open(block.id) + state.slash.onText(block.id, text) + } + + override fun applySlash(block: Block, command: SlashCommand) { + state.applySlash(block.id, command) + } +} + +/** The editor's blocks in a list, as a note screen shows them, with focus carried out as the screen does. */ +@Composable +internal fun EditorHarness( + controller: StateController, + onOpenSheet: (BlockSheetKind, Block) -> Unit, + isLocked: Boolean = false, + afterBlocks: (@Composable () -> Unit)? = null, + bar: (@Composable () -> Unit)? = null, +) { + val state = controller.state + MaterialTheme { + EditorProvider { + val registry = rememberBlockFocusRegistry(state.blocks) + val findState = state.find.state.collectAsState() + val highlights = remember(findState) { FindHighlights(findState) } + val host = remember(controller, registry, highlights) { + EditorBlocksHost(controller, registry, highlights, onOpenSheet) + } + LaunchedEffect(registry) { + state.selection.immediateFocus = { registry.focus(it) } + state.selection.requests.collect { registry.focusWhenComposed(it) } + } + val listState = rememberLazyListState() + val list: @Composable (Modifier) -> Unit = { modifier -> + LazyColumn(modifier, listState) { + editorBlocks(host, state.blocks, isLocked, controller.editingLinks, afterBlocks) + } + } + Box { + if (bar == null) { + list(Modifier) + } else { + Column { + list(Modifier.weight(1f)) + bar() + } + } + TextRangeOverlay(host, listState) + } + } + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHostTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHostTest.kt new file mode 100644 index 00000000..84cc2a5a --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHostTest.kt @@ -0,0 +1,94 @@ +package com.pasich.encly.editor.ui.blocks + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [toAsciiHost] on a fixed set of hosts, with the answers `java.net.IDN.toASCII` gave on the JVM. The test runs on + * the JVM targets against the real IDN (which proves the table) and on iOS against Nameprep and Punycode (which + * have to agree with it). The hosts arrive lower-cased, as `parseNoteLink` passes them. + * + * Left out on purpose: names with an emoji or another code point Unicode 3.2 had not assigned (the JDK rejects + * them, iOS accepts them, as browsers do), and the bidirectional rules of RFC 3454 (iOS does not check them). + */ +class AsciiHostTest { + private val answers = mapOf( + "bücher.de" to "xn--bcher-kva.de", + "münchen.example" to "xn--mnchen-3ya.example", + "аpple.com" to "xn--pple-43d.com", + "пример.рф" to "xn--e1afmkfd.xn--p1ai", + "www.bücher.de" to "www.xn--bcher-kva.de", + "ñandú.es" to "xn--and-6ma2c.es", + "日本語.jp" to "xn--wgv71a119e.jp", + "例え.テスト" to "xn--r8jz45g.xn--zckzah", + "bücher.de." to "xn--bcher-kva.de.", + // Normalization (NFKC): fullwidth letters, ligatures, superscripts, circled digits, composed accents. + "example.com" to "example.com", + "bücher.de" to "xn--bcher-kva.de", + "www.example.com" to "www.example.com", + "fish.com" to "fish.com", + "x².com" to "x2.com", + "①.com" to "1.com", + "é.com" to "xn--9ca.com", + "å.se" to "xn--5ca.se", + "á.com" to "xn--1ca.com", + // Case folding beyond lower-casing. + "σασ.gr" to "xn--mxa9ab.gr", + "ǰ.com" to "xn--ska.com", + "ẖ.com" to "xn--ckg.com", + "i̇stanbul.tr" to "xn--istanbul-o0e.tr", + // Characters that map to nothing. + "ex­ample.de" to "example.de", + "ab.com" to "ab.com", + "ö​ö.de" to "xn--ndaa.de", + // The other IDNA dots separate labels. + "bücher。de" to "xn--bcher-kva.de", + "bücher.de" to "xn--bcher-kva.de", + "bücher。de" to "xn--bcher-kva.de", + // ASCII is left as it is, an encoded label too. + "a_b.com" to "a_b.com", + "xn--bcher-kva.de" to "xn--bcher-kva.de", + ) + + private val invalid = listOf( + // Prohibited: private use. + ".com", + // Starts with the ACE prefix after preparation. + "xn--ü.com", + // Empty labels. + "a..bü", + ".bü", + "bü..de", + // A label over 63 characters once encoded, and one over it as typed. + "ж".repeat(60) + ".com", + "ü" + "a".repeat(70) + ".com", + ) + + @Test + fun theHostsGetTheAnswersOfJavaNetIdn() { + answers.forEach { (host, ascii) -> assertEquals(ascii, toAsciiHost(host), host) } + } + + @Test + fun theCardNeverNamesADifferentSiteThanTheBrowserOpens() { + // IDNA 2003 (java.net.IDN, Nameprep) maps these away; browsers keep them: "faß.de" opens "xn--fa-hia.de". + val deviations = listOf( + "faß.de", + "straße.de", + "fußball.de", + "ς.gr", + "σας.gr", + "fa\u1E9E.de", + "a\u200Db.com", + "a\u200Cb.com", + ) + deviations.forEach { host -> assertEquals(null, displayHost(host), host) } + assertEquals("xn--bcher-kva.de", displayHost("Bücher.de")) + assertEquals("example.com", displayHost("Example.com")) + } + + @Test + fun theInvalidNamesAreRefused() { + invalid.forEach { host -> assertEquals(null, toAsciiHost(host), host) } + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/CellValuesTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/CellValuesTest.kt new file mode 100644 index 00000000..75c3f9df --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/CellValuesTest.kt @@ -0,0 +1,45 @@ +package com.pasich.encly.editor.ui.blocks + +import com.pasich.encly.editor.model.TableData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class CellValuesTest { + private val start = TableData.of(List(40) { r -> List(5) { c -> "r$r c$c" } }) + private val model = MutableStateFlow(start) + private val values = CellValues(model) + private val cells = start.rows.flatMap { it.cells } + + @Test + fun everyCellReadsItsTextFromOneMapBuiltOncePerTable() { + cells.forEach { assertEquals(it.value, values.valueOf(it.id)) } + + assertEquals(1, values.builds) + } + + @Test + fun aNewTableIsReadAgainAndAMissingCellIsNull() { + values.valueOf(cells[0].id) + model.value = start.removeRow(0) + + assertEquals(null, values.valueOf(cells[0].id)) + assertEquals(cells[5].value, values.valueOf(cells[5].id)) + assertEquals(2, values.builds) + } + + @Test + fun aCellFieldIsOnlyToldAboutItsOwnChanges() = runTest(UnconfinedTestDispatcher()) { + val seen = mutableListOf() + val job = launch { values.changes(cells[7].id).collect { seen += it } } + + model.value = model.value.withCell(cells[3].id, "other") + model.value = model.value.withCell(cells[7].id, "mine") + job.cancel() + + assertEquals(listOf("r1 c2", "mine"), seen) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ItemValuesTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ItemValuesTest.kt new file mode 100644 index 00000000..e059a042 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ItemValuesTest.kt @@ -0,0 +1,44 @@ +package com.pasich.encly.editor.ui.blocks + +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ItemValuesTest { + private val items = List(500) { ItemListBlock("item $it") } + private val model = MutableStateFlow(items) + private val values = ItemValues(model) + + @Test + fun everyItemReadsItsTextFromOneMapBuiltOncePerList() { + items.forEach { assertEquals(it.value, values.valueOf(it.id)) } + + assertEquals(1, values.builds) + } + + @Test + fun aNewListIsReadAgainAndAMissingItemIsNull() { + values.valueOf(items[0].id) + model.value = items.drop(1) + + assertEquals(null, values.valueOf(items[0].id)) + assertEquals(items[1].value, values.valueOf(items[1].id)) + assertEquals(2, values.builds) + } + + @Test + fun anItemFieldIsOnlyToldAboutItsOwnChanges() = runTest(UnconfinedTestDispatcher()) { + val seen = mutableListOf() + val job = launch { values.changes(items[7].id).collect { seen += it } } + + model.value = model.value.toMutableList().also { it[3] = it[3].copy(value = "other") } + model.value = model.value.toMutableList().also { it[7] = it[7].copy(value = "mine") } + job.cancel() + + assertEquals(listOf("item 7", "mine"), seen) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/LinkUrlTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/LinkUrlTest.kt similarity index 83% rename from app/src/test/java/com/pasich/encly/presentation/editor/blocks/LinkUrlTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/LinkUrlTest.kt index 5026415a..ef710739 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/LinkUrlTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/LinkUrlTest.kt @@ -1,10 +1,10 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class LinkUrlTest { @@ -47,9 +47,9 @@ class LinkUrlTest { "tel:123", "ftp://example.com", ).forEach { url -> - assertTrue(url, parseNoteLink(url) is NoteLink.Blocked) - assertFalse(url, isOpenableLink(url)) - assertNull(url, buildLinkData(url)) + assertTrue(parseNoteLink(url) is NoteLink.Blocked, url) + assertFalse(isOpenableLink(url), url) + assertNull(buildLinkData(url), url) } } @@ -63,7 +63,7 @@ class LinkUrlTest { "https://bank.com evil.tld", "https://bank.com\u0000.evil.tld", "https:///path", - ).forEach { url -> assertTrue(url, parseNoteLink(url) is NoteLink.Blocked) } + ).forEach { url -> assertTrue(parseNoteLink(url) is NoteLink.Blocked, url) } } @Test diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ListReorderTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ListReorderTest.kt new file mode 100644 index 00000000..3c388d41 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ListReorderTest.kt @@ -0,0 +1,190 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import com.pasich.encly.editor.model.ItemListBlock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The keys, the per-item possibilities and the drag arithmetic behind reordering and nesting list items. */ +class ListReorderTest { + private fun row(text: String, level: Int = 0) = ItemListBlock(text, level = level) + + private fun key( + key: Key, + type: KeyEventType = KeyEventType.KeyDown, + command: Boolean = false, + alt: Boolean = false, + shift: Boolean = false, + ) = listItemKeyOf(key, type, command, alt, shift) + + // a0 b1 c2 d1 e0 + private val rows = listOf(row("a"), row("b", 1), row("c", 2), row("d", 1), row("e")) + + // --- keys ------------------------------------------------------------------------------------- + + @Test + fun tabIndentsAndShiftTabOutdents() { + assertEquals(ListItemKey.INDENT, key(Key.Tab)) + assertEquals(ListItemKey.OUTDENT, key(Key.Tab, shift = true)) + } + + @Test + fun altShiftUpAndDownMoveTheItem() { + assertEquals(ListItemKey.MOVE_UP, key(Key.DirectionUp, alt = true, shift = true)) + assertEquals(ListItemKey.MOVE_DOWN, key(Key.DirectionDown, alt = true, shift = true)) + } + + @Test + fun theBlockMoveChordAndPlainArrowsAreNotListItemKeys() { + // Ctrl or Cmd+Shift+Arrow moves the whole block; with Alt as well it is neither. + assertNull(key(Key.DirectionUp, command = true, shift = true)) + assertNull(key(Key.DirectionDown, command = true, shift = true)) + assertNull(key(Key.DirectionUp, command = true, alt = true, shift = true)) + assertNull(key(Key.DirectionUp, command = true, alt = true, shift = true)) + assertNull(key(Key.DirectionUp)) + assertNull(key(Key.DirectionDown, shift = true)) + assertNull(key(Key.DirectionUp, alt = true)) + assertNull(key(Key.DirectionDown, alt = true)) + } + + @Test + fun otherKeysAndKeysComingUpAreNotListItemKeys() { + assertNull(key(Key.Tab, type = KeyEventType.KeyUp)) + assertNull(key(Key.Tab, command = true)) + assertNull(key(Key.Tab, command = true, alt = true)) + assertNull(key(Key.Tab, alt = true)) + assertNull(key(Key.A)) + assertNull(key(Key.DirectionUp, type = KeyEventType.KeyUp, alt = true, shift = true)) + } + + // --- what each item can do -------------------------------------------------------------------- + + @Test + fun anItemCanMoveWhereItHasASiblingAndNestWhereTheItemAboveAllowsIt() { + val moves = movesOf(rows) + + // a: first of its siblings, e is the other; nothing above to nest under, nothing to come out of. + assertEquals(listOf(false, true, false, false), moves[0].let { listOf(it.up, it.down, it.indent, it.outdent) }) + // b: first child of a, d is its next sibling; it is one deeper than a already. + assertEquals(listOf(false, true, false, true), moves[1].let { listOf(it.up, it.down, it.indent, it.outdent) }) + // c: the only child of b; at the deepest level. + assertEquals(listOf(false, false, false, true), moves[2].let { listOf(it.up, it.down, it.indent, it.outdent) }) + // d: after b, and it can nest under c? c is at 2, d at 1: it can go to 2. + assertEquals(listOf(true, false, true, true), moves[3].let { listOf(it.up, it.down, it.indent, it.outdent) }) + // e: after a, and it can nest under d. + assertEquals(listOf(true, false, true, false), moves[4].let { listOf(it.up, it.down, it.indent, it.outdent) }) + } + + @Test + fun anItemWhoseChildrenAreAtTheDeepestLevelCannotNestFurther() { + val moves = movesOf(listOf(row("a"), row("b", 1), row("c", 2), row("d", 1), row("e", 2))) + + // d with child e: d could go under b's child c, but e would pass the deepest level. + assertFalse(moves[3].indent) + assertTrue(moves[3].outdent) + } + + @Test + fun aListOfOneItemCanDoNothingButOutdentIfNested() { + val moves = movesOf(listOf(row("only"))) + + assertFalse(moves.single().up || moves.single().down || moves.single().indent || moves.single().outdent) + } + + // --- drag ------------------------------------------------------------------------------------- + + private fun placed(drag: ListDrag, rows: List, height: Float = 10f) { + rows.forEachIndexed { at, row -> drag.place(row.id, at * height, (at + 1) * height) } + } + + @Test + fun aDragMarksTheItemAndItsChildrenAsMovingAndEndsClean() { + val drag = ListDrag { rows } + placed(drag, rows) + + drag.begin(rows[1].id) + + assertEquals(rows[1].id, drag.itemId) + assertEquals(setOf(rows[1].id, rows[2].id), drag.moving) + drag.by(4f) + drag.by(3f) + assertEquals(7f, drag.offset) + + drag.end() + + assertNull(drag.itemId) + assertTrue(drag.moving.isEmpty()) + assertEquals(0f, drag.offset) + } + + @Test + fun aDragOfAnItemThatIsNotThereDoesNotStart() { + val drag = ListDrag { rows } + + drag.begin("nope") + + assertNull(drag.itemId) + assertEquals(0, drag.placesMoved()) + assertNull(drag.dropLineAt()) + } + + @Test + fun anItemTakenDownOverItsNextSiblingHasMovedOnePlaceAndALineShowsWhere() { + val drag = ListDrag { rows } + placed(drag, rows) + drag.begin(rows[0].id) + + // a with b c d is 0-40; dragged 30 down its middle is at 50, over e (40-50). + drag.by(30f) + + assertEquals(1, drag.placesMoved()) + assertEquals(50f, drag.dropLineAt()) + } + + @Test + fun anItemTakenUpOverItsPreviousSiblingHasMovedOnePlaceAndALineShowsWhere() { + val drag = ListDrag { rows } + placed(drag, rows) + drag.begin(rows[4].id) + + drag.by(-30f) + + assertEquals(-1, drag.placesMoved()) + assertEquals(0f, drag.dropLineAt()) + } + + @Test + fun anItemThatHasNotLeftItsOwnRowsHasNotMoved() { + val drag = ListDrag { rows } + placed(drag, rows) + drag.begin(rows[3].id) + + drag.by(2f) + + assertEquals(0, drag.placesMoved()) + assertNull(drag.dropLineAt()) + } + + @Test + fun anItemWithoutKnownPositionsHasNotMoved() { + val drag = ListDrag { rows } + drag.begin(rows[0].id) + drag.by(100f) + + assertEquals(0, drag.placesMoved()) + } + + @Test + fun anItemWhoseLastChildHasNoKnownPositionHasNotMoved() { + val drag = ListDrag { rows } + drag.place(rows[0].id, 0f, 10f) + drag.begin(rows[0].id) + drag.by(100f) + + assertEquals(0, drag.placesMoved()) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/PunycodeTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/PunycodeTest.kt new file mode 100644 index 00000000..0ceaa8c3 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/PunycodeTest.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.editor.ui.blocks + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The pure-Kotlin encoder iOS uses, and the platform's `toAsciiHost` it has to agree with. */ +class PunycodeTest { + private val hosts = mapOf( + // Latin with diacritics: basic characters stay in front. + "bücher.de" to "xn--bcher-kva.de", + "münchen.example" to "xn--mnchen-3ya.example", + // The homograph the link card guards against: a Cyrillic "а" in an otherwise Latin name. + "аpple.com" to "xn--pple-43d.com", + // A whole label in another script, two labels. + "пример.рф" to "xn--e1afmkfd.xn--p1ai", + // Only the label with a non-ASCII character changes. + "www.bücher.de" to "www.xn--bcher-kva.de", + // Several non-ASCII characters, repeated and out of order. + "ñandú.es" to "xn--and-6ma2c.es", + "\u65E5\u672C\u8A9E.jp" to "xn--wgv71a119e.jp", + ) + + // Checked against Python's independent punycode codec; the platform's IDN does not take all of them. + private val encoderOnly = mapOf( + // Far-apart scripts: the bias adapts over a large gap. + "\u044F\u65E5\u672C.com" to "xn--41a8701cb2a.com", + "\u00E9\u65E5" to "xn--9ca8182c", + // A code point outside the basic plane between two ASCII letters. + "a\uD83D\uDCA9b.la" to "xn--ab-8t72a.la", + ) + + @Test + fun encodesNamesWithFarApartScripts() { + encoderOnly.forEach { (host, ascii) -> assertEquals(ascii, Punycode.toAsciiHost(host), host) } + } + + @Test + fun encodesTheReferenceNames() { + hosts.forEach { (host, ascii) -> assertEquals(ascii, Punycode.toAsciiHost(host), host) } + } + + @Test + fun aSurrogatePairIsOneCodePoint() { + assertEquals("xn--ls8h.la", Punycode.toAsciiHost("\uD83D\uDCA9.la")) + } + + @Test + fun thePlatformAgreesWithTheEncoder() { + hosts.forEach { (host, ascii) -> assertEquals(ascii, toAsciiHost(host), host) } + } + + @Test + fun anAsciiNameIsKept() { + assertEquals("example.com", Punycode.toAsciiHost("example.com")) + } + + @Test + fun aTrailingRootDotIsKept() { + assertEquals("xn--bcher-kva.de.", Punycode.toAsciiHost("bücher.de.")) + } + + @Test + fun anEmptyLabelMakesTheNameInvalid() { + assertNull(Punycode.toAsciiHost("a..b")) + assertNull(Punycode.toAsciiHost(".example.com")) + assertNull(Punycode.toAsciiHost("")) + assertNull(toAsciiHost("a..bü")) + } + + @Test + fun aLabelLongerThanDnsAllowsMakesTheNameInvalid() { + assertNull(Punycode.toAsciiHost("ü" + "a".repeat(70) + ".com")) + // Short as typed, too long once encoded. + assertNull(Punycode.toAsciiHost("ж".repeat(60) + ".com")) + } + + @Test + fun aLoneSurrogateIsEncodedAsItIs() { + assertTrue(Punycode.toAsciiHost("\uD83D")!!.startsWith("xn--")) + assertTrue(Punycode.toAsciiHost("\uDCA9")!!.startsWith("xn--")) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ReadingDirectionTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ReadingDirectionTest.kt new file mode 100644 index 00000000..e394c556 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ReadingDirectionTest.kt @@ -0,0 +1,66 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.ui.unit.LayoutDirection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** The first-strong-character rule that sets the direction of a paragraph and of what sits around it. */ +class ReadingDirectionTest { + private val rtl = LayoutDirection.Rtl + private val ltr = LayoutDirection.Ltr + + @Test + fun latinTextReadsLeftToRight() { + assertEquals(ltr, firstStrongDirection("hello")) + assertEquals(ltr, firstStrongDirection("Привіт")) + assertEquals(ltr, firstStrongDirection("你好")) + } + + @Test + fun letterOfARightToLeftScriptReadsRightToLeft() { + assertEquals(rtl, firstStrongDirection("مرحبا")) + assertEquals(rtl, firstStrongDirection("שלום")) + assertEquals(rtl, firstStrongDirection("ܫܠܡܐ")) + assertEquals(rtl, firstStrongDirection("ދ\u07A8ވ\u07ACހ\u07A8")) + assertEquals(rtl, firstStrongDirection("ߊߟߏ")) + } + + @Test + fun presentationFormsOfArabicAndHebrewReadRightToLeft() { + assertEquals(rtl, firstStrongDirection("\uFB2A")) + assertEquals(rtl, firstStrongDirection("\uFDF2")) + assertEquals(rtl, firstStrongDirection("\uFE8D")) + } + + @Test + fun theFirstStrongCharacterWinsInMixedText() { + assertEquals(ltr, firstStrongDirection("hello مرحبا")) + assertEquals(rtl, firstStrongDirection("مرحبا hello")) + } + + @Test + fun digitsPunctuationSpacesAndEmojiAreSkipped() { + assertEquals(rtl, firstStrongDirection(" 12. - (مرحبا")) + assertEquals(ltr, firstStrongDirection("12. \uD83D\uDE00 hello")) + // Arabic-Indic digits are numbers, not letters of the script. + assertEquals(ltr, firstStrongDirection("\u0661\u0662 hello")) + // Vowel marks are not letters either. + assertEquals(rtl, firstStrongDirection("\u064Eب")) + } + + @Test + fun aTextWithoutAStrongCharacterHasNoDirection() { + assertNull(firstStrongDirection("")) + assertNull(firstStrongDirection(" ")) + assertNull(firstStrongDirection("123 -- ?!")) + assertNull(firstStrongDirection("\uD83D\uDE00")) + } + + @Test + fun directionalMarksDecideBeforeLettersAfterThem() { + assertEquals(rtl, firstStrongDirection("\u200Fhello")) + assertEquals(ltr, firstStrongDirection("\u200Eمرحبا")) + assertEquals(rtl, firstStrongDirection("\u061C123")) + } +} diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/SpanningTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/SpanningTest.kt new file mode 100644 index 00000000..dff5e632 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/SpanningTest.kt @@ -0,0 +1,21 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.ui.geometry.Rect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SpanningTest { + @Test + fun aMatchOnOneLineSpansFromTheFirstBoxToTheLast() { + assertEquals(Rect(10f, 0f, 50f, 20f), spanning(Rect(10f, 0f, 20f, 20f), Rect(40f, 0f, 50f, 20f))) + } + + @Test + fun aMatchThatWrapsHasANonNegativeSize() { + val wrapped = spanning(Rect(300f, 0f, 320f, 20f), Rect(0f, 20f, 40f, 40f)) + + assertEquals(Rect(0f, 0f, 320f, 40f), wrapped) + assertTrue(wrapped.width > 0 && wrapped.height > 0) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/TextSyncTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt similarity index 77% rename from app/src/test/java/com/pasich/encly/presentation/editor/blocks/TextSyncTest.kt rename to editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt index 513db7b7..40a2a6cb 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/TextSyncTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt @@ -1,11 +1,11 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.TextRange -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class TextSyncTest { @Test @@ -52,4 +52,13 @@ class TextSyncTest { assertEquals("xyz", state.text.toString()) assertEquals(3, state.selection.start) } + + @Test + fun theCursorNeverLandsBetweenTheHalvesOfAnEmoji() { + val state = TextFieldState("\uD83D\uDE03", TextRange(0)) + + state.placeCaret("\uD83D\uDE03", 1) + + assertEquals(0, state.selection.start) + } } diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/components/MenuOffsetTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/components/MenuOffsetTest.kt new file mode 100644 index 00000000..d31ecfd6 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/components/MenuOffsetTest.kt @@ -0,0 +1,18 @@ +package com.pasich.encly.editor.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Where a right click's menu hangs from, in either layout direction. */ +class MenuOffsetTest { + @Test + fun aLeftToRightMenuIsOffsetFromTheLeftEdge() { + assertEquals(30f, menuOffsetFromStart(x = 30f, width = 400, rtl = false)) + } + + @Test + fun aRightToLeftMenuIsOffsetFromTheRightEdge() { + assertEquals(370f, menuOffsetFromStart(x = 30f, width = 400, rtl = true)) + assertEquals(0f, menuOffsetFromStart(x = 400f, width = 400, rtl = true)) + } +} diff --git a/editor/src/desktopMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.desktop.kt b/editor/src/desktopMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.desktop.kt new file mode 100644 index 00000000..db904bbe --- /dev/null +++ b/editor/src/desktopMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.desktop.kt @@ -0,0 +1,5 @@ +package com.pasich.encly.editor.ui.blocks + +import java.net.IDN + +internal actual fun toAsciiHost(host: String): String? = runCatching { IDN.toASCII(host) }.getOrNull() diff --git a/editor/src/desktopMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.desktop.kt b/editor/src/desktopMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.desktop.kt new file mode 100644 index 00000000..72f3a552 --- /dev/null +++ b/editor/src/desktopMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.desktop.kt @@ -0,0 +1,6 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun BlockSelectAllIme(onSelectAll: () -> Boolean, content: @Composable () -> Unit) = content() diff --git a/editor/src/desktopTest/kotlin/com/pasich/encly/editor/EditorBoundaryTest.kt b/editor/src/desktopTest/kotlin/com/pasich/encly/editor/EditorBoundaryTest.kt new file mode 100644 index 00000000..b3e31f40 --- /dev/null +++ b/editor/src/desktopTest/kotlin/com/pasich/encly/editor/EditorBoundaryTest.kt @@ -0,0 +1,104 @@ +package com.pasich.encly.editor + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The editor is a library of its own, so its shared sources may depend on nothing of the app and + * nothing of the platform: no `com.pasich.encly.` import outside `com.pasich.encly.editor`, no + * `android.`, `java.` or `javax.`, no `R`. Compose and `kotlin`/`kotlinx` libraries are what it may + * use. Platform code lives in the `androidMain`, `desktopMain` and `iosMain` source sets, behind + * `expect` declarations. The module may not depend on the app either (see [theModuleDoesNotDependOnTheApp]). + */ +class EditorBoundaryTest { + // Tests run with the module directory as the working directory. + private val moduleRoot = listOf(File("."), File("editor")).first { File(it, "build.gradle.kts").isFile } + private val sourceRoot = File(moduleRoot, "src/commonMain/kotlin/com/pasich/encly/editor") + + private val sources = sourceRoot.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + + private fun File.relative() = relativeTo(sourceRoot).invariantSeparatorsPath + + /** Source lines without comments (line, block and KDoc) and string literals' contents. */ + private fun File.codeLines(): List> { + // A block comment becomes as many line breaks as it had, so line numbers stay right. + val text = BLOCK_COMMENT.replace(readText()) { "\n".repeat(it.value.count { c -> c == '\n' }) } + return text.lines().mapIndexed { index, raw -> + (index + 1) to raw.replace(STRING, "\"\"").substringBefore("//") + } + } + + @Test + fun theSourcesWereFound() { + assertTrue(sources.size > 50, "no sources under $sourceRoot") + } + + @Test + fun noImportReachesOutOfTheEditorOrOntoThePlatform() { + val offenders = sources.flatMap { file -> + file.codeLines().mapNotNull { (number, line) -> + val import = IMPORT.matchEntire(line.trim())?.groupValues?.get(1) ?: return@mapNotNull null + "${file.relative()}:$number import $import".takeUnless { ALLOWED_PREFIXES.any(import::startsWith) } + } + } + assertEquals(emptyList(), offenders, "the editor may not import these") + } + + @Test + fun noCodeNamesThePlatformWithoutAnImport() { + val offenders = sources.flatMap { file -> + file.codeLines().filter { (_, line) -> !line.trim().startsWith("import ") } + .filter { (_, line) -> PLATFORM_USE.containsMatchIn(line) } + .map { (number, line) -> "${file.relative()}:$number ${line.trim()}" } + } + assertEquals(emptyList(), offenders, "the editor may not use the JVM or Android directly") + } + + @Test + fun theModuleDoesNotDependOnTheApp() { + val build = File(moduleRoot, "build.gradle.kts").readText() + assertFalse(Regex("""project\(\s*":(?!editor)""").containsMatchIn(build), "the editor depends on a module") + } + + @Test + fun theCheckSeesWhatItShouldReject() { + assertFalse(IMPORT.matchEntire("import java.net.IDN") == null) + assertTrue(PLATFORM_USE.containsMatchIn("val x = java.util.UUID.randomUUID()")) + assertTrue(PLATFORM_USE.containsMatchIn("val t = System.currentTimeMillis()")) + assertFalse(PLATFORM_USE.containsMatchIn("val t = Clock.System.now()")) + assertTrue(PLATFORM_USE.containsMatchIn("e.javaClass.simpleName")) + assertFalse(PLATFORM_USE.containsMatchIn("val language = Locale.javascript")) + assertTrue(ALLOWED_PREFIXES.none { "com.pasich.encly.R".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "com.pasich.encly.presentation.designsystem.X".startsWith(it) }) + assertTrue(ALLOWED_PREFIXES.none { "androidx.activity.compose.X".startsWith(it) }) + } + + private companion object { + val IMPORT = Regex("""import ([\w.]+)(?: as \w+)?""") + val STRING = Regex(""""(?:[^"\\]|\\.)*"""") + val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""") + + // Compose and the Kotlin libraries; the editor's own package. + val ALLOWED_PREFIXES = listOf( + "com.pasich.encly.editor.", + "androidx.compose.", + "org.jetbrains.compose.", + "kotlin.", + "kotlinx.", + ) + + // Direct uses of the platform that need no import: java.*, android.*, System, javaClass, R. + val PLATFORM_USE = Regex( + listOf( + """\b(?:java|javax|android)\.\w""", + """(? 0 }) { Text("field") } } + + onNodeWithText("field").assertExists() + assertEquals(0, asked) + } +} diff --git a/editor/src/desktopTest/kotlin/com/pasich/encly/editor/ui/components/PointerInteractionTest.kt b/editor/src/desktopTest/kotlin/com/pasich/encly/editor/ui/components/PointerInteractionTest.kt new file mode 100644 index 00000000..3563b6d9 --- /dev/null +++ b/editor/src/desktopTest/kotlin/com/pasich/encly/editor/ui/components/PointerInteractionTest.kt @@ -0,0 +1,119 @@ +package com.pasich.encly.editor.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** The keyboard focus ring that every pressable thing of the editor has on a desktop. */ +@OptIn(ExperimentalTestApi::class) +class PointerInteractionTest { + private val ring = Color.Red + + @Composable + private fun Target(enabled: Boolean) { + CompositionLocalProvider(LocalContentColor provides ring) { + Box( + Modifier + .testTag("target") + .size(40.dp) + .background(Color.White) + .editorInteractive(RectangleShape, enabled) + .clickable(enabled = enabled) {}, + ) + } + } + + private fun ComposeUiTest.show(enabled: Boolean = true) = setContent { Target(enabled) } + + @Test + fun theRingFollowsAShapeThatChanges() = runComposeUiTest { + var round by mutableStateOf(false) + setContent { + CompositionLocalProvider(LocalContentColor provides ring) { + Box( + Modifier + .testTag("target") + .size(40.dp) + .background(Color.White) + .editorInteractive(if (round) CircleShape else RectangleShape) + .clickable {}, + ) + } + } + onNodeWithTag("target").performKeyInput { pressKey(Key.Tab) } + waitForIdle() + assertEquals(ring, onNodeWithTag("target").captureToImage().toPixelMap()[2, 2]) + + round = true + waitForIdle() + + assertNotEquals(ring, onNodeWithTag("target").captureToImage().toPixelMap()[2, 2]) + } + + private fun ComposeUiTest.ringPixel() = onNodeWithTag("target").captureToImage().toPixelMap()[RING_X, RING_Y] + + @Test + fun aKeyboardFocusShowsTheRingInTheContentColour() = runComposeUiTest { + show() + assertEquals(Color.White, ringPixel()) + + onNodeWithTag("target").performKeyInput { pressKey(Key.Tab) } + waitForIdle() + + assertEquals(ring, ringPixel()) + } + + @Test + fun aMouseClickShowsNoRing() = runComposeUiTest { + show() + + onNodeWithTag("target").performMouseInput { click() } + waitForIdle() + + // The ripple's own hover layer tints the pixel; the ring would be the full content colour. + assertNotEquals(ring, ringPixel()) + } + + @Test + fun aDisabledTargetHasNoRingEvenOnTab() = runComposeUiTest { + show(enabled = false) + + onNodeWithTag("target").performKeyInput { pressKey(Key.Tab) } + waitForIdle() + + assertNotEquals(ring, ringPixel()) + } + + private companion object { + // The ring is 2 dp wide, 1 dp inside a 40 dp box; the test density is 1. + const val RING_X = 20 + const val RING_Y = 2 + } +} diff --git a/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.ios.kt b/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.ios.kt new file mode 100644 index 00000000..d085d36f --- /dev/null +++ b/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.ios.kt @@ -0,0 +1,3 @@ +package com.pasich.encly.editor.ui.blocks + +internal actual fun toAsciiHost(host: String): String? = Nameprep.prepare(host)?.let(Punycode::toAsciiHost) diff --git a/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/Nameprep.kt b/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/Nameprep.kt new file mode 100644 index 00000000..55031955 --- /dev/null +++ b/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/Nameprep.kt @@ -0,0 +1,107 @@ +package com.pasich.encly.editor.ui.blocks + +import platform.Foundation.NSString +import platform.Foundation.precomposedStringWithCompatibilityMapping + +/** + * The part of `java.net.IDN.toASCII` (IDNA 2003: Nameprep, RFC 3491, then Punycode) that [Punycode] does not do, + * for iOS, where Kotlin/Native's Foundation has no IDN call. A label with a non-ASCII character goes through + * the Nameprep steps in the order of the RFC: + * - the IDNA dots (U+3002, U+FF0E, U+FF61) separate labels, as they do for the JDK; + * - characters that map to nothing (RFC 3454 table B.1: soft hyphen, zero-width joiners, variation selectors, + * BOM, ...) are dropped, and the case is folded (`ß` is `ss`, final `ς` is `σ`); + * - the label is normalized to NFKC with Foundation's own `precomposedStringWithCompatibilityMapping`; + * - a label with a character Nameprep prohibits (non-ASCII spaces and controls, private use, non-characters, + * surrogates, tagging, bidi controls, ...) makes the name invalid, and so does one that starts with `xn--` + * after all this, which would pass for an already encoded label. + * + * Not done: the bidirectional rules of RFC 3454 section 6, and the rejection of code points that Unicode 3.2 + * had not assigned yet (the JDK refuses a name with an emoji, this accepts it, as browsers do). A name with + * only ASCII characters is returned by [Punycode.toAsciiHost] as it is, as in the JDK. + */ +@Suppress("MagicNumber") // The code point ranges of RFC 3454 are the data of this file. +internal object Nameprep { + private const val ACE_PREFIX = "xn--" + private const val ASCII_END = 0x80 + private const val SUPPLEMENTARY_START = 0x10000 + private const val SURROGATE_BITS = 10 + + /** [host] with its labels prepared, ready for [Punycode]; null when a label is prohibited. */ + fun prepare(host: String): String? { + val labels = host.split('.', IDEOGRAPHIC_FULL_STOP, FULLWIDTH_FULL_STOP, HALFWIDTH_IDEOGRAPHIC_FULL_STOP) + val prepared = ArrayList(labels.size) + for (label in labels) { + prepared += if (label.all { it.code < ASCII_END }) label else prepareLabel(label) ?: return null + } + return prepared.joinToString(".") + } + + private fun prepareLabel(label: String): String? { + val mapped = StringBuilder(label.length) + for (char in label) { + if (isMappedToNothing(char.code)) continue + when (char) { + 'ß' -> mapped.append("ss") + 'ς' -> mapped.append('σ') + else -> mapped.append(char.lowercaseChar()) + } + } + val normalized = (mapped.toString() as NSString).precomposedStringWithCompatibilityMapping.lowercase() + if (normalized.startsWith(ACE_PREFIX) || isProhibited(normalized)) return null + return normalized + } + + /** RFC 3454 table B.1. */ + private fun isMappedToNothing(code: Int): Boolean = code == 0x00AD || code == 0x034F || code == 0x1806 || + code in 0x180B..0x180D || code in 0x200B..0x200D || code == 0x2060 || code in 0xFE00..0xFE0F || + code == 0xFEFF + + /** The prohibited output of the Nameprep profile (RFC 3491 section 5), tables C.1.2 and C.2.2 to C.9. */ + private fun isProhibited(text: String): Boolean { + var index = 0 + while (index < text.length) { + val char = text[index] + val paired = char.isHighSurrogate() && index + 1 < text.length && text[index + 1].isLowSurrogate() + val code = if (paired) { + SUPPLEMENTARY_START + ((char - Char.MIN_HIGH_SURROGATE) shl SURROGATE_BITS) + + (text[index + 1] - Char.MIN_LOW_SURROGATE) + } else { + char.code + } + if (isProhibitedPoint(code, paired)) return true + index += if (paired) 2 else 1 + } + return false + } + + @Suppress("CyclomaticComplexMethod") // One flat list of the RFC's ranges. + private fun isProhibitedPoint(code: Int, paired: Boolean): Boolean = when { + // C.1.2 non-ASCII spaces, C.2.2 non-ASCII controls. + code == 0x00A0 || code == 0x1680 || code in 0x2000..0x200B || code == 0x202F || code == 0x205F || + code == 0x3000 -> true + + code in 0x0080..0x009F || code == 0x06DD || code == 0x070F || code == 0x180E || code == 0x200C || + code == 0x200D || code == 0x2028 || code == 0x2029 || code in 0x2060..0x2063 || + code in 0x206A..0x206F || code == 0xFEFF || code in 0xFFF9..0xFFFC || code in 0x1D173..0x1D17A -> true + + // C.3 private use, C.4 non-characters, C.5 surrogates, C.6 and C.7, C.8 bidi controls and more. + code in 0xE000..0xF8FF || code in 0xF0000..0xFFFFD || code in 0x100000..0x10FFFD -> true + + code in 0xFDD0..0xFDEF || (code and 0xFFFE) == 0xFFFE -> true + + code in 0xD800..0xDFFF && !paired -> true + + code in 0xFFF9..0xFFFD || code in 0x2FF0..0x2FFB -> true + + code == 0x0340 || code == 0x0341 || code == 0x200E || code == 0x200F || code in 0x202A..0x202E -> true + + // C.9 tagging characters. + code == 0xE0001 || code in 0xE0020..0xE007F -> true + + else -> false + } + + private const val IDEOGRAPHIC_FULL_STOP = '。' + private const val FULLWIDTH_FULL_STOP = '.' + private const val HALFWIDTH_IDEOGRAPHIC_FULL_STOP = '。' +} diff --git a/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.ios.kt b/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.ios.kt new file mode 100644 index 00000000..72f3a552 --- /dev/null +++ b/editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.ios.kt @@ -0,0 +1,6 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun BlockSelectAllIme(onSelectAll: () -> Boolean, content: @Composable () -> Unit) = content() diff --git a/fastlane/metadata/android/ar/changelogs/30000.txt b/fastlane/metadata/android/ar/changelogs/30000.txt new file mode 100644 index 00000000..1237b293 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• رمز PIN من 6 إلى 12 رقمًا؛ يبقى الرمز بعد الفتح بعبارة الاسترداد. +• تحديد النص عبر عدة فقرات؛ جداول وكتل برمجية وقوائم متداخلة وسحب العناصر. +• عدة وسوم للملاحظة الواحدة. +• شريط أدوات قابل للتخصيص؛ إخفاء نص الملاحظات في القوائم. +• العربية والتخطيط من اليمين إلى اليسار؛ الإعدادات ضمن النسخة الاحتياطية. +• البصمة تعمل بعد القفل في الخلفية. +• تُفرَّغ سلة المحذوفات بعد 28 يومًا. diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt new file mode 100644 index 00000000..09f62336 --- /dev/null +++ b/fastlane/metadata/android/ar/full_description.txt @@ -0,0 +1,35 @@ +Encly تطبيق ملاحظات مشفرة لملاحظاتك ومهامك الخاصة التي لا تغادر هاتفك أبدًا. ملاحظات دون اتصال بقفل برمز PIN: بلا حساب، بلا سحابة، بلا إعلانات، وبلا إذن للوصول إلى الإنترنت أصلًا. + +تطلب معظم تطبيقات الملاحظات حسابًا وخادمًا. أما Encly فلا يحتاج إلى أيٍّ منهما. ملاحظاتك المشفرة موجودة في مكان واحد فقط: هاتفك. + +استخدمه كمفكرة محمية بكلمة مرور، أو كمذكرات ويوميات خاصة، أو كمكان آمن لإخفاء ملاحظاتك عمّن يلتقط هاتفك. + +خصوصية بالتصميم +• بلا حساب ولا تسجيل ولا تحليلات ولا إعلانات ولا مشتريات داخل التطبيق. +• دون اتصال فعلًا: لا يطلب Encly إذن الوصول إلى الإنترنت (INTERNET)، لذا لا يستطيع إرسال ملاحظاتك إلى أي جهة. +• ملاحظات مشفرة أثناء التخزين: قاعدة بيانات SQLCipher (بتشفير AES-256) بمفتاح عشوائي طوله 256 بت. +• قفل برمز PIN: رمز من 6 إلى 12 رقمًا، مرتبط بالعتاد الآمن في هاتفك، مع فتح اختياري ببصمة الإصبع أو الوجه (القياسات الحيوية القوية فقط). +• يقفل نفسه: بعد مغادرتك التطبيق (15 ثانية افتراضيًا، أو فورًا) أو لحظة إطفاء الشاشة، تُغلق قاعدة البيانات ويُمحى المفتاح من الذاكرة. +• لقطات الشاشة وتسجيل الشاشة ومعاينات التطبيقات الأخيرة محظورة. والنسخ الاحتياطي السحابي في Android معطّل. + +عبارة الاسترداد والنسخ الاحتياطية المشفرة +• عبارة استرداد اختيارية من 12 كلمة: نسيت رمز PIN؟ تفتح العبارة خزنتك وتتيح لك تعيين رمز جديد. +• صدّر كل ملاحظاتك ومهامك ووسومك إلى ملف واحد مشفر (AES-256-GCM) واحفظه حيث تشاء: مجلد أو ذاكرة USB أو مساحتك السحابية الخاصة. لا يرفعه Encly أبدًا. +• استعد نسخك على هاتف جديد بالكلمات الـ12 نفسها، أو ادمج نسخة احتياطية في خزنة موجودة. +• لا يستطيع أحد استرجاع ملاحظاتك نيابةً عنك، ولا حتى المطوّر. احتفظ بالكلمات على الورق. + +مفكرة آمنة ممتعة للكتابة +• محرر كتل: نص وعناوين واقتباسات وقوائم مهام وقوائم نقطية ورقمية وروابط وفواصل. +• وسوم وبحث وعرض كقائمة أو كشبكة وعدة ترتيبات للفرز. +• مهام بأولويات إلى جانب ملاحظاتك مباشرة. +• سلة محذوفات مع إمكانية الاستعادة. +• خمس سمات (ورق، غابة، محيط، جرافيت، منتصف الليل)، ووضع فاتح أو داكن أو حسب النظام، وألوان ديناميكية على Android 12 فما فوق، وثلاث مجموعات خطوط مدمجة في التطبيق. + +مفتوح المصدر +• Encly مفتوح المصدر (Apache-2.0): يمكن لأي شخص التحقق مما يفعله. +• يعمل دون خدمات Google Play. يتطلب Android 8.0 أو أحدث. +• 10 لغات: العربية والإنجليزية والأوكرانية والألمانية والفرنسية والإسبانية والإيطالية والبولندية والبرتغالية والهولندية. اختر لغة لـ Encly وحده من الإعدادات ← اللغة. + +Encly: ملاحظات مشفرة تبقى ملكك. + +سياسة الخصوصية: https://pasichdev.xyz/apps/encly/privacy-policy/ diff --git a/fastlane/metadata/android/ar/short_description.txt b/fastlane/metadata/android/ar/short_description.txt new file mode 100644 index 00000000..a20f5da6 --- /dev/null +++ b/fastlane/metadata/android/ar/short_description.txt @@ -0,0 +1 @@ +مفكرة آمنة برمز PIN: ملاحظات ومهام. بلا حساب ولا إنترنت ولا إعلانات. diff --git a/fastlane/metadata/android/ar/title.txt b/fastlane/metadata/android/ar/title.txt new file mode 100644 index 00000000..3c4a9786 --- /dev/null +++ b/fastlane/metadata/android/ar/title.txt @@ -0,0 +1 @@ +Encly: ملاحظات مشفرة دون اتصال diff --git a/fastlane/metadata/android/de-DE/changelogs/30000.txt b/fastlane/metadata/android/de-DE/changelogs/30000.txt new file mode 100644 index 00000000..6167363a --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• PIN mit 6–12 Ziffern; PIN bleibt nach Entsperren mit der Wiederherstellungsphrase. +• Text über Absätze markieren; Tabellen, Codeblöcke, verschachtelte Listen, Einträge ziehen. +• Mehrere Labels pro Notiz. +• Eigene Werkzeugleiste; Notiztext in Listen ausblenden. +• Arabisch und Rechts-nach-links; Einstellungen im Backup. +• Fingerabdruck klappt nach Sperre im Hintergrund. +• Papierkorb leert sich nach 28 Tagen. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 6fc05652..ad2aa0a9 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -6,7 +6,7 @@ PRIVAT VON ANFANG AN • Kein Konto, keine Registrierung, keine Analyse, keine Werbung, keine In-App-Käufe. • Wirklich offline: Encly fordert die Berechtigung INTERNET gar nicht an und kann deine Notizen daher nirgendwohin senden. • Verschlüsselte Notizen: SQLCipher-Datenbank (AES-256) unter einem zufälligen 256-Bit-Schlüssel. -• Notizen mit Passwortschutz: eine 6-stellige PIN, an die Sicherheitshardware des Telefons gebunden, optional Fingerabdruck oder Gesicht (nur starke Biometrie). +• Notizen mit Passwortschutz: eine PIN mit 6 bis 12 Ziffern, an die Sicherheitshardware des Telefons gebunden, optional Fingerabdruck oder Gesicht (nur starke Biometrie). • Sperrt sich selbst: nach dem Verlassen (standardmäßig nach 15 Sekunden oder sofort) oder sobald der Bildschirm ausgeht, wird die Datenbank geschlossen und der Schlüssel aus dem Speicher gelöscht. • Screenshots, Bildschirmaufnahmen und die Vorschau in „Letzte Apps“ sind blockiert. Die Android-Cloudsicherung ist aus. @@ -26,7 +26,7 @@ EIN SICHERER NOTIZBLOCK, IN DEM MAN GERN SCHREIBT OPEN SOURCE • Encly ist Open Source (Apache-2.0): Jeder kann prüfen, was die App tut. • Läuft ohne Google Play-Dienste. Android 8.0 oder neuer. -• 9 Sprachen: Deutsch, Englisch, Ukrainisch, Französisch, Spanisch, Italienisch, Polnisch, Portugiesisch und Niederländisch. Unter Einstellungen → Sprache wählst du eine Sprache nur für Encly. +• 10 Sprachen: Deutsch, Englisch, Ukrainisch, Französisch, Spanisch, Italienisch, Polnisch, Portugiesisch, Niederländisch und Arabisch. Unter Einstellungen → Sprache wählst du eine Sprache nur für Encly. Encly: verschlüsselte Notizen, die dir gehören. diff --git a/fastlane/metadata/android/en-US/changelogs/30000.txt b/fastlane/metadata/android/en-US/changelogs/30000.txt new file mode 100644 index 00000000..251bb908 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• PIN of 6–12 digits; keep your PIN after a recovery unlock. +• Select text across paragraphs; tables, code blocks, nested lists, drag items into place. +• Give a note several tags. +• Arrange your own toolbar; hide note text in lists. +• Arabic and right-to-left layouts; settings travel with backups. +• Fingerprint unlock works after a background lock; notes keep their creation date. +• The trash empties itself after 28 days. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index c627986e..c38f9bb3 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -8,7 +8,7 @@ PRIVATE BY DESIGN • No account, no sign-up, no analytics, no ads, no in-app purchases. • Truly offline: Encly does not request the INTERNET permission, so it cannot send your notes anywhere. • Encrypted notes at rest: a SQLCipher (AES-256) database under a random 256-bit key. -• PIN lock: a 6-digit PIN tied to your phone's secure hardware, plus optional fingerprint or face unlock (strong biometrics only). +• PIN lock: a PIN of 6 to 12 digits, tied to your phone's secure hardware, plus optional fingerprint or face unlock (strong biometrics only). • Locks itself: after you leave (15 seconds by default, or immediately) or the moment the screen turns off, the database is closed and the key is wiped from memory. • Screenshots, screen recording and recent-apps previews are blocked. Android cloud backup is off. @@ -28,7 +28,7 @@ A SECURE NOTEPAD THAT IS PLEASANT TO WRITE IN OPEN SOURCE • Encly is open source (Apache-2.0): anyone can check what it does. • Works without Google Play Services. Android 8.0 or newer. -• 9 languages: English, Ukrainian, German, French, Spanish, Italian, Polish, Portuguese and Dutch. Pick one for Encly alone in Settings → Language. +• 10 languages: English, Ukrainian, German, French, Spanish, Italian, Polish, Portuguese, Dutch and Arabic. Pick one for Encly alone in Settings → Language. Encly: encrypted notes that stay yours. diff --git a/fastlane/metadata/android/es-ES/changelogs/30000.txt b/fastlane/metadata/android/es-ES/changelogs/30000.txt new file mode 100644 index 00000000..8f1c6eb4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• PIN de 6 a 12 dígitos; el PIN se mantiene tras desbloquear con la frase. +• Selección entre párrafos; tablas, bloques de código, listas anidadas, arrastrar elementos. +• Varias etiquetas por nota. +• Barra de herramientas a tu gusto; ocultar el texto de las notas. +• Árabe y diseño de derecha a izquierda; ajustes en la copia. +• La huella funciona tras el bloqueo en segundo plano. +• La papelera se vacía a los 28 días. diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 0100b5d7..f0c71ec0 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -6,7 +6,7 @@ PRIVADA DESDE EL DISEÑO • Sin cuenta, sin registro, sin analíticas, sin anuncios, sin compras en la app. • Sin conexión de verdad: Encly no pide el permiso INTERNET, así que no puede enviar tus notas a ninguna parte. • Notas cifradas: base de datos SQLCipher (AES-256) con una clave aleatoria de 256 bits. -• Notas con contraseña: un PIN de 6 dígitos ligado al hardware seguro del teléfono y, si quieres, huella o cara (solo biometría fuerte). +• Notas con contraseña: un PIN de 6 a 12 dígitos, ligado al hardware seguro del teléfono y, si quieres, huella o cara (solo biometría fuerte). • Se bloquea sola: al salir (a los 15 segundos por defecto, o al instante) o en cuanto se apaga la pantalla, la base de datos se cierra y la clave se borra de la memoria. • Se bloquean las capturas, la grabación de pantalla y la vista previa en apps recientes. La copia en la nube de Android está desactivada. @@ -26,7 +26,7 @@ UN BLOC DE NOTAS SEGURO Y CÓMODO CÓDIGO ABIERTO • Encly es de código abierto (Apache-2.0): cualquiera puede comprobar lo que hace. • Funciona sin los servicios de Google Play. Android 8.0 o superior. -• 9 idiomas: español, inglés, ucraniano, alemán, francés, italiano, polaco, portugués y neerlandés. Elige un idioma solo para Encly en Ajustes → Idioma. +• 10 idiomas: español, inglés, ucraniano, alemán, francés, italiano, polaco, portugués, neerlandés y árabe. Elige un idioma solo para Encly en Ajustes → Idioma. Encly: notas cifradas que siguen siendo tuyas. diff --git a/fastlane/metadata/android/fr-FR/changelogs/30000.txt b/fastlane/metadata/android/fr-FR/changelogs/30000.txt new file mode 100644 index 00000000..57e1558a --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• PIN de 6 à 12 chiffres ; le PIN reste après un déverrouillage par phrase. +• Sélection sur plusieurs paragraphes ; tableaux, code, listes imbriquées, éléments à glisser. +• Plusieurs libellés par note. +• Barre d'outils personnalisable ; masquer le texte des notes. +• Arabe et mise en page de droite à gauche ; réglages dans la sauvegarde. +• L'empreinte marche après un verrouillage en arrière-plan. +• La corbeille se vide après 28 jours. diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 5ec424fc..94981487 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -6,7 +6,7 @@ PRIVÉ PAR CONCEPTION • Pas de compte, pas d'inscription, pas d'analyse, pas de publicité, pas d'achats intégrés. • Vraiment hors ligne : Encly ne demande pas la permission INTERNET et ne peut donc envoyer vos notes nulle part. • Notes chiffrées : base SQLCipher (AES-256) sous une clé aléatoire de 256 bits. -• Notes protégées par code : un code PIN à 6 chiffres lié au matériel sécurisé du téléphone, plus empreinte ou visage en option (biométrie forte uniquement). +• Notes protégées par code : un code PIN de 6 à 12 chiffres, lié au matériel sécurisé du téléphone, plus empreinte ou visage en option (biométrie forte uniquement). • Verrouillage automatique : après la sortie (15 secondes par défaut, ou immédiatement) ou dès que l'écran s'éteint, la base est fermée et la clé effacée de la mémoire. • Captures d'écran, enregistrement d'écran et aperçu dans les applis récentes sont bloqués. La sauvegarde cloud d'Android est désactivée. @@ -26,7 +26,7 @@ UN BLOC-NOTES SÛR ET AGRÉABLE OPEN SOURCE • Encly est open source (Apache-2.0) : chacun peut vérifier ce qu'elle fait. • Fonctionne sans les services Google Play. Android 8.0 ou plus récent. -• 9 langues : français, anglais, ukrainien, allemand, espagnol, italien, polonais, portugais et néerlandais. Choisissez une langue pour Encly seule dans Paramètres → Langue. +• 10 langues : français, anglais, ukrainien, allemand, espagnol, italien, polonais, portugais, néerlandais et arabe. Choisissez une langue pour Encly seule dans Paramètres → Langue. Encly : des notes chiffrées qui restent à vous. diff --git a/fastlane/metadata/android/it-IT/changelogs/30000.txt b/fastlane/metadata/android/it-IT/changelogs/30000.txt new file mode 100644 index 00000000..47319d91 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• PIN da 6 a 12 cifre; il PIN resta dopo lo sblocco con la frase. +• Selezione tra più paragrafi; tabelle, blocchi di codice, elenchi annidati, trascinamento. +• Più etichette per nota. +• Barra degli strumenti personalizzabile; nascondi il testo delle note. +• Arabo e layout da destra a sinistra; impostazioni nel backup. +• L'impronta funziona dopo il blocco in background. +• Il cestino si svuota dopo 28 giorni. diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index 16bcac9c..eddbda08 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -6,7 +6,7 @@ PRIVATA PER PROGETTO • Nessun account, nessuna registrazione, nessuna analisi, nessuna pubblicità, nessun acquisto in-app. • Davvero offline: Encly non chiede il permesso INTERNET, quindi non può inviare le tue note da nessuna parte. • Note criptate: database SQLCipher (AES-256) con una chiave casuale a 256 bit. -• Note con password: un PIN a 6 cifre legato all'hardware sicuro del telefono e, se vuoi, impronta o volto (solo biometria forte). +• Note con password: un PIN da 6 a 12 cifre, legato all'hardware sicuro del telefono e, se vuoi, impronta o volto (solo biometria forte). • Si blocca da sola: dopo l'uscita (15 secondi per impostazione predefinita, o subito) o appena lo schermo si spegne, il database viene chiuso e la chiave cancellata dalla memoria. • Screenshot, registrazione dello schermo e anteprima nelle app recenti sono bloccati. Il backup cloud di Android è disattivato. @@ -26,7 +26,7 @@ UN BLOCCO NOTE SICURO E PIACEVOLE OPEN SOURCE • Encly è open source (Apache-2.0): chiunque può verificare cosa fa. • Funziona senza Google Play Services. Android 8.0 o successivo. -• 9 lingue: italiano, inglese, ucraino, tedesco, francese, spagnolo, polacco, portoghese e olandese. Scegli una lingua solo per Encly in Impostazioni → Lingua. +• 10 lingue: italiano, inglese, ucraino, tedesco, francese, spagnolo, polacco, portoghese, olandese e arabo. Scegli una lingua solo per Encly in Impostazioni → Lingua. Encly: note criptate che restano tue. diff --git a/fastlane/metadata/android/nl-NL/changelogs/30000.txt b/fastlane/metadata/android/nl-NL/changelogs/30000.txt new file mode 100644 index 00000000..ade366b4 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Pincode van 6–12 cijfers; pincode blijft na ontgrendelen met de herstelzin. +• Tekst over alinea's selecteren; tabellen, codeblokken, geneste lijsten, items slepen. +• Meerdere labels per notitie. +• Eigen werkbalk; notitietekst in lijsten verbergen. +• Arabisch en rechts-naar-links; instellingen in de back-up. +• Vingerafdruk werkt na vergrendeling op de achtergrond. +• De prullenbak leegt zich na 28 dagen. diff --git a/fastlane/metadata/android/nl-NL/full_description.txt b/fastlane/metadata/android/nl-NL/full_description.txt index e67b4455..52b439f8 100644 --- a/fastlane/metadata/android/nl-NL/full_description.txt +++ b/fastlane/metadata/android/nl-NL/full_description.txt @@ -6,7 +6,7 @@ PRIVÉ VANAF HET BEGIN • Geen account, geen registratie, geen analyse, geen advertenties, geen in-app-aankopen. • Echt offline: Encly vraagt de toestemming INTERNET niet aan en kan je notities dus nergens heen sturen. • Versleutelde notities: een SQLCipher-database (AES-256) met een willekeurige 256-bits sleutel. -• Notities met wachtwoord: een 6-cijferige pincode, gebonden aan de beveiligde hardware van de telefoon, optioneel vingerafdruk of gezicht (alleen sterke biometrie). +• Notities met wachtwoord: een pincode van 6 tot 12 cijfers, gebonden aan de beveiligde hardware van de telefoon, optioneel vingerafdruk of gezicht (alleen sterke biometrie). • Vergrendelt zichzelf: na het verlaten (standaard na 15 seconden, of meteen) of zodra het scherm uitgaat, wordt de database gesloten en de sleutel uit het geheugen gewist. • Screenshots, schermopnames en het voorbeeld bij recente apps zijn geblokkeerd. De cloudback-up van Android staat uit. @@ -26,7 +26,7 @@ EEN VEILIG NOTITIEBLOK DAT PRETTIG SCHRIJFT OPEN SOURCE • Encly is open source (Apache-2.0): iedereen kan nagaan wat de app doet. • Werkt zonder Google Play-services. Android 8.0 of nieuwer. -• 9 talen: Nederlands, Engels, Oekraïens, Duits, Frans, Spaans, Italiaans, Pools en Portugees. Kies een taal alleen voor Encly in Instellingen → Taal. +• 10 talen: Nederlands, Engels, Oekraïens, Duits, Frans, Spaans, Italiaans, Pools, Portugees en Arabisch. Kies een taal alleen voor Encly in Instellingen → Taal. Encly: versleutelde notities die van jou blijven. diff --git a/fastlane/metadata/android/pl-PL/changelogs/30000.txt b/fastlane/metadata/android/pl-PL/changelogs/30000.txt new file mode 100644 index 00000000..b616a28d --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• PIN od 6 do 12 cyfr; PIN zostaje po odblokowaniu frazą. +• Zaznaczanie przez wiele akapitów; tabele, bloki kodu, zagnieżdżone listy, przeciąganie. +• Kilka etykiet na notatkę. +• Własny pasek narzędzi; ukrywanie tekstu notatek na listach. +• Arabski i układ od prawej do lewej; ustawienia w kopii. +• Odcisk palca działa po blokadzie w tle. +• Kosz opróżnia się po 28 dniach. diff --git a/fastlane/metadata/android/pl-PL/full_description.txt b/fastlane/metadata/android/pl-PL/full_description.txt index bc39775a..d20052f0 100644 --- a/fastlane/metadata/android/pl-PL/full_description.txt +++ b/fastlane/metadata/android/pl-PL/full_description.txt @@ -6,7 +6,7 @@ PRYWATNOŚĆ OD PODSTAW • Bez konta, rejestracji, analityki, reklam i zakupów w aplikacji. • Naprawdę offline: Encly nie prosi o uprawnienie INTERNET, więc nie może nigdzie wysłać twoich notatek. • Szyfrowane notatki: baza SQLCipher (AES-256) z losowym kluczem 256-bitowym. -• Notatki z hasłem: 6-cyfrowy PIN powiązany z bezpiecznym układem telefonu, opcjonalnie odcisk palca lub twarz (tylko silna biometria). +• Notatki z hasłem: PIN od 6 do 12 cyfr, powiązany z bezpiecznym układem telefonu, opcjonalnie odcisk palca lub twarz (tylko silna biometria). • Blokuje się sama: po wyjściu (domyślnie po 15 sekundach lub od razu) albo gdy ekran się wyłącza, baza jest zamykana, a klucz usuwany z pamięci. • Zrzuty ekranu, nagrywanie ekranu i podgląd w ostatnich aplikacjach są zablokowane. Kopia w chmurze Androida jest wyłączona. @@ -26,7 +26,7 @@ BEZPIECZNY NOTATNIK, W KTÓRYM DOBRZE SIĘ PISZE OTWARTE ŹRÓDŁO • Encly ma otwarty kod (Apache-2.0): każdy może sprawdzić, co robi. • Działa bez Usług Google Play. Android 8.0 lub nowszy. -• 9 języków: polski, angielski, ukraiński, niemiecki, francuski, hiszpański, włoski, portugalski i niderlandzki. Język tylko dla Encly wybierzesz w Ustawienia → Język. +• 10 języków: polski, angielski, ukraiński, niemiecki, francuski, hiszpański, włoski, portugalski, niderlandzki i arabski. Język tylko dla Encly wybierzesz w Ustawienia → Język. Encly: szyfrowane notatki, które zostają twoje. diff --git a/fastlane/metadata/android/pt-PT/changelogs/30000.txt b/fastlane/metadata/android/pt-PT/changelogs/30000.txt new file mode 100644 index 00000000..35abb354 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• PIN de 6 a 12 dígitos; o PIN mantém-se após desbloquear com a frase. +• Seleção entre parágrafos; tabelas, blocos de código, listas aninhadas, arrastar itens. +• Várias etiquetas por nota. +• Barra de ferramentas à sua medida; ocultar o texto das notas. +• Árabe e esquema da direita para a esquerda; definições na cópia. +• A impressão digital funciona após bloqueio em segundo plano. +• O lixo esvazia-se após 28 dias. diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt index 3108a05b..891607a9 100644 --- a/fastlane/metadata/android/pt-PT/full_description.txt +++ b/fastlane/metadata/android/pt-PT/full_description.txt @@ -6,7 +6,7 @@ PRIVADO DESDE A ORIGEM • Sem conta, sem registo, sem análises, sem anúncios, sem compras na app. • Mesmo offline: o Encly não pede a permissão INTERNET, por isso não consegue enviar as suas notas para lado nenhum. • Notas encriptadas: base de dados SQLCipher (AES-256) com uma chave aleatória de 256 bits. -• Notas com palavra-passe: um PIN de 6 dígitos ligado ao hardware seguro do telemóvel e, opcionalmente, impressão digital ou rosto (só biometria forte). +• Notas com palavra-passe: um PIN de 6 a 12 dígitos, ligado ao hardware seguro do telemóvel e, opcionalmente, impressão digital ou rosto (só biometria forte). • Bloqueia-se sozinho: depois de sair (15 segundos por omissão, ou de imediato) ou assim que o ecrã se desliga, a base de dados é fechada e a chave apagada da memória. • Capturas de ecrã, gravação de ecrã e pré-visualização nas apps recentes estão bloqueadas. A cópia na nuvem do Android está desligada. @@ -26,7 +26,7 @@ UM BLOCO DE NOTAS SEGURO E AGRADÁVEL CÓDIGO ABERTO • O Encly tem código aberto (Apache-2.0): qualquer pessoa pode verificar o que faz. • Funciona sem os serviços do Google Play. Android 8.0 ou mais recente. -• 9 idiomas: português, inglês, ucraniano, alemão, francês, espanhol, italiano, polaco e neerlandês. Escolha um idioma só para o Encly em Configurações → Idioma. +• 10 idiomas: português, inglês, ucraniano, alemão, francês, espanhol, italiano, polaco, neerlandês e árabe. Escolha um idioma só para o Encly em Configurações → Idioma. Encly: notas encriptadas que continuam a ser suas. diff --git a/fastlane/metadata/android/uk/changelogs/30000.txt b/fastlane/metadata/android/uk/changelogs/30000.txt new file mode 100644 index 00000000..b7eeae4d --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• PIN від 6 до 12 цифр; PIN лишається після входу фразою відновлення. +• Виділення тексту через кілька абзаців; таблиці, блоки коду, вкладені списки, перетягування пунктів. +• Кілька міток для однієї нотатки. +• Власний тулбар; можна сховати текст нотаток у списках. +• Арабська і інтерфейс справа наліво; налаштування в бекапі. +• Відбиток працює після блокування у фоні; нотатки зберігають дату створення. +• Кошик очищається сам через 28 днів. diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index 7c13e4b1..e0a3fd4e 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -8,7 +8,7 @@ Encly — це захищені нотатки й завдання, зашифр • Без акаунта, реєстрації, аналітики, реклами й покупок у застосунку. • Справді офлайн: Encly не запитує дозвіл INTERNET, тож не може нікуди надіслати ваші нотатки. • Зашифровані нотатки: база SQLCipher (AES-256) під випадковим 256-бітним ключем. -• Нотатки з паролем: 6-значний PIN-код, прив'язаний до захищеного чипа телефона, за бажанням відбиток пальця чи обличчя (лише надійна біометрія). +• Нотатки з паролем: PIN-код від 6 до 12 цифр, прив'язаний до захищеного чипа телефона, за бажанням відбиток пальця чи обличчя (лише надійна біометрія). • Блокується сам: після виходу (за замовчуванням через 15 секунд або одразу) чи щойно вимикається екран база закривається, а ключ стирається з пам'яті. • Знімки й запис екрана та прев'ю в недавніх застосунках заблоковані. Хмарне резервне копіювання Android вимкнене. @@ -28,7 +28,7 @@ Encly — це захищені нотатки й завдання, зашифр ВІДКРИТИЙ КОД • Encly має відкритий код (Apache-2.0): кожен може перевірити, що він робить. • Працює без Google Play Services. Android 8.0 або новіший. -• 9 мов: українська, англійська, німецька, французька, іспанська, італійська, польська, португальська й нідерландська. Мову для Encly можна вибрати окремо в Налаштування → Мова. +• 10 мов: українська, англійська, німецька, французька, іспанська, італійська, польська, португальська, нідерландська й арабська. Мову для Encly можна вибрати окремо в Налаштування → Мова. Encly: зашифровані нотатки, які залишаються вашими. diff --git a/gradle.properties b/gradle.properties index 15fcaed9..e42c6327 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,3 +20,8 @@ org.gradle.configureondemand=true org.gradle.configuration-cache=true android.nonTransitiveRClass=false android.nonFinalResIds=true + +# Apple klibs are built on macOS only (the editor-ios CI job compiles them, runs the iOS tests and checks +# their ABI dumps). :core:crypto links a Swift library that only Xcode can build, so other hosts must not +# cross-compile the iOS klibs; their ABI dumps are inferred there instead. +kotlin.native.enableKlibsCrossCompilation=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c308635..b076379c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,12 +3,15 @@ # KSP releases use their own version scheme; keep the Kotlin/KSP pair compatible. [versions] -agp = "9.4.1" +agp = "9.5.1" kotlin = "2.4.21" -ksp = "2.3.12" -hilt = "2.60.1" +ksp = "2.3.13" +metro = "1.5.0" detekt = "1.23.8" kover = "0.9.11" +dokka = "2.2.0" +# Compose Multiplatform, used by the :editor module; the app keeps the androidx BOM below. +composeMultiplatform = "1.12.2" spotless = "8.10.4" # ktlint and the Compose detekt rules are tool artifacts, not app dependencies (see build.gradle.kts). ktlint = "1.8.0" @@ -21,20 +24,33 @@ lifecycle = "2.11.0" activityCompose = "1.13.0" appcompat = "1.8.0" navigationCompose = "2.10.2" -hiltNavigationCompose = "1.4.0" +# Navigation for Compose Multiplatform (org.jetbrains.androidx); on Android it resolves to the androidx one. +navigationComposeMultiplatform = "2.10.0" coreSplashscreen = "1.2.0" coreKtx = "1.19.1" room = "2.8.5" coroutines = "1.11.0" biometric = "1.1.0" reorderable = "3.1.0" -gson = "2.14.0" -datastore = "1.2.1" -serializationJson = "1.11.0" -sqlcipher = "4.19.1" +datastore = "1.3.0" +serializationJson = "1.12.0" +datetime = "0.8.0" +kotlinxIo = "0.9.1" +# SynchronizedObject and synchronized {} for common code (the locks of :core:security); the Gradle plugin is not used. +atomicfu = "0.33.0" +sqlcipher = "4.20.0" +sealite = "1.0.1" sqliteKtx = "2.7.1" -bip39 = "1.0.9" iconsLucide = "1.1.0" +# The OS secret stores behind :core:keystore on the desktop: DPAPI and the Keychain through JNA, the freedesktop +# Secret Service through D-Bus. +jna = "5.19.1" +dbusJava = "5.1.1" +slf4j = "2.0.17" +# The desktop PDF writer and printer (Apache-2.0): builds the document in memory, with no network code. +pdfbox = "3.0.5" +# Arabic letter shaping and the bidirectional algorithm for the PDF (Unicode License): PDFBox has neither. +icu4j = "78.3" junit = "4.13.2" androidxTestCore = "1.7.0" @@ -44,6 +60,16 @@ mockito = "5.24.0" robolectric = "4.17" [libraries] +# Gradle plugins on the classpath of build-logic (the convention plugins apply them). +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-compose-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } +android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +compose-gradle-plugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "composeMultiplatform" } +spotless-gradle-plugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } +detekt-gradle-plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } +kover-gradle-plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } +dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } + androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } @@ -55,25 +81,44 @@ androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } -androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +compose-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationComposeMultiplatform" } +compose-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +compose-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "lifecycle" } +compose-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } -androidx-sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqliteKtx" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteKtx" } +compose-ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "composeMultiplatform" } +compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } +compose-components-resources ={ module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serializationJson" } -hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } -hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } +kotlinx-serialization-json-io = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-io", version.ref = "serializationJson" } +kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } +androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" } +kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIo" } +metrox-viewmodel = { module = "dev.zacsweers.metro:metrox-viewmodel", version.ref = "metro" } +metrox-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } sqlcipher-android = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" } -kotlin-bip39 = { module = "cash.z.ecc.android:kotlin-bip39", version.ref = "bip39" } +sealite = { module = "io.github.pasichdev:sealite", version.ref = "sealite" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } +dbus-java-core = { module = "com.github.hypfvieh:dbus-java-core", version.ref = "dbusJava" } +dbus-java-transport = { module = "com.github.hypfvieh:dbus-java-transport-native-unixsocket", version.ref = "dbusJava" } +slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } +pdfbox = { module = "org.apache.pdfbox:pdfbox", version.ref = "pdfbox" } +icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } icons-lucide = { module = "com.composables:icons-lucide", version.ref = "iconsLucide" } compose-rules-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "composeRulesDetekt" } @@ -92,7 +137,8 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +androidx-room = { id = "androidx.room", version.ref = "room" } +metro = { id = "dev.zacsweers.metro", version.ref = "metro" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2e..9fa7f288 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a78c88e5..5907d39c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip +distributionSha256Sum=21bd5da2de49b642904077f38c0efdc7778e92b3ed0889392dca61b97e389607 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index a51ec4f5..3185a43f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -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 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -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 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @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 +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 & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/iosApp/Info.plist b/iosApp/Info.plist new file mode 100644 index 00000000..a58bd900 --- /dev/null +++ b/iosApp/Info.plist @@ -0,0 +1,56 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Encly + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + LSApplicationQueriesSchemes + + mailto + + NSFaceIDUsageDescription + Encly uses Face ID to unlock your notes without typing the PIN. Your face never leaves the device and the PIN always works. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/iosApp/README.md b/iosApp/README.md new file mode 100644 index 00000000..49a6744d --- /dev/null +++ b/iosApp/README.md @@ -0,0 +1,47 @@ +# Encly for iOS + +The iPhone and iPad app. It is a thin SwiftUI shell around the Kotlin framework `Shared` (the `:shared` module): +the shell shows `MainViewController()`, and the vault, the lock, the screens and every security rule are Kotlin. +Nothing in the project asks for the network, the camera or any other permission: the only usage description is +`NSFaceIDUsageDescription` for the optional Face ID / Touch ID unlock. + +| Path | What it is | +|---|---| +| `iosApp.xcodeproj` | The Xcode project (checked in; no generator). The `Sources` folder is a synchronized group: add a file there and it is in the target. | +| `Sources/` | `EnclyApp.swift` (the app, blocks third-party keyboards), `ContentView.swift` (hosts the Kotlin view controller), `Assets.xcassets` (the icon; 1024 px upscaled from the 512 px brand mark, replace it with the art master). | +| `Info.plist` | Bundle id `io.github.pasichdev.Encly` (the id of the Flatpak), no ATS exceptions, `LSApplicationQueriesSchemes` = `mailto` only (to know whether a mail app exists). | + +## Build + +The first build phase runs `./gradlew :shared:embedAndSignAppleFrameworkForXcode`, which builds the static +framework for the SDK and architecture Xcode is building and copies the Compose resources into the app. It needs a +JDK 21 (`JAVA_HOME`, else `/usr/libexec/java_home -v 21`) and takes several minutes the first time. +`GRADLE_COMMAND` replaces `./gradlew` (a wrapper script, for example). + +```sh +xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +The version is written into the app from `version.properties` by the last build phase, so a release bumps one file +for Android, the desktop and iOS. Only arm64 is built (Apple silicon simulators and every supported device). + +## Running on the Simulator + +An unsigned Simulator build has no application identifier, so the Keychain refuses every call: the app starts and +shows the screens, but cannot create a PIN. Build with `CODE_SIGN_IDENTITY=-` (ad-hoc signing) to try the whole +flow; Face ID is `Features → Face ID → Enrolled` in the Simulator. + +## Decisions for the owner + +- **`ITSAppUsesNonExemptEncryption`** is deliberately not set in `Info.plist`. The app uses encryption (AES-256-GCM, + SQLCipher, PBKDF2, CryptoKit and the Secure Enclave) only to protect the user's own data on the device, which is + usually exempt from export documentation, but whether to answer "No" or to file the annual self-classification is + the owner's call. Until it is set, App Store Connect asks the export-compliance question for every build. + +## What differs from Android + +See the table in the pull request description and `SECURITY.md`; in short: no way to block a screenshot (the +app switcher snapshot and screen recording are covered), the in-app language applies after a restart, an auto-lock +delay longer than iOS lets a background app run locks when that time is up, and the PDF export and printing are not +offered, as on Android. diff --git a/iosApp/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/iosApp/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 00000000..066f3e18 Binary files /dev/null and b/iosApp/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/iosApp/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..cefcc878 --- /dev/null +++ b/iosApp/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/Sources/Assets.xcassets/Contents.json b/iosApp/Sources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/iosApp/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/Sources/ContentView.swift b/iosApp/Sources/ContentView.swift new file mode 100644 index 00000000..4e17c909 --- /dev/null +++ b/iosApp/Sources/ContentView.swift @@ -0,0 +1,12 @@ +import SwiftUI +import Shared + +/// Hosts the Kotlin root view controller. It is created once per scene; the vault, the lock and the privacy +/// cover live in the Kotlin process-wide graph, not in this view. +struct ContentView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} diff --git a/iosApp/Sources/EnclyApp.swift b/iosApp/Sources/EnclyApp.swift new file mode 100644 index 00000000..edb5a35a --- /dev/null +++ b/iosApp/Sources/EnclyApp.swift @@ -0,0 +1,27 @@ +import SwiftUI +import UIKit + +/// The app shell: a single scene that shows the Kotlin root view controller (`MainViewController` of the +/// `Shared` framework). Everything the user sees and every security rule is in Kotlin; see `iosApp/README.md`. +@main +struct EnclyApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + WindowGroup { + ContentView() + .ignoresSafeArea(.all) + } + } +} + +final class AppDelegate: NSObject, UIApplicationDelegate { + /// No third-party keyboard in Encly: a custom keyboard is code of another vendor that sees every key the user + /// types, notes and PIN included. The system keyboard is the only one the app accepts. + func application( + _ application: UIApplication, + shouldAllowExtensionPointIdentifier extensionPointIdentifier: UIApplication.ExtensionPointIdentifier + ) -> Bool { + extensionPointIdentifier != .keyboard + } +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000..0fad8a44 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,310 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + A10000000000000000000001 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000003 /* Sources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Sources; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000000000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000000000000000000005 = { + isa = PBXGroup; + children = ( + A10000000000000000000003 /* Sources */, + A10000000000000000000002 /* Info.plist */, + A10000000000000000000006 /* Products */, + ); + sourceTree = ""; + }; + A10000000000000000000006 /* Products */ = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A10000000000000000000007 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000008 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + A10000000000000000000009 /* Compile Kotlin framework */, + A1000000000000000000000A /* Sources */, + A10000000000000000000004 /* Frameworks */, + A1000000000000000000000B /* Resources */, + A1000000000000000000000C /* Set version from version.properties */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000003 /* Sources */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = A10000000000000000000001 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A1000000000000000000000D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + A10000000000000000000007 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = A1000000000000000000000E /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A10000000000000000000005; + minimizedProjectReferenceProxies = 1; + productRefGroup = A10000000000000000000006 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A10000000000000000000007 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A1000000000000000000000B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + A10000000000000000000009 /* Compile Kotlin framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Builds the Kotlin framework `Shared` (static) for the SDK and architecture Xcode is building, and\n# puts the Compose resources into the app bundle. Gradle needs a JDK 21: JAVA_HOME wins when it is set; GRADLE_COMMAND replaces ./gradlew.\nif [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping the Gradle build: OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED is YES\"\n exit 0\nfi\nif [ -z \"$JAVA_HOME\" ]; then\n export JAVA_HOME=\"$(/usr/libexec/java_home -v 21 2>/dev/null)\"\nfi\ncd \"$SRCROOT/..\"\n\"${GRADLE_COMMAND:-./gradlew}\" :shared:embedAndSignAppleFrameworkForXcode\n"; + }; + A1000000000000000000000C /* Set version from version.properties */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Set version from version.properties"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# One source of truth for the version, as on Android and the desktop: version.properties.\n# CFBundleShortVersionString = MAJOR.MINOR.PATCH, CFBundleVersion = MAJOR*10000 + MINOR*100 + PATCH.\nprops=\"$SRCROOT/../version.properties\"\nmajor=$(sed -n 's/^VERSION_MAJOR=//p' \"$props\" | tr -d ' \\r')\nminor=$(sed -n 's/^VERSION_MINOR=//p' \"$props\" | tr -d ' \\r')\npatch=$(sed -n 's/^VERSION_PATCH=//p' \"$props\" | tr -d ' \\r')\nplist=\"$TARGET_BUILD_DIR/$INFOPLIST_PATH\"\n/usr/libexec/PlistBuddy -c \"Set :CFBundleShortVersionString $major.$minor.$patch\" \"$plist\"\n/usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $((major * 10000 + minor * 100 + patch))\" \"$plist\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A1000000000000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A1000000000000000000000F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A10000000000000000000010 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A10000000000000000000011 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 20301; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.3.1; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + Shared, + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.pasichdev.Encly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A10000000000000000000012 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 20301; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.3.1; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + Shared, + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.pasichdev.Encly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A1000000000000000000000E /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000F /* Debug */, + A10000000000000000000010 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A10000000000000000000008 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000000000000000000011 /* Debug */, + A10000000000000000000012 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A1000000000000000000000D /* Project object */; +} diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 00000000..3c020094 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/licenses/fonts/NotoSansArabic-OFL.txt b/licenses/fonts/NotoSansArabic-OFL.txt new file mode 100644 index 00000000..d4e17055 --- /dev/null +++ b/licenses/fonts/NotoSansArabic-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/settings.gradle.kts b/settings.gradle.kts index e171c63d..228dcc8b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,5 @@ pluginManagement { + includeBuild("build-logic") repositories { google() mavenCentral() @@ -8,6 +9,15 @@ pluginManagement { rootProject.name = "Encly" include(":app") +include(":editor") +include(":core:database") +include(":core:common") +include(":core:crypto") +include(":core:security") +include(":core:keystore") +include(":core:resources") +include(":shared") +include(":desktopApp") dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1 @@ +/build diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 00000000..f9596d34 --- /dev/null +++ b/shared/README.md @@ -0,0 +1,24 @@ +# :shared + +Everything of the app that is not Android-specific, in Kotlin Multiplatform (Android and desktop JVM) with Compose +Multiplatform. + +- `domain`: use cases and repository contracts. +- `data`: the repositories, the settings store, and the backup, Markdown export, notes import and My Notes hand-off + logic. Files arrive as `kotlinx-io` sources and sinks through `PlatformDocuments`. +- `presentation`, `ui`, `utils`: the design system, screens, dialogs, navigation (`EnclyApp`, `AppNavHost`), + ViewModels (Metro, contributed to the host's graph) and the theme and fonts. +- `platform`: the interfaces the host app implements for what only a platform can do (document dialogs and files, + links, toasts, the sensitive clipboard, the window, the biometric host, the in-app language, the build's facts). + The host gives the composables theirs through the `Local...` values around `EnclyApp`. + +It depends on `:core:*` and `:editor`, never on `:app` (`SharedBoundaryTest`). There is no iOS target while +`:core:crypto` has none. + +| Target | Checked in | +|---|---| +| Android | `testAndroidHostTest` (JVM; the Compose UI tests run under Robolectric) | +| Desktop JVM | `desktopTest` (also `SharedBoundaryTest`: no `android.`, `java.` or Android-bound locals in `commonMain`) | + +Tests that need no Android are in `jvmAndAndroidTest` (JUnit and Mockito, run on both). Line coverage is gated at 85 %. +The fonts and the brand mark are Compose resources of this module; the strings are `:core:resources`'. diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 00000000..b92e4c7e --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,197 @@ +import dev.zacsweers.metro.gradle.OptionalBindingBehavior +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +// Everything of the app that is not Android-specific, as a Kotlin Multiplatform library (Android and desktop +// JVM): the domain, the repositories and the backup, import, export and hand-off logic, and the whole UI +// (design system, screens, dialogs, navigation, ViewModels) in Compose Multiplatform. What the UI needs from +// the platform is an interface in `com.pasich.encly.platform` that the host app implements (document +// pickers, links, toasts, the sensitive clipboard, the window, the biometric host, the in-app language). +// +// iOS (iosArm64, iosSimulatorArm64) is a target too: its host (the Metro graph, the platform services, the +// biometric slot, the privacy cover, MainViewController) is in `iosMain` and is built into the static framework +// `Shared` that the Xcode project in `iosApp/` embeds. The Android and desktop hosts are in :app and :desktopApp. +plugins { + id("encly.kmp-library") + id("encly.kmp-ios") + id("encly.compose") + id("encly.quality") + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.metro) +} + +// The shared detekt config plus the module's own (the tests are left out of more rules). Findings that predate +// the move out of the app are captured in detekt-baseline.xml, which should only ever shrink. +detekt { + config.setFrom(rootProject.files("config/detekt/detekt.yml"), files("config/detekt.yml")) + baseline = file("detekt-baseline.xml") +} + +// Minimum line coverage (%) of the whole module, enforced by koverVerify. The same bar the app had. +val sharedMinCoverage = 85 + +// The same as in :app. A Kotlin default on an injected constructor parameter stays a plain default: the +// parameter is always taken from the graph, and a missing binding is an error instead of silently falling +// back to the default (e.g. a lock event source that never locks). +metro { + optionalBindingBehavior.set(OptionalBindingBehavior.DISABLED) +} + +// The store changelog of the version being built, bundled for "What's new" (see GenerateChangelogs in build-logic). +val appVersionCode = versionCodeOf(rootProject.file("version.properties")) +val generateChangelogs = tasks.register("generateChangelogs") { + versionCode.set(appVersionCode) + changelogs.from( + fileTree(rootProject.file("fastlane/metadata/android")) { include("*/changelogs/$appVersionCode.txt") }, + ) + output.set(layout.buildDirectory.dir("generated/changelogs/kotlin")) +} + +kotlin { + // An application layer, not a library: its classes are public because the host app and the tests reach + // them, not because they are a published API. + explicitApi = ExplicitApiMode.Disabled + + compilerOptions { + // BackHandler of Compose Multiplatform is still marked experimental. + optIn.add("androidx.compose.ui.ExperimentalComposeUiApi") + } + + android { + namespace = "com.pasich.encly.shared" + // The Android target packages the composeResources files (fonts, the brand mark) as assets, which + // needs the AGP resource pipeline (off by default in the KMP library plugin). + androidResources { + enable = true + } + } + + sourceSets { + commonMain { + kotlin.srcDir(generateChangelogs.flatMap { it.output }) + } + commonMain.dependencies { + api(project(":core:common")) + api(project(":core:crypto")) + api(project(":core:security")) + api(project(":core:database")) + api(project(":core:resources")) + api(project(":editor")) + + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.io.core) + api(libs.kotlinx.datetime) + api(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.json.io) + implementation(libs.kotlinx.atomicfu) + + // BackHandler (the system Back, on Android the predictive back gesture too). + implementation(libs.compose.ui.backhandler) + // Compose Multiplatform resources (the fonts and the brand mark of this module). + implementation(libs.compose.components.resources) + // Navigation, ViewModels and lifecycle for Compose Multiplatform. + api(libs.compose.navigation) + api(libs.compose.lifecycle.viewmodel) + api(libs.compose.lifecycle.viewmodel.savedstate) + api(libs.compose.lifecycle.runtime) + + // Metro's ViewModel integration (the graph itself is declared in the app). + api(libs.metrox.viewmodel) + api(libs.metrox.viewmodel.compose) + + // The settings store (DataStore); its Android factory is the app's. + api(libs.androidx.datastore.preferences) + // Room's transaction helpers used by the repositories. + api(libs.androidx.room.runtime) + + implementation(libs.icons.lucide) + implementation(libs.reorderable) + } + androidMain.dependencies { + // The Android navigation host runs on the version the app tested (the Multiplatform artifact + // resolves to an older androidx one). + api(libs.androidx.navigation.compose) + implementation(libs.androidx.core.ktx) + } + // The Keychain behind the PIN's device secret and the biometric slot (the iOS host's). + getByName("iosMain").dependencies { + implementation(project(":core:keystore")) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + // JUnit and Mockito run on both JVM targets: the tests that need java.io, threads or mocks. + getByName("jvmAndAndroidTest").dependencies { + implementation(libs.junit) + implementation(libs.mockito.core) + implementation(libs.kotlinx.coroutines.test) + } + // The real exports of other apps that the import tests read (classpath resources of both JVM targets). + getByName("desktopTest").resources.srcDir("src/jvmAndAndroidTest/resources") + getByName("androidHostTest").resources.srcDir("src/jvmAndAndroidTest/resources") + getByName("androidHostTest").dependencies { + // Compose UI tests run on the JVM under Robolectric, no emulator needed. + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + implementation(libs.junit) + implementation(libs.androidx.test.core) + implementation(libs.androidx.activity.compose) + implementation(libs.mockito.core) + implementation(libs.robolectric) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.androidx.compose.ui.test.manifest) + } + } +} + +// The one framework of the iOS app: static, so the Xcode project links a single binary and needs no embedding +// step beyond the Compose resources. `./gradlew :shared:embedAndSignAppleFrameworkForXcode` builds it for Xcode. +kotlin.targets.withType().configureEach { + binaries.framework { + baseName = "Shared" + isStatic = true + } +} + +compose.resources { + // Only this module reads them (the fonts and the brand mark); the strings are :core:resources'. + packageOfResClass = "com.pasich.encly.shared.resources" + publicResClass = false + generateResClass = always +} + +// An application layer has no published API to dump. +tasks.matching { it.name.contains("KotlinAbi") }.configureEach { + enabled = false +} + +// Coverage: `./gradlew :shared:koverVerify` over the desktop JVM tests and the Android host tests. +kover { + reports { + total { + filters { + // Code the compilers and plugins write, not this project. + excludes { + classes( + "*ComposableSingletons*", + "*MetroFactory*", + "*MetroContributionTo*", + "*MetroMembersInjector*", + "com.pasich.encly.shared.resources.*", + ) + } + } + xml { onCheck.set(false) } + html { onCheck.set(false) } + verify { + rule("Shared line coverage") { + bound { + minValue = sharedMinCoverage + coverageUnits = CoverageUnit.LINE + } + } + } + } + } +} diff --git a/shared/config/detekt.yml b/shared/config/detekt.yml new file mode 100644 index 00000000..c75c1bf4 --- /dev/null +++ b/shared/config/detekt.yml @@ -0,0 +1,20 @@ +# Shared-module detekt rules, layered on config/detekt/detekt.yml. The module's tests live in the source sets +# `commonTest`, `jvmAndAndroidTest`, `desktopTest` and `androidHostTest`. The root config already leaves the +# complexity and style rules out of them; the tests are scenarios, not production code, so the same goes for the +# rules that police how production code spends time or throws: they build arrays with a spread, loop over +# ranges, throw bare exceptions from fakes and catch broadly to fail with a message. +complexity: + excludes: &tests + - '**/commonTest/**' + - '**/jvmAndAndroidTest/**' + - '**/desktopTest/**' + - '**/androidHostTest/**' + +style: + excludes: *tests + +performance: + excludes: *tests + +exceptions: + excludes: *tests diff --git a/shared/detekt-baseline.xml b/shared/detekt-baseline.xml new file mode 100644 index 00000000..f11195d4 --- /dev/null +++ b/shared/detekt-baseline.xml @@ -0,0 +1,62 @@ + + + + + CyclomaticComplexMethod:EditNoteScreen.kt$@OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, ) @Composable fun EditNoteScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: EditNoteViewModel = metroViewModel(), ) + CyclomaticComplexMethod:EditTagScreen.kt$@Composable private fun TagEditItem( item: Tag, onAction: (TagEditTypeAction, Tag) -> Unit, globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, onFocusChange: (Boolean) -> Unit, shouldPreventFocus: Boolean, isDragging: Boolean = false, ) + CyclomaticComplexMethod:NotesTextFormatter.kt$NotesTextFormatter$fun blocksToPlainText(blocks: List<Block>): String + CyclomaticComplexMethod:TasksScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TasksScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: TasksViewModel = metroViewModel(), ) + LongMethod:AboutScreen.kt$@Composable private fun DeveloperActionsSection(links: ExternalLinks, toaster: Toaster, onSupport: () -> Unit) + LongMethod:EditNoteBottomSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditNoteBottomSheet( isVisible: Boolean, onDismiss: () -> Unit, viewModel: EditNoteViewModel = metroViewModel<EditNoteViewModel>(), onAction: (EditNoteBottomSheetAction) -> Unit = {}, ) + LongMethod:EditNoteScreen.kt$@OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, ) @Composable fun EditNoteScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: EditNoteViewModel = metroViewModel(), ) + LongMethod:EditTagScreen.kt$@Composable private fun TagEditItem( item: Tag, onAction: (TagEditTypeAction, Tag) -> Unit, globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, onFocusChange: (Boolean) -> Unit, shouldPreventFocus: Boolean, isDragging: Boolean = false, ) + LongMethod:EditTagScreen.kt$@OptIn(ExperimentalFoundationApi::class) @SuppressLint("MutableCollectionMutableState") @Composable private fun ListTagsEdit( globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, modifier: Modifier = Modifier, viewModel: TagListViewModel = metroViewModel(), ) + LongMethod:LossRecoveryScreen.kt$@Composable fun LossRecoveryScreen(onRecoveryConfirm: () -> Unit, modifier: Modifier = Modifier) + LongMethod:MainScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( navController: NavHostController, drawerState: DrawerState, modifier: Modifier = Modifier, mainListStateViewModel: MainListStateViewModel = metroViewModel(), settingsViewModel: SettingsViewModel = metroViewModel(), noteListViewModel: NoteListViewModel = metroViewModel(), ) + LongMethod:NavHost.kt$@Composable fun AppNavHost(navController: NavHostController, startDestination: String = NavRoutes.HomeRoute.name) + LongMethod:NoteBottomBar.kt$@Composable fun NoteBottomBar( modifier: Modifier = Modifier, viewModel: EditNoteViewModel = metroViewModel(), simpleEdit: Boolean = false, ) + LongMethod:NoteCardBottomSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun NoteCardBottomSheet( isVisible: Boolean, item: NoteWithTag, onAction: (NoteAction) -> Unit, sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), onDismiss: () -> Unit, ) + LongMethod:NotesListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun NotesList( isGrid: Boolean, listScrollState: LazyListState, gridScrollState: LazyStaggeredGridState, onItemClick: (Note) -> Unit, modifier: Modifier = Modifier, onSecondActionClick: (Note) -> Unit = {}, noteListViewModel: NoteListViewModel = metroViewModel(), ) + LongMethod:SecuritySettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SecuritySettingsScreen( navController: NavHostController, modifier: Modifier = Modifier, securityViewModel: SecuritySettingsViewModel = metroViewModel(), vaultViewModel: BackupViewModel = metroViewModel(), ) + LongMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( navController: NavHostController?, modifier: Modifier = Modifier, settingViewState: SettingsViewModel = metroViewModel(), securityDisable: Boolean = false, ) + LongMethod:TaskProgressCard.kt$@Composable fun TaskProgressCard( completionPercentage: Int, completedTasksCount: Int, totalTasksCount: Int, modifier: Modifier = Modifier, ) + LongMethod:TasksScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TasksScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: TasksViewModel = metroViewModel(), ) + LongMethod:TrashNotesList.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun TrashNotesList( onItemClick: (Int, Note) -> Unit, modifier: Modifier = Modifier, trashViewModel: TrashViewModel = metroViewModel(), ) + LongMethod:TrashScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TrashScreen( navController: NavHostController, modifier: Modifier = Modifier, trashViewModel: TrashViewModel = metroViewModel(), ) + LongParameterList:EditNoteScreen.kt$( isReadTrashOnly: Boolean, lockEditor: Boolean, onBackClick: () -> Unit, onRestoreClick: () -> Unit, onDeleteClick: () -> Unit, onMenuClick: () -> Unit, onLockToggle: () -> Unit, onDoneClick: () -> Unit, ) + LongParameterList:EditTagScreen.kt$( item: Tag, onAction: (TagEditTypeAction, Tag) -> Unit, globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, onFocusChange: (Boolean) -> Unit, shouldPreventFocus: Boolean, isDragging: Boolean = false, ) + MagicNumber:EditNoteViewModel.kt$EditNoteViewModel$5000 + MagicNumber:EditTagScreen.kt$20 + MagicNumber:FontSizeUtils.kt$FontSizeUtils$10 + MagicNumber:FontSizeUtils.kt$FontSizeUtils$4 + MagicNumber:FontSizeUtils.kt$FontSizeUtils$6 + MagicNumber:FontSizeUtils.kt$FontSizeUtils$8 + MagicNumber:MainDrawer.kt$0.8f + MagicNumber:MainDrawer.kt$600 + MagicNumber:MainListStateViewModel.kt$MainListStateViewModel$5_000 + MagicNumber:NoteCardBottomSheet.kt$300 + MagicNumber:NotesListScreen.kt$200 + MagicNumber:NotesListScreen.kt$4 + MagicNumber:SettingsLocalDataSource.kt$SettingsLocalDataSource$10 + MagicNumber:SettingsLocalDataSource.kt$SettingsLocalDataSource$16 + MagicNumber:SettingsLocalDataSource.kt$SettingsLocalDataSource$32 + MagicNumber:StatisticViewModel.kt$StatisticViewModel$5000 + MagicNumber:TagItem.kt$0.6f + MagicNumber:TaskItem.kt$0.8f + MagicNumber:TaskProgressCard.kt$100f + MagicNumber:ThemeViewModel.kt$ThemeViewModel$5_000 + MatchingDeclarationName:EditNoteBottomSheet.kt$EditNoteBottomSheetAction + MatchingDeclarationName:EditTagScreen.kt$TagEditTypeAction + MatchingDeclarationName:NoteBottomBar.kt$NoteBottomBarFragment + MatchingDeclarationName:NoteCardBottomSheet.kt$NoteAction + MatchingDeclarationName:TasksScreen.kt$TasksScreenUiState + MatchingDeclarationName:TrashScreen.kt$DialogTrashAction + NestedBlockDepth:NotesTextFormatter.kt$NotesTextFormatter$fun blocksToPlainText(blocks: List<Block>): String + TooManyFunctions:EditNoteViewModel.kt$EditNoteViewModel : ViewModel + TooManyFunctions:OnboardingViewModel.kt$OnboardingViewModel : ViewModel + TooManyFunctions:TasksRepositoryImpl.kt$TasksRepositoryImpl : TasksRepository + TooManyFunctions:TasksViewModel.kt$TasksViewModel : ViewModel + UnusedPrivateProperty:DeviceCapabilities.kt$DeviceCapabilities$@param:ApplicationContext private val context: Context + UnusedPrivateProperty:SettingsBuilder.kt$SettingsBuilder$private val deviceCapabilities: DeviceCapabilities + + diff --git a/app/src/test/java/com/pasich/encly/presentation/designsystem/TooltipTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/designsystem/TooltipTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/presentation/designsystem/TooltipTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/designsystem/TooltipTest.kt diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/ConfirmationDialogsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/ConfirmationDialogsTest.kt new file mode 100644 index 00000000..748ef13f --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/ConfirmationDialogsTest.kt @@ -0,0 +1,130 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.clear +import com.pasich.encly.core.resources.delete +import com.pasich.encly.core.resources.delete_tag +import com.pasich.encly.core.resources.dialog_confirm +import com.pasich.encly.core.resources.dialog_dismiss +import com.pasich.encly.core.resources.task_clear_completed_title +import com.pasich.encly.ui.screens.ComposeScreenTest +import org.jetbrains.compose.resources.StringResource +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The yes/no dialogs: hidden unless asked, and a confirmation always closes the dialog it came from. */ +class ConfirmationDialogsTest : ComposeScreenTest() { + private val calls = mutableListOf() + + private fun shown(id: StringResource) = countText(str(id)) > 0 + + @Test + fun aHiddenDeleteTagDialogShowsNothing() { + setScreen { RequestDeleteTagsDialog(showDialog = false, onDismiss = { calls += "dismiss" }, onConfirm = {}) } + + assertEquals(0, countText(str(Res.string.delete_tag))) + } + + @Test + fun confirmingTheTagDeletionConfirmsThenDismisses() { + setScreen { + RequestDeleteTagsDialog( + showDialog = true, + onDismiss = { calls += "dismiss" }, + onConfirm = { calls += "confirm" }, + ) + } + assertEquals(1, countText(str(Res.string.delete_tag))) + + rule.onNodeWithText(str(Res.string.delete)).performClick() + + assertEquals(listOf("confirm", "dismiss"), calls) + } + + @Test + fun cancellingTheTagDeletionOnlyDismisses() { + setScreen { + RequestDeleteTagsDialog( + showDialog = true, + onDismiss = { calls += "dismiss" }, + onConfirm = { calls += "confirm" }, + ) + } + + rule.onNodeWithText(str(Res.string.cancel)).performClick() + + assertEquals(listOf("dismiss"), calls) + } + + @Test + fun aHiddenClearCompletedDialogShowsNothing() { + setScreen { RequestCleanCompleteTaskDialog(showDialog = false, onDismiss = {}, onConfirm = {}) } + + assertEquals(0, countText(str(Res.string.task_clear_completed_title))) + } + + @Test + fun confirmingTheClearOfCompletedTasksConfirmsThenDismisses() { + setScreen { + RequestCleanCompleteTaskDialog( + showDialog = true, + onDismiss = { calls += "dismiss" }, + onConfirm = { calls += "confirm" }, + ) + } + assertEquals(1, countText(str(Res.string.task_clear_completed_title))) + + rule.onNodeWithText(str(Res.string.clear)).performClick() + + assertEquals(listOf("confirm", "dismiss"), calls) + } + + @Test + fun cancellingTheClearOfCompletedTasksOnlyDismisses() { + setScreen { + RequestCleanCompleteTaskDialog( + showDialog = true, + onDismiss = { calls += "dismiss" }, + onConfirm = { calls += "confirm" }, + ) + } + + rule.onNodeWithText(str(Res.string.cancel)).performClick() + + assertEquals(listOf("dismiss"), calls) + } + + @Test + fun theGenericConfirmDialogShowsItsTextAndRoutesBothButtons() { + setScreen { + ConfirmDialog( + isVisible = true, + titleText = "Erase?", + messageText = "This cannot be undone.", + onConfirm = { calls += "confirm" }, + onDismiss = { calls += "dismiss" }, + destructive = true, + ) + } + assertEquals(1, countText("Erase?")) + assertEquals(1, countText("This cannot be undone.")) + + rule.onNodeWithText(str(Res.string.dialog_dismiss)).performClick() + rule.onNodeWithText(str(Res.string.dialog_confirm)).performClick() + + assertEquals(listOf("dismiss", "confirm"), calls) + } + + @Test + fun anInvisibleConfirmDialogShowsNothing() { + setScreen { + ConfirmDialog(isVisible = false, titleText = "Erase?", messageText = "m", onConfirm = {}, onDismiss = {}) + } + + assertEquals(0, countText("Erase?")) + assertEquals(false, shown(Res.string.dialog_confirm)) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheetTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheetTest.kt new file mode 100644 index 00000000..93eb2744 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheetTest.kt @@ -0,0 +1,158 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.delete +import com.pasich.encly.core.resources.duplicate +import com.pasich.encly.core.resources.edit +import com.pasich.encly.core.resources.no_tag +import com.pasich.encly.core.resources.note_description_placeholder +import com.pasich.encly.core.resources.save +import com.pasich.encly.core.resources.tap_again_to_delete +import com.pasich.encly.core.resources.untitled +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.ui.screens.ComposeScreenTest +import com.pasich.encly.ui.screens.TestApp +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The long-press sheet of a note: what it shows, which action each row reports, and the description editor. */ +@OptIn(ExperimentalMaterial3Api::class) +class NoteCardBottomSheetTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + private val actions = mutableListOf() + private var dismissed = 0 + + private val tags = listOf(Tag(id = 1, nameTag = "Work"), Tag(id = 2, nameTag = "Home")) + + private fun show( + note: Note = Note(id = 5, title = "Groceries", date = 1_700_000_000_000L), + visible: Boolean = true, + ) { + app.tags.tags.value = tags + setScreen(viewModels(app.tagList())) { + NoteCardBottomSheet( + isVisible = visible, + item = NoteWithTags(note, tags.filter { it.id in note.tagIds }), + onAction = { actions += it }, + onDismiss = { dismissed++ }, + ) + } + } + + @Test + fun aHiddenSheetShowsNothing() { + show(visible = false) + + assertEquals(0, countText("Groceries")) + } + + @Test + fun theSheetShowsTheTitleTheTagChipsAndTheRows() { + show() + + assertEquals(1, countText("Groceries")) + waitForText("Work") + listOf("Home", str(Res.string.no_tag), str(Res.string.edit), str(Res.string.duplicate), str(Res.string.delete)) + .forEach { assertEquals(it, 1, countText(it)) } + } + + @Test + fun aNoteWithoutATitleIsCalledUntitled() { + show(Note(id = 5, title = "", date = 1L)) + + assertEquals(1, countText(str(Res.string.untitled))) + } + + @Test + fun editAndDuplicateReportTheirActions() { + show() + + rule.onNodeWithText(str(Res.string.edit)).performClick() + rule.onNodeWithText(str(Res.string.duplicate)).performClick() + + assertEquals(listOf(NoteAction.Edit, NoteAction.Duplicate), actions) + } + + @Test + fun deletingTakesASecondTap() { + show() + + rule.onNodeWithText(str(Res.string.delete)).performClick() + rule.waitForIdle() + assertEquals(emptyList(), actions) + + rule.onNodeWithText(str(Res.string.tap_again_to_delete)).performClick() + assertEquals(listOf(NoteAction.Delete), actions) + } + + @Test + fun tagChipsToggleTheirTagAndNoTagClearsThemAll() { + show(Note(id = 5, title = "Groceries", tagIds = setOf(2))) + waitForText("Work") + + rule.onNodeWithText("Work").performClick() + rule.onNodeWithText("Home").performClick() + rule.onNodeWithText(str(Res.string.no_tag)).performClick() + + assertEquals( + listOf( + NoteAction.ChangeTag(1, tagged = true), + NoteAction.ChangeTag(2, tagged = false), + NoteAction.ChangeTag(0, tagged = false), + ), + actions, + ) + } + + @Test + fun theDescriptionRowShowsItsPlaceholderWhenEmptyAndTheTextWhenNot() { + show(Note(id = 5, title = "Groceries", description = "Weekly shop")) + assertEquals(1, countText("Weekly shop")) + assertEquals(0, countText(str(Res.string.note_description_placeholder))) + } + + @Test + fun anEmptyDescriptionShowsThePlaceholder() { + show() + + assertEquals(1, countText(str(Res.string.note_description_placeholder))) + } + + @Test + fun savingADescriptionReportsItTrimmedAndLeavesEditing() { + show() + + rule.onNodeWithText(str(Res.string.note_description_placeholder)).performClick() + rule.waitForIdle() + assertEquals("the other rows make way for the field", 0, countText(str(Res.string.duplicate))) + rule.onNode(hasSetTextAction()).performTextInput(" Buy milk ") + rule.onNodeWithText(str(Res.string.save)).performClick() + rule.waitForIdle() + + assertEquals(listOf(NoteAction.ChangeDescription("Buy milk")), actions) + assertEquals("the rows are back", 1, countText(str(Res.string.duplicate))) + } + + @Test + fun cancellingTheDescriptionEditReportsNothing() { + show(Note(id = 5, title = "Groceries", description = "Weekly shop")) + + rule.onNodeWithText("Weekly shop").performClick() + rule.waitForIdle() + rule.onNode(hasSetTextAction()).performTextInput(" and more") + rule.onNodeWithText(str(Res.string.cancel)).performClick() + rule.waitForIdle() + + assertEquals(emptyList(), actions) + assertEquals(1, countText("Weekly shop")) + assertEquals(1, countText(str(Res.string.duplicate))) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/NotesSortBottomSheetTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/NotesSortBottomSheetTest.kt new file mode 100644 index 00000000..7f835f9c --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/NotesSortBottomSheetTest.kt @@ -0,0 +1,83 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.sort_by +import com.pasich.encly.core.resources.view_switch_to_grid +import com.pasich.encly.core.resources.view_switch_to_list +import com.pasich.encly.domain.enums.NoteSortOption +import com.pasich.encly.ui.screens.ComposeScreenTest +import com.pasich.encly.ui.screens.TestApp +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The list's view options: every sort order, the list/grid switch, and closing after a choice. */ +@OptIn(ExperimentalMaterial3Api::class) +class NotesSortBottomSheetTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + private var dismissed = 0 + private var toggled = 0 + + private fun show(visible: Boolean = true, grid: Boolean = false) { + val viewModel = app.noteList() + setScreen(viewModels(viewModel)) { + NotesSortBottomSheet( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismiss = { dismissed++ }, + isGrid = grid, + onToggleView = { toggled++ }, + isBottomSheetVisible = visible, + noteListViewModel = viewModel, + ) + } + } + + @Test + fun aHiddenSheetShowsNothing() { + show(visible = false) + + assertEquals(0, countText(str(Res.string.sort_by))) + } + + @Test + fun theSheetListsEverySortOrder() { + show() + + assertEquals(1, countText(str(Res.string.sort_by))) + NoteSortOption.entries.forEach { assertEquals(it.name, 1, countText(str(it.labelRes))) } + } + + @Test + fun choosingASortOrderStoresItAndClosesTheSheet() { + show() + + rule.onNodeWithText(str(NoteSortOption.UPDATED_ASC.labelRes)).performClick() + + waitFor { app.settings.sort.value == NoteSortOption.UPDATED_ASC } + assertEquals(1, dismissed) + assertEquals(0, toggled) + } + + @Test + fun aListOffersTheSwitchToTheGridAndTheOtherWayAround() { + show(grid = false) + assertEquals(1, countText(str(Res.string.view_switch_to_grid))) + assertEquals(0, countText(str(Res.string.view_switch_to_list))) + + rule.onNodeWithText(str(Res.string.view_switch_to_grid)).performClick() + + assertEquals(1, toggled) + assertEquals(1, dismissed) + } + + @Test + fun aGridOffersTheSwitchBackToTheList() { + show(grid = true) + + assertEquals(1, countText(str(Res.string.view_switch_to_list))) + assertEquals(0, countText(str(Res.string.view_switch_to_grid))) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/effects/ContainerTransformTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/ContainerTransformTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/presentation/effects/ContainerTransformTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/ContainerTransformTest.kt diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/ScreenTransitionTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/ScreenTransitionTest.kt new file mode 100644 index 00000000..3bd5be8f --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/ScreenTransitionTest.kt @@ -0,0 +1,88 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.ui.screens.ComposeScreenTest +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Screens swap through [animationScreens] with the fade drawn by [screenFade] and not by + * `fadeIn`/`fadeOut`: the leaving screen is still held while the swap runs and gone when it ends, a + * swap with the lock screen is immediate, and the arriving screen is there at the end. + */ +class ScreenTransitionTest : ComposeScreenTest() { + private companion object { + const val SETTLED_MS = 1_000L + const val IMMEDIATE_MS = 80L + } + + private lateinit var controller: NavHostController + + private fun setHost() { + setScreen { + val navController = rememberNavController() + controller = navController + NavHost(navController, startDestination = NavRoutes.SettingsRoute.name) { + listOf(NavRoutes.SettingsRoute, NavRoutes.AboutRoute, NavRoutes.LockRoute).forEach { route -> + animationScreens(route.name) { Box(Modifier.testTag(route.name).size(40.dp)) } + } + } + } + rule.onNodeWithTag(NavRoutes.SettingsRoute.name).assertExists() + } + + private fun go(route: NavRoutes) { + rule.mainClock.autoAdvance = false + rule.runOnUiThread { controller.navigate(route.name) } + } + + @Test + fun aSharedAxisSwapHoldsTheLeavingScreenThenDropsIt() { + setHost() + go(NavRoutes.AboutRoute) + + rule.mainClock.advanceTimeBy(IMMEDIATE_MS) + rule.onNodeWithTag(NavRoutes.SettingsRoute.name).assertExists() + rule.onNodeWithTag(NavRoutes.AboutRoute.name).assertExists() + + rule.mainClock.advanceTimeBy(SETTLED_MS) + rule.onNodeWithTag(NavRoutes.SettingsRoute.name).assertDoesNotExist() + rule.onNodeWithTag(NavRoutes.AboutRoute.name).assertExists() + } + + @Test + fun aSwapToTheLockScreenNeverKeepsTheScreenItLeaves() { + setHost() + val before = rule.mainClock.currentTime + go(NavRoutes.LockRoute) + + rule.mainClock.advanceTimeBy(IMMEDIATE_MS) + rule.onNodeWithTag(NavRoutes.SettingsRoute.name).assertDoesNotExist() + rule.onNodeWithTag(NavRoutes.LockRoute.name).assertExists() + assertTrue(rule.mainClock.currentTime - before <= IMMEDIATE_MS + 16) + } + + @Test + fun backPlaysTheSameMotionAndEndsOnTheScreenBelow() { + setHost() + go(NavRoutes.AboutRoute) + rule.mainClock.advanceTimeBy(SETTLED_MS) + + rule.runOnUiThread { controller.popBackStack() } + rule.mainClock.advanceTimeBy(IMMEDIATE_MS) + rule.onNodeWithTag(NavRoutes.AboutRoute.name).assertExists() + + rule.mainClock.advanceTimeBy(SETTLED_MS) + rule.onNodeWithTag(NavRoutes.AboutRoute.name).assertDoesNotExist() + rule.onNodeWithTag(NavRoutes.SettingsRoute.name).assertExists() + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/UnlockRevealTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/UnlockRevealTest.kt new file mode 100644 index 00000000..bb80022c --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/UnlockRevealTest.kt @@ -0,0 +1,215 @@ +package com.pasich.encly.presentation.effects + +import android.provider.Settings +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.geometry.Rect +import com.pasich.encly.ui.screens.ComposeScreenTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The unlock reveal: when the lock screen is left, how often the full animation plays, and how it is cut short. */ +class UnlockRevealTest : ComposeScreenTest() { + private val state = UnlockRevealState() + private var covered = 0 + + private val logo = Rect(left = 100f, top = 200f, right = 164f, bottom = 264f) + + @Test + fun withoutTheLogoBoundsTheLockScreenIsLeftAtOnce() { + state.start { covered++ } + + assertEquals(1, covered) + assertFalse(state.running) + } + + @Test + fun withTheLogoTheLockScreenIsLeftOnlyOnceCovered() { + state.logoBounds = logo + + state.start { covered++ } + + assertTrue(state.running) + assertEquals("nothing happens until the colour fills the window", 0, covered) + + state.cover() + state.cover() + assertEquals("leaving happens once", 1, covered) + assertEquals("the screens wait off the right edge", 1f, state.contentOffset, 0f) + } + + @Test + fun theListSlidesInAsTheProgressGrowsAndFinishingSettlesIt() { + state.logoBounds = logo + state.start {} + state.cover() + + state.push(0.25f) + assertEquals(0.75f, state.contentOffset, 0.0001f) + state.push(1f) + assertEquals(0f, state.contentOffset, 0f) + + state.finish() + assertFalse(state.running) + assertEquals(0f, state.contentOffset, 0f) + } + + @Test + fun aSecondStartWhilePlayingChangesNothing() { + state.logoBounds = logo + state.start { covered += 1 } + + state.start { covered += 100 } + state.cover() + + assertEquals(1, covered) + } + + @Test + fun onlyTheFirstUnlockGetsTheFullReveal() { + state.logoBounds = logo + state.start {} + assertTrue(state.full) + state.finish() + + state.start {} + + assertFalse(state.full) + } + + @Test + fun cancellingDropsTheLeaveAndPutsTheScreensBack() { + state.logoBounds = logo + state.start { covered++ } + state.cover() + + state.cancel() + + assertFalse(state.running) + assertEquals(0f, state.contentOffset, 0f) + state.cover() + assertEquals("the pending navigation does not run after a cancel", 1, covered) + } + + @Test + fun revealThenRunsTheLeaveDirectlyWithoutAReveal() { + var left = 0 + val none: UnlockRevealState? = null + + none.revealThen { left++ } + + assertEquals(1, left) + } + + @Test + fun revealThenStartsTheRevealWhenOneIsProvided() { + var left = 0 + state.logoBounds = logo + + state.revealThen { left++ } + + assertTrue(state.running) + assertEquals(0, left) + } + + @Test + fun theFullRevealPlaysToTheEndAndLeavesTheLockScreenOnce() { + state.logoBounds = logo + setScreen { UnlockRevealOverlay(state) } + + rule.runOnIdle { state.start { covered++ } } + waitFor { !state.running } + + assertEquals(1, covered) + assertEquals(0f, state.contentOffset, 0f) + } + + @Test + fun theLaterRevealIsTheShortFadeAndAlsoLeavesOnce() { + state.logoBounds = logo + setScreen { UnlockRevealOverlay(state) } + rule.runOnIdle { state.start { covered++ } } + waitFor { !state.running } + + rule.runOnIdle { state.start { covered++ } } + assertFalse(state.full) + waitFor { !state.running } + + assertEquals(2, covered) + assertEquals(0f, state.contentOffset, 0f) + } + + @Test + fun aRevealCancelledMidwayNeverLeavesTheLockScreen() { + state.logoBounds = logo + setScreen { UnlockRevealOverlay(state) } + rule.mainClock.autoAdvance = false + + rule.runOnIdle { state.start { covered++ } } + rule.mainClock.advanceTimeBy(50) + rule.runOnIdle { state.cancel() } + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + assertEquals(0, covered) + assertFalse(state.running) + } + + // One frame of the test clock; the budgets below are what a user waits behind the colour. + private val frameMs = 16L + + /** Starts the reveal on the paused clock and gives the overlay its first frames to compose and launch. */ + private fun startAndLetItBegin() { + rule.runOnIdle { state.start { covered++ } } + // The paused clock does not idle the looper that delivers the state change to the composition. + Snapshot.sendApplyNotifications() + repeat(3) { rule.mainClock.advanceTimeByFrame() } + } + + @Test + fun theFullRevealIsOverWithinHalfASecond() { + state.logoBounds = logo + setScreen { UnlockRevealOverlay(state) } + rule.mainClock.autoAdvance = false + + startAndLetItBegin() + rule.mainClock.advanceTimeBy(UNLOCK_GROW_MS + frameMs * (SETTLE_FRAMES + 2)) + assertEquals("the list is out of sight once the colour has grown", 1, covered) + assertTrue("the push is still playing", state.running) + + rule.mainClock.advanceTimeBy(UNLOCK_PUSH_MS + frameMs) + assertFalse(state.running) + assertTrue(UNLOCK_GROW_MS + SETTLE_FRAMES * frameMs + UNLOCK_PUSH_MS <= 500) + } + + @Test + fun theLaterRevealIsShorterStill() { + state.logoBounds = logo + setScreen { UnlockRevealOverlay(state) } + rule.runOnIdle { state.start {} } + waitFor { !state.running } + rule.mainClock.autoAdvance = false + + startAndLetItBegin() + rule.mainClock.advanceTimeBy(QUICK_OUT_MS + QUICK_IN_MS + frameMs * (SETTLE_FRAMES + 3)) + + assertEquals(1, covered) + assertFalse(state.running) + } + + @Test + fun withAnimationsOffTheLockScreenIsLeftAtOnceWithoutAnyColour() { + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + state.logoBounds = logo + setScreen { UnlockRevealOverlay(state) } + rule.mainClock.autoAdvance = false + + startAndLetItBegin() + rule.mainClock.advanceTimeBy(frameMs * 2) + + assertEquals(1, covered) + assertFalse(state.running) + assertEquals(0f, state.contentOffset, 0f) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/screen/editnote/EditorBindingsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/screen/editnote/EditorBindingsTest.kt new file mode 100644 index 00000000..5ed1fc0c --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/screen/editnote/EditorBindingsTest.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.presentation.screen.editnote + +import android.app.Application +import androidx.compose.ui.test.junit4.createComposeRule +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.block_heading_level +import com.pasich.encly.core.resources.block_link +import com.pasich.encly.core.resources.block_move_up +import com.pasich.encly.core.resources.block_separator +import com.pasich.encly.core.resources.delete_block +import com.pasich.encly.core.resources.quote +import com.pasich.encly.core.resources.selection_count +import com.pasich.encly.core.resources.tap_again_to_delete +import com.pasich.encly.core.resources.text +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.EditorStrings +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The editor ships no string resources; the app maps each of its keys onto its own. A key without a + * string, or one mapped to the wrong string, would show in a sheet or a menu of the editor. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class EditorBindingsTest { + @get:Rule + val rule = createComposeRule() + + private lateinit var strings: EditorStrings + + @Before + fun readTheStrings() { + rule.setContent { strings = rememberEditorStrings() } + rule.waitForIdle() + } + + @Test + fun everyKeyTheEditorAsksForHasATextInTheApp() { + EditorString.entries.forEach { key -> + assertTrue("$key has no text", strings.text(key).isNotBlank()) + } + } + + @Test + fun theNamesOfTheBlockKindsAreTheAppsOwn() { + val expected = mapOf( + EditorString.TEXT to Res.string.text, + EditorString.QUOTE to Res.string.quote, + EditorString.BLOCK_LINK to Res.string.block_link, + EditorString.BLOCK_SEPARATOR to Res.string.block_separator, + EditorString.BLOCK_MOVE_UP to Res.string.block_move_up, + EditorString.BLOCK_DELETE to Res.string.delete_block, + EditorString.TAP_AGAIN_TO_DELETE to Res.string.tap_again_to_delete, + ) + + expected.forEach { (key, id) -> + assertEquals(key.name, id, key.resource()) + assertEquals(key.name, runBlocking { getString(id) }, strings.text(key)) + } + } + + @Test + fun aHeadingNamesItsLevel() { + (1..4).forEach { level -> + assertEquals(runBlocking { getString(Res.string.block_heading_level, level) }, strings.heading(level)) + } + } + + @Test + fun theSelectionCountNamesTheNumberOfBlocks() { + listOf(1, 7, 42).forEach { count -> + assertEquals(runBlocking { getString(Res.string.selection_count, count) }, strings.selectionCount(count)) + } + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt similarity index 87% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index 2960f8a7..a1d3c6a1 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -1,24 +1,31 @@ package com.pasich.encly.presentation.viewmodel -import android.net.Uri -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.backup.BackupError import com.pasich.encly.core.backup.BackupKeys +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_phrase_mismatch +import com.pasich.encly.core.resources.backup_error_phrase_setup +import com.pasich.encly.core.resources.backup_export_done +import com.pasich.encly.core.resources.backup_phrase_added +import com.pasich.encly.core.resources.backup_phrase_check_wrong +import com.pasich.encly.core.security.Mnemonic import com.pasich.encly.core.security.RecoveryWords +import com.pasich.encly.core.security.ScreenshotAllowance import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup import com.pasich.encly.data.backup.ImportSummary +import com.pasich.encly.data.backup.NoBackupPreferences import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task +import com.pasich.encly.platform.DocumentRef import com.pasich.encly.presentation.designsystem.RecoveryPhraseState import com.pasich.encly.presentation.screen.backup.backupErrorMessage -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.anyCharArray @@ -50,15 +57,15 @@ import org.mockito.Mockito.`when` */ @OptIn(ExperimentalCoroutinesApi::class) class BackupFlowsTest { - private val phrase = String(MnemonicCode(WordCount.COUNT_12).chars) - private val otherPhrase = String(MnemonicCode(WordCount.COUNT_12).chars) - private val uri: Uri = mock(Uri::class.java) + private val phrase = String(Mnemonic.generate()) + private val otherPhrase = String(Mnemonic.generate()) + private val uri = DocumentRef("content://test/doc") private var written: ByteArray? = null private lateinit var security: SecurityManager private lateinit var documents: BackupDocuments private lateinit var source: InMemoryVaultDataStore - private lateinit var prefs: InMemorySharedPreferences + private lateinit var prefs: InMemoryKeyValueStore private lateinit var viewModel: BackupViewModel @Before @@ -83,11 +90,12 @@ class BackupFlowsTest { source = InMemoryVaultDataStore() runBlocking { source.insertTag(Tag(nameTag = "Work", uid = "t-work")) - source.insertNote(Note(title = "Plan", value = "[]", uid = "n-plan", tagId = 1)) + val plan = source.insertNote(Note(title = "Plan", value = "[]", uid = "n-plan")) + source.insertNoteTag(NoteTag(plan, 1)) source.insertNote(Note(title = "Ideas", value = "[]", uid = "n-ideas")) source.insertTask(Task(title = "Call", uid = "k-call")) } - prefs = InMemorySharedPreferences() + prefs = InMemoryKeyValueStore() viewModel = viewModelFor(source, prefs) } @@ -106,7 +114,7 @@ class BackupFlowsTest { assertNotNull(written) assertEquals(BackupStep.Idle, state().step) - assertEquals(BackupMessage.Text(R.string.backup_export_done), state().message) + assertEquals(BackupMessage.Text(Res.string.backup_export_done), state().message) assertNotNull(state().lastExportAt) assertEquals( "remembered across screens", @@ -181,7 +189,7 @@ class BackupFlowsTest { assertEquals(listOf("Work"), target.tags.map { it.nameTag }) assertEquals(listOf("Call"), target.tasks.map { it.title }) val plan = target.notes.single { it.uid == "n-plan" } - assertEquals("the note keeps its tag", target.tags.single().id, plan.tagId) + assertEquals("the note keeps its tag", setOf(target.tags.single().id), target.tagIdsOf(plan.id)) } @Test @@ -331,7 +339,7 @@ class BackupFlowsTest { assertEquals(check.positions.sorted(), check.positions) viewModel.phraseFlow.submitCheck(listOf("wrong", "words", "here")) - assertEquals(R.string.backup_phrase_check_wrong, (state().step as BackupStep.CheckNewPhrase).error) + assertEquals(Res.string.backup_phrase_check_wrong, (state().step as BackupStep.CheckNewPhrase).error) viewModel.phraseFlow.clearError() assertNull((state().step as BackupStep.CheckNewPhrase).error) @@ -339,7 +347,7 @@ class BackupFlowsTest { waitFor { it == BackupStep.PickExportTarget } verify(security).addRecoverySeed(anyChars()) - assertEquals(BackupMessage.Text(R.string.backup_phrase_added), state().message) + assertEquals(BackupMessage.Text(Res.string.backup_phrase_added), state().message) } @Test @@ -351,7 +359,7 @@ class BackupFlowsTest { viewModel.phraseFlow.submitCheck(check.expected.take(2)) - assertEquals(R.string.backup_phrase_check_wrong, (state().step as BackupStep.CheckNewPhrase).error) + assertEquals(Res.string.backup_phrase_check_wrong, (state().step as BackupStep.CheckNewPhrase).error) verify(security, never()).addRecoverySeed(anyChars()) } @@ -366,7 +374,7 @@ class BackupFlowsTest { viewModel.phraseFlow.submitCheck(check.expected) waitFor { it == BackupStep.Idle } - assertEquals(BackupMessage.Text(R.string.backup_error_phrase_setup), state().message) + assertEquals(BackupMessage.Text(Res.string.backup_error_phrase_setup), state().message) } @Test @@ -377,7 +385,7 @@ class BackupFlowsTest { waitFor { it == BackupStep.ConfirmExistingPhrase() } viewModel.phraseFlow.submitExisting(typedIntoCells(otherPhrase)) - waitFor { it == BackupStep.ConfirmExistingPhrase(R.string.backup_error_phrase_mismatch) } + waitFor { it == BackupStep.ConfirmExistingPhrase(Res.string.backup_error_phrase_mismatch) } viewModel.phraseFlow.clearError() assertEquals(BackupStep.ConfirmExistingPhrase(), state().step) @@ -423,16 +431,15 @@ class BackupFlowsTest { // --- helpers ------------------------------------------------------------------------------ - private fun viewModelFor( - store: InMemoryVaultDataStore, - prefs: InMemorySharedPreferences = InMemorySharedPreferences(), - ) = BackupViewModel( - backupManager = BackupManager(security, store, prefs), - phraseSetup = BackupPhraseSetup(security), - securityManager = security, - sessionLockManager = SessionLockManager(security), - documents = documents, - ) + private fun viewModelFor(store: InMemoryVaultDataStore, prefs: InMemoryKeyValueStore = InMemoryKeyValueStore()) = + BackupViewModel( + backupManager = BackupManager(security, store, prefs, NoBackupPreferences), + phraseSetup = BackupPhraseSetup(security), + securityManager = security, + sessionLockManager = SessionLockManager(security), + documents = documents, + screenshots = ScreenshotAllowance(), + ) private fun state() = viewModel.uiState.value @@ -447,7 +454,7 @@ class BackupFlowsTest { viewModel.onSystemPickerLaunched() viewModel.exportFlow.onTarget(uri) waitFor { it == BackupStep.Idle } - assertTrue(state().message == BackupMessage.Text(R.string.backup_export_done)) + assertTrue(state().message == BackupMessage.Text(Res.string.backup_export_done)) } private fun pickBackup() { @@ -474,7 +481,7 @@ class BackupFlowsTest { } } - private fun anyUri(): Uri = ArgumentMatchers.any(Uri::class.java) ?: uri + private fun anyUri(): DocumentRef = ArgumentMatchers.any(DocumentRef::class.java) ?: uri private fun anyChars(): CharArray = ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0) diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt new file mode 100644 index 00000000..d0b57239 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt @@ -0,0 +1,290 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_no_picker +import com.pasich.encly.core.resources.backup_phrase_exists +import com.pasich.encly.core.resources.lock_wrong_pin +import com.pasich.encly.core.resources.security_erase_failed +import com.pasich.encly.core.security.ScreenshotAllowance +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.data.backup.BackupDocuments +import com.pasich.encly.data.backup.BackupManager +import com.pasich.encly.data.backup.BackupPhraseSetup +import com.pasich.encly.data.backup.NoBackupPreferences +import com.pasich.encly.testutil.FakeDocuments +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.testutil.InMemoryVaultDataStore +import com.pasich.encly.testutil.anyCharArray +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +@OptIn(ExperimentalCoroutinesApi::class) +class BackupViewModelTest { + + private lateinit var security: SecurityManager + private lateinit var viewModel: BackupViewModel + private val screenshots = ScreenshotAllowance() + private lateinit var sessionLock: SessionLockManager + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + security = mock(SecurityManager::class.java) + sessionLock = SessionLockManager(security, screenshots = screenshots) + viewModel = BackupViewModel( + backupManager = BackupManager( + security, + InMemoryVaultDataStore(), + InMemoryKeyValueStore(), + NoBackupPreferences, + ), + phraseSetup = BackupPhraseSetup(security), + securityManager = security, + sessionLockManager = sessionLock, + documents = BackupDocuments(FakeDocuments()), + screenshots = screenshots, + ) + viewModel.start(BackupAction.IMPORT) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun theRightPinDuringALockoutIsNotReportedAsWrong() { + `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) + + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + val step = viewModel.uiState.value.step as BackupStep.Reauth + assertNull(step.error) + // Nothing is verified, so the lockout is neither extended nor reported as a wrong PIN. + verify(security, never()).verifyPin(anyCharArray()) + } + + @Test + fun theSubmittedPinIsWipedWhetherItIsCheckedOrNot() { + `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) + val refused = "123456".toCharArray() + viewModel.reauthFlow.submitPin(refused) + assertArrayEquals(CharArray(refused.size), refused) + + `when`(security.pinLockoutRemainingMillis()).thenReturn(0L) + `when`(security.verifyPin(anyCharArray())).thenReturn(false) + val checked = "000000".toCharArray() + viewModel.reauthFlow.submitPin(checked) + waitForStep { (it as? BackupStep.Reauth)?.error != null } + assertArrayEquals(CharArray(checked.size), checked) + } + + @Test + fun aWrongPinOutsideALockoutIsReported() { + `when`(security.verifyPin(anyCharArray())).thenReturn(false) + + viewModel.reauthFlow.submitPin("000000".toCharArray()) + + waitForStep { (it as? BackupStep.Reauth)?.error != null } + assertEquals(Res.string.lock_wrong_pin, (viewModel.uiState.value.step as BackupStep.Reauth).error) + } + + @Test + fun theRightPinOutsideALockoutProceeds() { + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + waitForStep { it == BackupStep.PickImportFile } + } + + @Test + fun creatingARecoveryPhraseLaterShowsNewWordsAfterThePin() { + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + `when`(security.hasRecoverySeed()).thenReturn(false) + `when`(security.generateMnemonicCode()).thenReturn(WORDS.toCharArray()) + viewModel.start(BackupAction.CREATE_PHRASE) + + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + waitForStep { it is BackupStep.ShowNewPhrase } + assertEquals(WORDS.split(' '), (viewModel.uiState.value.step as BackupStep.ShowNewPhrase).words) + } + + @Test + fun eachWrongPinCountsAFailureSoTheDotsShakeAgain() { + `when`(security.verifyPin(anyCharArray())).thenReturn(false) + + viewModel.reauthFlow.submitPin("000000".toCharArray()) + waitForStep { (it as? BackupStep.Reauth)?.failures == 1 } + viewModel.reauthFlow.submitPin("000000".toCharArray()) + + waitForStep { (it as? BackupStep.Reauth)?.failures == 2 } + } + + @Test + fun creatingARecoveryPhraseWhenOneExistsSaysSo() { + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + `when`(security.hasRecoverySeed()).thenReturn(true) + viewModel.start(BackupAction.CREATE_PHRASE) + + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + waitForStep { it == BackupStep.Idle } + assertEquals(BackupMessage.Text(Res.string.backup_phrase_exists), viewModel.uiState.value.message) + } + + @Test + fun theCheckKnowsItsWordsAndCanGoBackToThem() { + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + `when`(security.hasRecoverySeed()).thenReturn(false) + `when`(security.generateMnemonicCode()).thenReturn(WORDS.toCharArray()) + viewModel.start(BackupAction.CREATE_PHRASE) + viewModel.reauthFlow.submitPin("123456".toCharArray()) + waitForStep { it is BackupStep.ShowNewPhrase } + + viewModel.phraseFlow.writtenDown() + val check = viewModel.uiState.value.step as BackupStep.CheckNewPhrase + assertEquals(check.positions.map(WORDS.split(' ')::get), check.expected) + + viewModel.phraseFlow.showAgain() + assertEquals(WORDS.split(' '), (viewModel.uiState.value.step as BackupStep.ShowNewPhrase).words) + } + + @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 + verify(security, never()).wipeAndReset() + + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + waitForStep { it == BackupStep.Idle && viewModel.uiState.value.erased } + verify(security).wipeAndReset() + assertTrue(viewModel.uiState.value.erased) + } + + @Test + fun allowingScreenshotsNeedsReauthenticationAndNothingElse() { + `when`(security.verifyPin(anyCharArray())).thenReturn(false) + viewModel.start(BackupAction.ALLOW_SCREENSHOTS) + assertEquals(BackupStep.Reauth(BackupAction.ALLOW_SCREENSHOTS, biometric = false), viewModel.uiState.value.step) + assertFalse(screenshots.allowed.value) + + // A wrong PIN allows nothing. + viewModel.reauthFlow.submitPin("111111".toCharArray()) + waitForStep { it is BackupStep.Reauth && it.error != null } + assertFalse(screenshots.allowed.value) + + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + waitForStep { it == BackupStep.Idle && screenshots.allowed.value } + assertTrue(screenshots.allowed.value) + // No recovery phrase, no export: the re-authentication was the whole flow. + assertNull(viewModel.uiState.value.message) + } + + @Test + fun aPinCheckThatEndsAfterTheAppLockedDoesNotAllowScreenshots() { + `when`(security.isLockable()).thenReturn(true) + `when`(security.isDatabaseUnlocked()).thenReturn(true) + // The app locks (no auto-lock delay) while the PIN is being checked. + `when`(security.verifyPin(anyCharArray())).thenAnswer { + sessionLock.lockNow() + true + } + viewModel.start(BackupAction.ALLOW_SCREENSHOTS) + + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + waitForStep { it == BackupStep.Idle } + assertTrue(sessionLock.locked.value) + assertFalse(screenshots.allowed.value) + } + + @Test + fun erasingTheVaultEndsTheAllowance() { + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + `when`(security.wipeAndReset()).thenReturn(true) + screenshots.allow() + viewModel.start(BackupAction.ERASE) + + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + waitForStep { it == BackupStep.Idle && viewModel.uiState.value.erased } + assertFalse(screenshots.allowed.value) + } + + @Test + fun cancellingTheReauthenticationLeavesScreenshotsBlocked() { + viewModel.start(BackupAction.ALLOW_SCREENSHOTS) + + viewModel.cancel() + + assertEquals(BackupStep.Idle, viewModel.uiState.value.step) + assertFalse(screenshots.allowed.value) + } + + @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(Res.string.security_erase_failed), viewModel.uiState.value.message) + assertEquals(false, viewModel.uiState.value.erased) + } + + @Test + fun aDeviceWithoutAPickerEndsTheFlowWithAMessage() { + viewModel.onSystemPickerUnavailable() + + assertEquals(BackupStep.Idle, viewModel.uiState.value.step) + assertEquals( + BackupMessage.Text(Res.string.backup_error_no_picker), + viewModel.uiState.value.message, + ) + } + + /** + * verifyPin runs on Dispatchers.Default, a real thread. Waits for the whole busy block to + * end too, so nothing touches Dispatchers.Main after [tearDown] resets it. + */ + private fun waitForStep(matches: (BackupStep) -> Boolean) { + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (!matches(viewModel.uiState.value.step) || viewModel.uiState.value.busy) { + check(System.currentTimeMillis() < deadline) { "step is ${viewModel.uiState.value.step}" } + Thread.sleep(POLL_MS) + } + } + + private companion object { + const val TIMEOUT_MS = 5_000L + const val POLL_MS = 10L + const val WORDS = "one two three four five six seven eight nine ten eleven twelve" + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt index 89fcb657..60990c34 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt @@ -1,17 +1,15 @@ 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.backup.VaultSnapshot +import com.pasich.encly.data.importer.ImportDocuments 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.FakeDocuments import com.pasich.encly.testutil.InMemoryVaultDataStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -26,18 +24,12 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith import org.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 @@ -59,7 +51,7 @@ class ImportNotesViewModelTest { } private fun viewModel(vault: VaultDataStore = store) = - ImportNotesViewModel(SafImportFiles(context), vault, sessionLock, dispatcher) + ImportNotesViewModel(ImportDocuments(FakeDocuments()), vault, sessionLock, dispatcher) private fun keep(title: String, text: String, created: Long, label: String? = null, trashed: Boolean = false) = "$title.json" to ( @@ -107,7 +99,7 @@ class ImportNotesViewModelTest { 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("Work"), rows[0].tags) assertEquals(listOf(false, false, true), rows.map { it.trashed }) } @@ -193,7 +185,7 @@ class ImportNotesViewModelTest { vm.confirm() assertEquals(listOf("work", "Home"), store.tags.map { it.nameTag }) - assertEquals(store.tags.first().id, store.notes.first { it.title == "One" }.tagId) + assertEquals(setOf(store.tags.first().id), store.tagIdsOf(store.notes.first { it.title == "One" }.id)) } @Test @@ -348,7 +340,7 @@ class ImportNotesViewModelTest { } // The read runs on its own dispatcher, so begin() has stored its job before Cancel arrives. val io = StandardTestDispatcher() - vm = ImportNotesViewModel(SafImportFiles(context), cancelling, sessionLock, io) + vm = ImportNotesViewModel(ImportDocuments(FakeDocuments()), cancelling, sessionLock, io) vm.begin(ImportFixtures.textSource(*files)) io.scheduler.advanceUntilIdle() diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt new file mode 100644 index 00000000..a2d0d43a --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt @@ -0,0 +1,570 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.core.backup.BackupError +import com.pasich.encly.core.backup.BackupException +import com.pasich.encly.core.common.AppStrings +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_io +import com.pasich.encly.core.resources.backup_error_locked +import com.pasich.encly.core.resources.backup_error_target_exists +import com.pasich.encly.core.resources.lock_wrong_pin +import com.pasich.encly.core.resources.markdown_export_done +import com.pasich.encly.core.resources.pdf_export_done +import com.pasich.encly.core.resources.print_done +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.backup.VaultSnapshot +import com.pasich.encly.data.export.MarkdownDocuments +import com.pasich.encly.data.export.NoteDocument +import com.pasich.encly.data.model.Note +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.NoteDocumentRenderer +import com.pasich.encly.platform.PrintResult +import com.pasich.encly.testutil.InMemoryVaultDataStore +import com.pasich.encly.testutil.anyCharArray +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.io.Sink +import kotlinx.io.asSink +import kotlinx.io.buffered +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.ZipInputStream + +@OptIn(ExperimentalCoroutinesApi::class) +class MarkdownExportViewModelTest { + + private lateinit var security: SecurityManager + private lateinit var documents: MarkdownDocuments + private lateinit var store: InMemoryVaultDataStore + private lateinit var sessionLock: SessionLockManager + private lateinit var viewModel: MarkdownExportViewModel + private val written = ByteArrayOutputStream() + private val uri = DocumentRef("content://test/doc") + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + security = mock(SecurityManager::class.java) + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + documents = mock(MarkdownDocuments::class.java) + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + writeTo(invocation.getArgument(1) as (Sink) -> Unit) + }.`when`(documents).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + store = InMemoryVaultDataStore().apply { + notes += Note(id = 1, title = "Groceries", value = "[]", uid = "n1", date = 5L) + notes += Note(id = 2, title = "Trashed", value = "[]", uid = "n2", isTrash = true) + } + sessionLock = SessionLockManager(security) + viewModel = viewModelOver(store) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModelOver(vaultStore: VaultDataStore) = MarkdownExportViewModel( + securityManager = security, + sessionLockManager = sessionLock, + store = vaultStore, + documents = documents, + strings = mock(AppStrings::class.java) { "label" }, + ) + + private fun anyBlock(): (Sink) -> Unit = any() ?: {} + + /** What the real writer does: the block writes to a buffered sink over the document, which is flushed after it. */ + private fun writeTo(block: (Sink) -> Unit) { + val sink = written.asSink().buffered() + try { + block(sink) + } finally { + sink.flush() + } + } + + private fun step() = viewModel.uiState.value.step + + private fun authenticate() { + viewModel.confirmWarning() + viewModel.reauthFlow.submitPin("123456".toCharArray()) + waitUntil { step() is BackupStep.PickMarkdownTarget || step() == BackupStep.Idle } + } + + /** Lets the work the ViewModel started on other threads finish, so none outlives the test. */ + private fun settle() = waitUntil { true } + + private fun waitUntil(condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (!condition() || viewModel.uiState.value.busy) { + check(System.currentTimeMillis() < deadline) { "state is ${viewModel.uiState.value}" } + Thread.sleep(POLL_MS) + } + } + + private fun zipNames(): List = ZipInputStream(ByteArrayInputStream(written.toByteArray())).use { zip -> + generateSequence { zip.nextEntry }.map { it.name }.toList() + } + + // --- the order: warning, PIN, save dialog, write ------------------------------------------------- + + @Test + fun theWarningComesFirstAndNothingIsReadOrWritten() { + viewModel.start(MarkdownExportTarget.AllData) + + assertEquals(BackupStep.ConfirmPlainExport, step()) + verify(documents, never()).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + verify(security, never()).verifyPin(anyCharArray()) + } + + @Test + fun continuingFromTheWarningAsksForThePinWithTheExistingReauthStep() { + viewModel.start(MarkdownExportTarget.AllData) + + viewModel.confirmWarning() + + assertEquals(BackupStep.Reauth(BackupAction.EXPORT_MARKDOWN, biometric = false), step()) + } + + @Test + fun theWarningCannotBeSkippedByAskingForThePinDirectly() { + // Without start() there is no export pending: a PIN submitted now has nowhere to go. + viewModel.confirmWarning() + + assertEquals(BackupStep.Idle, step()) + } + + @Test + fun aWrongPinStaysOnTheReauthAndReachesNothingElse() { + `when`(security.verifyPin(anyCharArray())).thenReturn(false) + viewModel.start(MarkdownExportTarget.AllData) + viewModel.confirmWarning() + + viewModel.reauthFlow.submitPin("000000".toCharArray()) + waitUntil { (step() as? BackupStep.Reauth)?.error != null } + + assertEquals(Res.string.lock_wrong_pin, (step() as BackupStep.Reauth).error) + verify(documents, never()).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + } + + @Test + fun theExportNeverAsksForARecoveryPhraseOrSealsABackup() { + // The encrypted export would stop at NeedsPhrase for a vault without a phrase. + viewModel.start(MarkdownExportTarget.AllData) + authenticate() + + assertTrue(step() is BackupStep.PickMarkdownTarget) + verify(security, never()).hasBackupKey() + verify(security, never()).copyBackupRootKey() + } + + @Test + fun theSaveDialogSuggestsADateOnlyNameNeverATitle() { + viewModel.start(MarkdownExportTarget.AllData) + authenticate() + val all = step() as BackupStep.PickMarkdownTarget + assertTrue(all.zip) + assertTrue(all.name.matches(Regex("""encly-notes-\d{4}-\d{2}-\d{2}\.zip"""))) + + viewModel.cancel() + viewModel.start(MarkdownExportTarget.SingleNote(noteId = 1)) + authenticate() + val one = step() as BackupStep.PickMarkdownTarget + assertFalse(one.zip) + assertTrue(one.name.matches(Regex("""encly-note-\d{4}-\d{2}-\d{2}\.md"""))) + } + + // --- writing --------------------------------------------------------------------------------------- + + @Test + fun theZipIsWrittenAfterTheSaveDialogReturns() { + viewModel.start(MarkdownExportTarget.AllData) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.markdown_export_done), viewModel.uiState.value.message) + assertEquals(listOf("tasks.md", "Groceries.md"), zipNames()) + assertEquals(BackupStep.Idle, step()) + } + + @Test + fun oneNoteIsWrittenAsOneMarkdownDocument() { + viewModel.start(MarkdownExportTarget.SingleNote(noteId = 1)) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertTrue(written.toString(Charsets.UTF_8).startsWith("---\ntitle: \"Groceries\"\n")) + } + + @Test + fun aCancelledSaveDialogWritesNothingAndKeepsNothing() { + viewModel.start(MarkdownExportTarget.AllData) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(null) + viewModel.onTarget(uri) + settle() + + // The pending export was dropped with the first call: a second result has nothing to write. + // The empty document it names is removed, because nothing will be written to it. + assertEquals(0, written.size()) + verify(documents).discard(uri) + assertEquals(BackupStep.Idle, step()) + } + + @Test + fun cancellingAtAnyStepDropsTheExport() { + viewModel.start(MarkdownExportTarget.AllData) + viewModel.cancel() + assertEquals(BackupStep.Idle, step()) + + viewModel.start(MarkdownExportTarget.AllData) + viewModel.confirmWarning() + viewModel.cancel() + assertEquals(BackupStep.Idle, step()) + + viewModel.onTarget(uri) + settle() + assertEquals(0, written.size()) + } + + @Test + fun aVaultThatClosedMeanwhileFailsInsteadOfExportingAndTheEmptyDocumentIsRemoved() { + val closing = object : VaultDataStore by store { + override fun bindToCurrentSession(): VaultDataStore = this + + override suspend fun snapshot(): VaultSnapshot = error("Vault locked") + } + viewModel = viewModelOver(closing) + viewModel.start(MarkdownExportTarget.AllData) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.backup_error_locked), viewModel.uiState.value.message) + verify(documents).discard(uri) + assertEquals(0, written.size()) + } + + @Test + fun aReLockAfterTheReadStopsTheWriteBeforeTheDocumentIsOpened() { + val closing = object : VaultDataStore by store { + override fun bindToCurrentSession(): VaultDataStore = this + + // The read succeeds; the vault closes right after it. + override fun assertCurrentSession() = error("Vault locked") + } + viewModel = viewModelOver(closing) + viewModel.start(MarkdownExportTarget.AllData) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.backup_error_locked), viewModel.uiState.value.message) + verify(documents, never()).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + verify(documents).discard(uri) + assertEquals(0, written.size()) + } + + @Test + fun aPinThatVerifiesAfterTheFlowWasCancelledCannotSkipTheNextWarning() { + val release = java.util.concurrent.CountDownLatch(1) + `when`(security.verifyPin(anyCharArray())).thenAnswer { + release.await(TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS) + true + } + viewModel.start(MarkdownExportTarget.AllData) + viewModel.confirmWarning() + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + // The user backs out and starts again while the first PIN is still being checked. + viewModel.cancel() + viewModel.start(MarkdownExportTarget.AllData) + release.countDown() + waitUntil { true } + + assertEquals(BackupStep.ConfirmPlainExport, step()) + verify(documents, never()).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + } + + @Test + fun oneNoteIsReadWithoutReadingTheWholeVault() { + val noteOnly = object : VaultDataStore by store { + override fun bindToCurrentSession(): VaultDataStore = this + + override suspend fun snapshot(): VaultSnapshot = error("the whole vault must not be read") + } + viewModel = viewModelOver(noteOnly) + viewModel.start(MarkdownExportTarget.SingleNote(noteId = 1)) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.markdown_export_done), viewModel.uiState.value.message) + assertTrue(written.toString(Charsets.UTF_8).contains("Groceries")) + } + + @Test + fun theFingerprintIsOfferedOnlyWhereTheSecurityLayerAllowsIt() { + `when`(security.canReauthWithBiometric()).thenReturn(true) + viewModel.start(MarkdownExportTarget.AllData) + viewModel.confirmWarning() + assertEquals(true, (step() as BackupStep.Reauth).biometric) + + viewModel.cancel() + `when`(security.canReauthWithBiometric()).thenReturn(false) + viewModel.start(MarkdownExportTarget.AllData) + viewModel.confirmWarning() + assertEquals(false, (step() as BackupStep.Reauth).biometric) + } + + @Test + fun aVaultThatIsAlreadyLockedWhenTheExportIsAuthenticatedEndsWithAMessage() { + val locked = object : VaultDataStore by store { + override fun bindToCurrentSession(): VaultDataStore = error("Vault locked") + } + viewModel = viewModelOver(locked) + viewModel.start(MarkdownExportTarget.AllData) + viewModel.confirmWarning() + viewModel.reauthFlow.submitPin("123456".toCharArray()) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.backup_error_locked), viewModel.uiState.value.message) + assertEquals(BackupStep.Idle, step()) + } + + @Test + fun aProviderFailureIsReportedWithoutItsMessage() { + doAnswer { throw BackupException(BackupError.TARGET_EXISTS) } + .`when`(documents).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + viewModel.start(MarkdownExportTarget.AllData) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.backup_error_target_exists), viewModel.uiState.value.message) + } + + @Test + fun aNoteThatIsGoneMeanwhileFailsTheWholeWriteSoTheDocumentIsDiscardedByTheWriter() { + viewModel.start(MarkdownExportTarget.SingleNote(noteId = 99)) + authenticate() + viewModel.onSystemPickerLaunched() + // The real writer deletes the document when its block throws; the fake only runs the block. + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + runCatching { writeTo(invocation.getArgument(1) as (Sink) -> Unit) } + .getOrElse { throw it } + }.`when`(documents).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.backup_error_io), viewModel.uiState.value.message) + } + + @Test + fun theDecoyVaultExportsWhatItHolds() { + `when`(security.isDecoySession()).thenReturn(true) + store.notes.clear() + store.notes += Note(id = 1, title = "Decoy note", value = "[]", uid = "d1") + viewModel.start(MarkdownExportTarget.AllData) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(listOf("tasks.md", "Decoy note.md"), zipNames()) + } + + // --- one note as a PDF or a printout (the desktop) --------------------------------------------------- + + private class FakeRenderer : NoteDocumentRenderer { + val pdfs = mutableListOf() + val printed = mutableListOf() + var printResult = PrintResult.PRINTED + var printFails = false + + override fun writePdf(document: NoteDocument, out: Sink) { + pdfs += document + out.write("%PDF-fake".encodeToByteArray()) + } + + override suspend fun print(document: NoteDocument, mayProceed: () -> Boolean): PrintResult { + if (printFails) throw java.io.IOException("no printer") + printed += document + return printResult + } + } + + private val renderer = FakeRenderer() + + private fun asPdf(id: Long = 1) = MarkdownExportTarget.NoteAsDocument(id, DocumentFormat.PDF, renderer) + + private fun asPrint(id: Long = 1) = MarkdownExportTarget.NoteAsDocument(id, DocumentFormat.PRINT, renderer) + + @Test + fun aPdfAsksTheSameWarningAndThePinBeforeTheSaveDialog() { + viewModel.start(asPdf()) + assertEquals(BackupStep.ConfirmDocumentExport(print = false), step()) + verify(security, never()).verifyPin(anyCharArray()) + + authenticate() + + val picker = step() as BackupStep.PickMarkdownTarget + assertTrue(picker.pdf) + assertFalse(picker.zip) + assertTrue(picker.name.matches(Regex("""encly-note-\d{4}-\d{2}-\d{2}\.pdf"""))) + assertTrue(renderer.pdfs.isEmpty()) + } + + @Test + fun thePdfIsBuiltAfterTheSaveDialogReturnsFromTheStoredNote() { + viewModel.start(asPdf()) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.pdf_export_done), viewModel.uiState.value.message) + assertEquals("%PDF-fake", written.toString(Charsets.UTF_8)) + assertEquals("Groceries", renderer.pdfs.single().title) + } + + @Test + fun aCancelledPdfWritesNothingAndTouchesNoRenderer() { + viewModel.start(asPdf()) + viewModel.cancel() + viewModel.start(asPdf()) + viewModel.confirmWarning() + viewModel.cancel() + viewModel.start(asPdf()) + authenticate() + viewModel.onSystemPickerLaunched() + viewModel.onTarget(null) + settle() + + assertEquals(0, written.size()) + assertTrue(renderer.pdfs.isEmpty()) + verify(documents, never()).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + assertEquals(BackupStep.Idle, step()) + } + + @Test + fun aPdfOfANoteThatIsGoneFailsAndTheEmptyDocumentIsRemoved() { + viewModel.start(asPdf(id = 2)) + authenticate() + viewModel.onSystemPickerLaunched() + + viewModel.onTarget(uri) + waitUntil { viewModel.uiState.value.message != null } + + // The note in the trash is not exported. + assertEquals(BackupMessage.Text(Res.string.backup_error_io), viewModel.uiState.value.message) + assertTrue(renderer.pdfs.isEmpty()) + } + + @Test + fun printingWarnsAboutThePrinterThenHandsTheNoteOverWithoutAFile() { + viewModel.start(asPrint()) + assertEquals(BackupStep.ConfirmDocumentExport(print = true), step()) + + authenticate() + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.print_done), viewModel.uiState.value.message) + assertEquals("Groceries", renderer.printed.single().title) + assertEquals(0, written.size()) + verify(documents, never()).write(any(DocumentRef::class.java) ?: uri, anyBlock()) + } + + @Test + fun aClosedPrintDialogEndsQuietly() { + renderer.printResult = PrintResult.CANCELLED + viewModel.start(asPrint()) + authenticate() + settle() + + assertEquals(null, viewModel.uiState.value.message) + assertEquals(BackupStep.Idle, step()) + } + + @Test + fun aPrinterThatFailsIsReportedWithoutItsMessage() { + renderer.printFails = true + viewModel.start(asPrint()) + authenticate() + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.backup_error_io), viewModel.uiState.value.message) + } + + @Test + fun aReLockBeforeThePrintStopsItBeforeTheDialog() { + val closing = object : VaultDataStore by store { + override fun bindToCurrentSession(): VaultDataStore = this + + override fun assertCurrentSession() = error("Vault locked") + } + viewModel = viewModelOver(closing) + viewModel.start(asPrint()) + authenticate() + waitUntil { viewModel.uiState.value.message != null } + + assertEquals(BackupMessage.Text(Res.string.backup_error_locked), viewModel.uiState.value.message) + assertTrue(renderer.printed.isEmpty()) + } + + @Test + fun aCancelledPrintAtTheWarningOrThePinPrintsNothing() { + viewModel.start(asPrint()) + viewModel.cancel() + viewModel.start(asPrint()) + viewModel.confirmWarning() + viewModel.cancel() + settle() + + assertTrue(renderer.printed.isEmpty()) + assertEquals(BackupStep.Idle, step()) + } + + private companion object { + const val TIMEOUT_MS = 5_000L + const val POLL_MS = 10L + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt similarity index 88% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt index 14f13a24..8a96a6ba 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt @@ -1,21 +1,25 @@ package com.pasich.encly.presentation.viewmodel -import android.net.Uri -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.backup.BackupCipher import com.pasich.encly.core.backup.BackupPayloadCodec import com.pasich.encly.core.backup.BackupSecret import com.pasich.encly.core.common.UiText +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_wrong_phrase +import com.pasich.encly.core.resources.pin_mismatch_retry +import com.pasich.encly.core.resources.pin_save_failed +import com.pasich.encly.core.security.Mnemonic import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.bip39.Bip39 import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupMapper +import com.pasich.encly.data.backup.NoBackupPreferences import com.pasich.encly.data.backup.PendingRestore import com.pasich.encly.data.model.Note import com.pasich.encly.domain.usecase.OnboardingUseCase -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import com.pasich.encly.testutil.anyCharArray import kotlinx.coroutines.Dispatchers @@ -44,7 +48,7 @@ import org.mockito.Mockito.`when` @OptIn(ExperimentalCoroutinesApi::class) class OnboardingViewModelTest { - private val words = MnemonicCode(WordCount.COUNT_12).chars + private val words = Mnemonic.generate() private val wordList = String(words).split(" ") private lateinit var security: SecurityManager private lateinit var store: InMemoryVaultDataStore @@ -65,7 +69,7 @@ class OnboardingViewModelTest { } `when`(security.configurePin(anyCharArray())).thenReturn(true) `when`(security.isValidRecoveryPhrase(anyChars() ?: CharArray(0))).thenAnswer { - runCatching { MnemonicCode(it.getArgument(0).copyOf()).validate() }.isSuccess + Bip39().isValid(it.getArgument(0)) } store = InMemoryVaultDataStore() documents = mock(BackupDocuments::class.java) @@ -78,7 +82,7 @@ class OnboardingViewModelTest { private fun viewModel(biometric: Boolean = false): OnboardingViewModel { `when`(security.biometricAvailable()).thenReturn(biometric) - val backupManager = BackupManager(security, store, InMemorySharedPreferences()) + val backupManager = BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences) pending = PendingRestore(backupManager) return OnboardingViewModel(OnboardingUseCase(security, backupManager, pending), documents) } @@ -169,21 +173,49 @@ class OnboardingViewModelTest { assertEquals(OnboardingStep.PIN, vm.state.step) assertFalse(vm.state.isConfirmingPin) assertEquals(0, vm.state.pinLength) - assertEquals(UiText.of(R.string.pin_mismatch_retry), vm.state.pinError) + assertEquals(UiText.of(Res.string.pin_mismatch_retry), vm.state.pinError) } @Test - fun thePinTakesSixDigitsAndContinueNeedsAllOfThem() { + fun thePinTakesSixToTwelveDigitsAndContinueNeedsAtLeastSix() { val vm = viewModel() vm.getStarted() - vm.typePin("1234567") - assertEquals(6, vm.state.pinLength) - vm.onPinBackspace() + vm.typePin("12345") vm.submitPin() - assertEquals(5, vm.state.pinLength) assertFalse("five digits do not continue", vm.state.isConfirmingPin) + + vm.typePin("67890123456") + assertEquals(12, vm.state.pinLength) + } + + @Test + fun aPinLongerThanSixDigitsIsConfirmedWhole() { + val vm = viewModel() + vm.getStarted() + + vm.typePin("12345678") + vm.submitPin() + assertTrue(vm.state.isConfirmingPin) + vm.typePin("12345678") + vm.submitPin() + + assertEquals(OnboardingStep.RECOVERY_INFO, vm.state.step) + } + + @Test + fun aLongerPinAndItsPrefixDoNotMatch() { + val vm = viewModel() + vm.getStarted() + + vm.typePin("12345678") + vm.submitPin() + vm.typePin("123456") + vm.submitPin() + + assertEquals(OnboardingStep.PIN, vm.state.step) + assertEquals(UiText.of(Res.string.pin_mismatch_retry), vm.state.pinError) } @Test @@ -275,7 +307,7 @@ class OnboardingViewModelTest { vm.completeVerification() vm.settled() assertEquals(OnboardingStep.VERIFY, vm.state.step) - assertEquals(UiText.of(R.string.pin_save_failed), vm.state.error) + assertEquals(UiText.of(Res.string.pin_save_failed), vm.state.error) `when`(security.configurePin(anyCharArray())).thenReturn(true) vm.completeVerification() @@ -319,7 +351,7 @@ class OnboardingViewModelTest { } private suspend fun OnboardingViewModel.pickBackup() { - val uri = mock(Uri::class.java) + val uri = DocumentRef("content://test/doc") `when`(documents.read(uri)).thenReturn(backupFile()) `when`(documents.displayName(uri)).thenReturn("encly.enclybak") onRestoreFilePicked(uri) @@ -360,11 +392,11 @@ class OnboardingViewModelTest { vm.startRestore() vm.pickBackup() - vm.restoreBackup(MnemonicCode(WordCount.COUNT_12).chars.copyOf()) + vm.restoreBackup(Mnemonic.generate().copyOf()) vm.settled() assertEquals(OnboardingStep.RESTORE, vm.state.step) - assertEquals(UiText.of(R.string.backup_error_wrong_phrase), vm.state.restorePhraseError) + assertEquals(UiText.of(Res.string.backup_error_wrong_phrase), vm.state.restorePhraseError) assertNull(vm.state.restoreFileError) verify(security, never()).initializeNewVault(anyChars()) } @@ -374,7 +406,7 @@ class OnboardingViewModelTest { val vm = viewModel() vm.startRestore() vm.pickBackup() - vm.restoreBackup(MnemonicCode(WordCount.COUNT_12).chars.copyOf()) + vm.restoreBackup(Mnemonic.generate().copyOf()) vm.settled() vm.onRestorePhraseEdited() @@ -399,7 +431,7 @@ class OnboardingViewModelTest { fun aFileThatIsNotABackupIsReportedUnderThePicker() = runTest { val vm = viewModel() vm.startRestore() - val uri = mock(Uri::class.java) + val uri = DocumentRef("content://test/doc") `when`(documents.read(uri)).thenReturn(ByteArray(64) { 7 }) vm.onRestoreFilePicked(uri) diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/testutil/ActivityTestWindow.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/testutil/ActivityTestWindow.kt new file mode 100644 index 00000000..f4993209 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/testutil/ActivityTestWindow.kt @@ -0,0 +1,9 @@ +package com.pasich.encly.testutil + +import android.app.Activity +import com.pasich.encly.platform.AppWindow + +/** The window of a test Activity: it says when the Activity is being recreated, as the app's does. */ +internal class ActivityTestWindow(private val activity: Activity) : AppWindow by AppWindow.None { + override val isChangingConfigurations: Boolean get() = activity.isChangingConfigurations +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/testutil/TestLocale.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/testutil/TestLocale.kt new file mode 100644 index 00000000..c2a3aee7 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/testutil/TestLocale.kt @@ -0,0 +1,27 @@ +package com.pasich.encly.testutil + +import android.os.LocaleList +import java.util.Locale + +/** + * The process's default locale, which is what the Compose resources read: a test that wants a + * language sets it here and puts the original back afterwards. + */ +object TestLocale { + fun set(tag: String) { + val locales = LocaleList.forLanguageTags(tag) + LocaleList.setDefault(locales) + Locale.setDefault(locales[0]) + } + + fun within(tag: String, block: () -> T): T { + val saved = LocaleList.getDefault() + set(tag) + try { + return block() + } finally { + LocaleList.setDefault(saved) + Locale.setDefault(saved[0]) + } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AdaptiveAppTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AdaptiveAppTest.kt new file mode 100644 index 00000000..f9a538fa --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AdaptiveAppTest.kt @@ -0,0 +1,260 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.lifecycle.ViewModel +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.empty_notes +import com.pasich.encly.core.resources.lock_title +import com.pasich.encly.core.resources.open_menu +import com.pasich.encly.core.resources.pane_select_note +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.data.model.Note +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.designsystem.EnclyShortcuts +import com.pasich.encly.presentation.navigation.EnclyApp +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel +import com.pasich.encly.presentation.viewmodel.NoteTabsViewModel +import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.presentation.viewmodel.ThemeViewModel +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.annotation.Config +import kotlin.reflect.KClass + +/** + * The whole app in windows of each size, the size measured from the window itself: a phone gets the phone + * layout, a desktop window the expanded one, and a re-lock leaves nothing of the open notes on screen. + */ +class AdaptiveAppTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private fun note(id: Long, title: String) = Note( + id = id, + title = title, + value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("body")))), + date = 1_700_000_000_000 + id, + ) + + private inner class AppFactory(private val tabs: NoteTabsViewModel) : MetroViewModelFactory() { + override val viewModelProviders = ( + listOf(ThemeViewModel(app.settings), app.lock(), tabs) + app.home() + ).associate { it::class to { it } } + mapOf, () -> ViewModel>( + TagListViewModel::class to { app.tagList() }, + MarkdownExportViewModel::class to { app.markdownExport() }, + ) + + override val assistedFactoryProviders = mapOf, () -> ViewModelAssistedFactory>( + EditNoteViewModel::class to { + object : ViewModelAssistedFactory { + override fun create(extras: CreationExtras): ViewModel = + app.editor(noteId = extras.createSavedStateHandle()["idNote"] ?: -1L) + } + }, + ) + } + + private val tabs = NoteTabsViewModel() + + private fun show(declined: Set = emptySet()) { + app.withNotes(note(1, "Trip"), note(2, "Plan")) + `when`(app.security.isDatabaseUnlocked()).thenReturn(true) + `when`(app.security.hasRecoverySeed()).thenReturn(true) + rule.setContent { + CompositionLocalProvider(LocalMetroViewModelFactory provides AppFactory(tabs)) { + EnclyApp( + InitialStatus.MAIN, + app.security, + app.sessionLock, + shortcuts = EnclyShortcuts(commandKeyIsMeta = false), + declinedCommands = declined, + ) + } + } + rule.waitForIdle() + } + + private fun chord(keys: List) { + rule.onRoot().performKeyInput { + keys.dropLast(1).forEach { keyDown(it) } + pressKey(keys.last()) + keys.dropLast(1).reversed().forEach { keyUp(it) } + } + rule.waitForIdle() + } + + private fun openTrip() { + waitFor { rule.onAllNodes(hasText("Trip") and hasClickAction()).fetchSemanticsNodes().isNotEmpty() } + rule.onAllNodes(hasText("Trip") and hasClickAction())[0].performClick() + waitFor { tabs.tabs.tabs.isNotEmpty() } + } + + @Test + fun aPhoneWindowGetsThePhoneLayout() { + show() + + waitFor { rule.onAllNodes(hasText("Trip")).fetchSemanticsNodes().isNotEmpty() } + rule.onNodeWithContentDescription(str(Res.string.open_menu)).assertExists() + assertEquals(0, countText(str(Res.string.pane_select_note))) + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun aDesktopWindowGetsTheExpandedLayout() { + show() + + waitForText(str(Res.string.pane_select_note)) + assertEquals(0, rule.onAllNodes(hasText(str(Res.string.open_menu))).fetchSemanticsNodes().size) + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun aReLockWithNotesOpenInThePaneLeavesNothingOfThemOnScreenOrInMemory() { + show() + waitFor { rule.onAllNodes(hasText("Trip") and hasClickAction()).fetchSemanticsNodes().isNotEmpty() } + rule.onAllNodes(hasText("Trip") and hasClickAction())[0].performClick() + waitFor { rule.onAllNodes(hasText("Trip") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + assertEquals(1, tabs.tabs.tabs.size) + + rule.runOnIdle { app.sessionLock.requireUnlock() } + + waitFor { countText(str(Res.string.lock_title)) > 0 } + assertEquals(0, countText("Trip")) + assertEquals(0, countText(str(Res.string.empty_notes))) + assertEquals(0, countText(str(Res.string.pane_select_note))) + assertTrue(tabs.tabs.tabs.isEmpty()) + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun theNewNoteShortcutOpensABlankTabInAnExpandedWindow() { + show() + waitForText(str(Res.string.pane_select_note)) + + chord(listOf(Key.CtrlLeft, Key.N)) + + waitFor { tabs.tabs.tabs.size == 1 } + assertEquals(-1L, tabs.tabs.tabs.single().noteId) + waitFor { countText(str(Res.string.pane_select_note)) == 0 } + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun theCloseTabShortcutClosesTheShownTab() { + show() + openTrip() + + chord(listOf(Key.CtrlLeft, Key.W)) + + waitFor { tabs.tabs.tabs.isEmpty() } + waitForText(str(Res.string.pane_select_note)) + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun theTabShortcutsStepThroughTheTabs() { + show() + openTrip() + waitFor { rule.onAllNodes(hasText("Plan") and hasClickAction()).fetchSemanticsNodes().isNotEmpty() } + rule.onAllNodes(hasText("Plan") and hasClickAction())[0].performClick() + waitFor { tabs.tabs.tabs.size == 2 && tabs.tabs.active?.noteId == 2L } + waitFor { rule.onAllNodes(hasText("Plan") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + + chord(listOf(Key.CtrlLeft, Key.Tab)) + waitFor { tabs.tabs.active?.noteId == 1L } + waitFor { rule.onAllNodes(hasText("Trip") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + + chord(listOf(Key.CtrlLeft, Key.ShiftLeft, Key.Tab)) + waitFor { tabs.tabs.active?.noteId == 2L } + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun aCommandTheHostDeclinesIsLeftToTheMenu() { + show(declined = setOf(AppCommand.NewNote)) + waitForText(str(Res.string.pane_select_note)) + + chord(listOf(Key.CtrlLeft, Key.N)) + + assertTrue(tabs.tabs.tabs.isEmpty()) + // Only the declined command is: the others still run. + openTrip() + chord(listOf(Key.CtrlLeft, Key.W)) + waitFor { tabs.tabs.tabs.isEmpty() } + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun theLockShortcutLocksTheApp() { + `when`(app.security.isLockable()).thenReturn(true) + show() + waitForText(str(Res.string.pane_select_note)) + + chord(listOf(Key.CtrlLeft, Key.L)) + + waitFor { app.sessionLock.locked.value } + verify(app.security).lock() + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun noShortcutRunsWhileTheVaultIsLocked() { + `when`(app.security.isLockable()).thenReturn(true) + show() + waitForText(str(Res.string.pane_select_note)) + rule.runOnIdle { app.sessionLock.requireUnlock() } + waitFor { countText(str(Res.string.lock_title)) > 0 } + + chord(listOf(Key.CtrlLeft, Key.N)) + chord(listOf(Key.CtrlLeft, Key.L)) + + assertTrue(tabs.tabs.tabs.isEmpty()) + verify(app.security, never()).lock() + assertTrue(countText(str(Res.string.lock_title)) > 0) + } + + @Test + fun onAPhoneTheNewNoteShortcutOpensTheFullScreenEditorLikeTheButton() { + show() + waitFor { rule.onAllNodes(hasText("Trip")).fetchSemanticsNodes().isNotEmpty() } + + chord(listOf(Key.CtrlLeft, Key.N)) + + waitFor { rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + assertTrue(tabs.tabs.tabs.isEmpty()) + assertEquals(0, countText(str(Res.string.pane_select_note))) + } + + @Test + fun onAPhoneTheTabShortcutsDoNothing() { + show() + waitFor { rule.onAllNodes(hasText("Trip")).fetchSemanticsNodes().isNotEmpty() } + + chord(listOf(Key.CtrlLeft, Key.W)) + chord(listOf(Key.CtrlLeft, Key.Tab)) + + assertTrue(tabs.tabs.tabs.isEmpty()) + assertTrue(countText("Trip") > 0) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AdaptiveLayoutTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AdaptiveLayoutTest.kt new file mode 100644 index 00000000..9f3f715c --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AdaptiveLayoutTest.kt @@ -0,0 +1,491 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.rightClick +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.close +import com.pasich.encly.core.resources.dialog_dismiss +import com.pasich.encly.core.resources.main_drawer_settings +import com.pasich.encly.core.resources.main_drawer_tasks +import com.pasich.encly.core.resources.note_open +import com.pasich.encly.core.resources.note_open_in_new_tab +import com.pasich.encly.core.resources.open_menu +import com.pasich.encly.core.resources.pane_select_note +import com.pasich.encly.core.resources.sort_by +import com.pasich.encly.core.resources.swipe_choice_delete +import com.pasich.encly.core.resources.swipe_delete_title +import com.pasich.encly.core.resources.view_options +import com.pasich.encly.data.model.Note +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.presentation.dialogs.SHEET_DIALOG_TAG +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.navigation.RelockReturn +import com.pasich.encly.presentation.pane.CenteredContent +import com.pasich.encly.presentation.pane.NOTE_TAB_TAG +import com.pasich.encly.presentation.pane.NavigationPage +import com.pasich.encly.presentation.screen.MainRootScreen +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel +import com.pasich.encly.presentation.viewmodel.NoteTabsViewModel +import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.ui.theme.LocalPointerFirst +import com.pasich.encly.ui.theme.LocalWidthClass +import com.pasich.encly.ui.theme.WidthClass +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.robolectric.annotation.Config +import kotlin.reflect.KClass + +/** + * The layout at each window size: the phone layout at compact (the default of every other test), a rail at + * medium, and at expanded the permanent drawer with the notes list beside the open notes, whose tabs are + * opened, switched and closed. The window is 1280 dp wide; the class each test lays out in is provided + * directly, as the app does from the window's measured size. + */ +@Config(qualifiers = "w1280dp-h800dp-xhdpi") +class AdaptiveLayoutTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + /** The editors the detail pane makes: each reads its note from its own destination's arguments. */ + private inner class PaneFactory : MetroViewModelFactory() { + override val viewModelProviders = + mapOf, () -> androidx.lifecycle.ViewModel>( + TagListViewModel::class to { app.tagList() }, + MarkdownExportViewModel::class to { app.markdownExport() }, + ) + + override val assistedFactoryProviders = + mapOf, () -> ViewModelAssistedFactory>( + EditNoteViewModel::class to { + object : ViewModelAssistedFactory { + override fun create(extras: CreationExtras): androidx.lifecycle.ViewModel { + val args = extras.createSavedStateHandle() + return app.editor( + noteId = args["idNote"] ?: -1L, + copySource = args["copySource"] ?: -1L, + ) + } + } + }, + ) + } + + private fun note(id: Long, title: String, text: String = "") = Note( + id = id, + title = title, + value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow(text)))), + date = 1_700_000_000_000 + id, + ) + + /** Whether the window is driven by a mouse first, as the desktop is; a phone or tablet is not. */ + private var pointerFirst = false + + private fun showHome(widthClass: WidthClass, vararg notes: Note) { + app.withNotes(*notes) + setNavScreen(viewModels(*app.home(), NoteTabsViewModel()), route = NavRoutes.HomeRoute.name) { nav -> + CompositionLocalProvider( + LocalWidthClass provides widthClass, + LocalPointerFirst provides pointerFirst, + LocalMetroViewModelFactory provides PaneFactory(), + LocalLayoutDirection provides direction, + ) { WithShortcuts { MainRootScreen(nav) } } + } + } + + /** The layout direction of the window, left to right unless a test says otherwise. */ + private var direction = LayoutDirection.Ltr + + private fun listCard(title: String) = + rule.onNode(hasText(title) and hasClickAction() and !isTab and !hasSetTextAction()) + + private val isTab = hasTestTag(NOTE_TAB_TAG) + + private fun tabs(title: String? = null) = rule.onAllNodes( + if (title == null) isTab else isTab and hasText(title), + ).fetchSemanticsNodes() + + // Selected through its click action: a tap in the middle of a small tab could land on its close button. + private fun selectTab(title: String) = rule.onNode(isTab and hasText(title)) + .performSemanticsAction(SemanticsActions.OnClick) + + private fun closeButton(title: String) = rule.onNode( + hasContentDescription("${str(Res.string.close)} $title") and hasClickAction(), + ) + + private fun count(description: String) = + rule.onAllNodes(hasContentDescription(description), useUnmergedTree = true).fetchSemanticsNodes().size + + private fun openFromList(title: String) { + rule.onNode(hasText(title) and hasClickAction()).performClick() + waitFor { tabs(title).isNotEmpty() } + } + + @Test + fun aCompactWindowKeepsTheDrawerButtonAndHasNoRailOrPane() { + showHome(WidthClass.Compact, note(1, "Trip")) + + assertEquals(1, count(str(Res.string.open_menu))) + assertEquals(0, count(str(Res.string.main_drawer_tasks))) + assertEquals(0, countText(str(Res.string.pane_select_note))) + } + + private fun centreX(text: String) = rule.onNode(hasText(text)).fetchSemanticsNode().boundsInRoot.center.x + + @Test + fun anExpandedWindowPutsTheDrawerThenTheNotesThenThePaneFromLeftToRight() { + showHome(WidthClass.Expanded, note(1, "Trip")) + + val drawer = rule.onAllNodes(hasText(str(Res.string.main_drawer_tasks))).onFirst().fetchSemanticsNode() + assertTrue(drawer.boundsInRoot.center.x < centreX("Trip")) + assertTrue(centreX("Trip") < centreX(str(Res.string.pane_select_note))) + } + + @Test + fun anExpandedWindowMirrorsItsDrawerNotesAndPaneInARightToLeftLayout() { + direction = LayoutDirection.Rtl + showHome(WidthClass.Expanded, note(1, "Trip")) + + val drawer = rule.onAllNodes(hasText(str(Res.string.main_drawer_tasks))).onFirst().fetchSemanticsNode() + assertTrue(drawer.boundsInRoot.center.x > centreX("Trip")) + assertTrue(centreX("Trip") > centreX(str(Res.string.pane_select_note))) + } + + @Test + fun aMediumWindowHasARailInsteadOfTheDrawerButton() { + showHome(WidthClass.Medium, note(1, "Trip")) + + assertEquals(0, count(str(Res.string.open_menu))) + assertTrue(count(str(Res.string.main_drawer_tasks)) > 0) + assertEquals(0, countText(str(Res.string.pane_select_note))) + assertEquals(1, countText("Trip")) + } + + @Test + fun aMediumWindowOpensANoteFullScreenLikeAPhone() { + showHome(WidthClass.Medium, note(1, "Trip")) + + rule.onNode(hasText("Trip") and hasClickAction()).performClick() + waitFor { currentRouteName() == NavRoutes.EditNoteRoute.name } + } + + @Test + fun anExpandedWindowShowsTheDrawerTheNotesAndAnEmptyPane() { + showHome(WidthClass.Expanded, note(1, "Trip")) + + assertEquals(0, count(str(Res.string.open_menu))) + assertTrue(countText(str(Res.string.main_drawer_tasks)) > 0) + assertEquals(1, countText("Trip")) + assertEquals(1, countText(str(Res.string.pane_select_note))) + assertTrue(tabs().isEmpty()) + } + + @Test + fun pickingANoteOpensItInTheDetailPaneAndStaysOnTheNotesScreen() { + showHome(WidthClass.Expanded, note(1, "Trip", "body")) + + openFromList("Trip") + + assertEquals(NavRoutes.HomeRoute.name, currentRouteName()) + assertEquals(0, countText(str(Res.string.pane_select_note))) + // The list row, the tab and the editor's title field. + waitFor { rule.onAllNodes(hasText("Trip") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + assertEquals(1, tabs("Trip").size) + } + + @Test + fun theShownNoteIsRecordedOnTheHomeEntryForARelockReturn() { + showHome(WidthClass.Expanded, note(5, "Trip")) + + openFromList("Trip") + + waitFor { + rule.runOnIdle { + nav.getBackStackEntry(NavRoutes.HomeRoute.name).savedStateHandle.get(RelockReturn.OPEN_NOTE_ID) + } == 5L + } + } + + @Test + fun twoNotesAreTwoTabsAndAClickOnATabShowsItsNote() { + showHome(WidthClass.Expanded, note(1, "Trip"), note(2, "Plan")) + + openFromList("Trip") + openFromList("Plan") + + assertEquals(2, tabs().size) + waitFor { rule.onAllNodes(hasText("Plan") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + + selectTab("Trip") + waitFor { rule.onAllNodes(hasText("Trip") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + assertEquals(0, rule.onAllNodes(hasText("Plan") and hasSetTextAction()).fetchSemanticsNodes().size) + assertEquals(2, tabs().size) + } + + @Test + fun anOpenNoteIsNotOpenedTwice() { + showHome(WidthClass.Expanded, note(1, "Trip"), note(2, "Plan")) + + openFromList("Trip") + openFromList("Plan") + rule.onNode(hasText("Trip") and hasClickAction() and !isTab).performClick() + rule.waitForIdle() + + assertEquals(2, tabs().size) + } + + @Test + fun leavingATabSavesItsEdits() { + showHome(WidthClass.Expanded, note(1, "Trip"), note(2, "Plan")) + openFromList("Trip") + waitFor { rule.onAllNodes(hasText("Trip") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + rule.onAllNodes(hasText("Trip") and hasSetTextAction())[0].performTextInput(" two") + + openFromList("Plan") + + waitFor { app.notes.updatedNotes.any { it.id == 1L && it.title.contains("two") } } + } + + @Test + fun closingTheShownTabLeavesTheEmptyPaneWhenItWasTheLastOne() { + showHome(WidthClass.Expanded, note(1, "Trip")) + openFromList("Trip") + + closeButton("Trip").performClick() + + waitFor { tabs().isEmpty() } + waitForText(str(Res.string.pane_select_note)) + } + + @Test + fun closingTheShownTabShowsTheOtherOne() { + showHome(WidthClass.Expanded, note(1, "Trip"), note(2, "Plan")) + openFromList("Trip") + openFromList("Plan") + + closeButton("Plan").performClick() + + waitFor { tabs().size == 1 } + waitFor { rule.onAllNodes(hasText("Trip") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + } + + @Test + fun closingABackgroundTabKeepsTheShownNote() { + showHome(WidthClass.Expanded, note(1, "Trip"), note(2, "Plan")) + openFromList("Trip") + openFromList("Plan") + + closeButton("Trip").performClick() + + waitFor { tabs().size == 1 } + assertEquals(1, tabs("Plan").size) + } + + @Test + fun viewOptionsAreABottomSheetOnAPhone() { + showHome(WidthClass.Compact) + rule.onNodeWithContentDescription(str(Res.string.view_options)).performClick() + waitForText(str(Res.string.sort_by)) + assertEquals(0, rule.onAllNodesWithTag(SHEET_DIALOG_TAG).fetchSemanticsNodes().size) + } + + @Test + fun viewOptionsAreADialogInAnExpandedWindow() { + showHome(WidthClass.Expanded) + rule.onNodeWithContentDescription(str(Res.string.view_options)).performClick() + waitForText(str(Res.string.sort_by)) + rule.onNodeWithTag(SHEET_DIALOG_TAG).assertWidthIsEqualTo(480.dp) + } + + @Test + fun aPageIsHeldToItsMaximumWidthInAWideWindowAndFillsAPhone() { + fun show(widthClass: WidthClass) = setScreen { + CompositionLocalProvider(LocalWidthClass provides widthClass) { + CenteredContent { Box(Modifier.fillMaxSize().testTag("page")) } + } + } + show(WidthClass.Expanded) + rule.onNodeWithTag("page").assertWidthIsEqualTo(720.dp) + } + + @Test + fun aPageFillsTheWindowOfAPhone() { + setScreen { + CompositionLocalProvider(LocalWidthClass provides WidthClass.Compact) { + CenteredContent { Box(Modifier.fillMaxSize().testTag("page")) } + } + } + rule.onNodeWithTag("page").assertWidthIsEqualTo(1280.dp) + } + + private fun showPage(widthClass: WidthClass) { + setNavScreen( + viewModels(app.statistics()), + route = NavRoutes.SettingsRoute.name, + start = NavRoutes.HomeRoute.name, + ) { nav -> + CompositionLocalProvider(LocalWidthClass provides widthClass) { + NavigationPage(nav, NavRoutes.SettingsRoute) { Text("the page") } + } + } + navigateTo(NavRoutes.SettingsRoute.name) + } + + @Test + fun aPageHasNoNavigationOnAPhone() { + showPage(WidthClass.Compact) + + waitForText("the page") + assertEquals(0, countText(str(Res.string.main_drawer_settings))) + assertEquals(0, count(str(Res.string.main_drawer_settings))) + } + + @Test + fun aPageHasTheDrawerInAnExpandedWindowAndNavigatesWithoutGrowingTheBackStack() { + showPage(WidthClass.Expanded) + // The drawer is on the page, wired to the app's navigation. + waitForText("the page") + rule.onNode(hasText(str(Res.string.main_drawer_tasks)) and hasClickAction()).performClick() + waitFor { currentRouteName() == NavRoutes.TasksRoute.name } + + rule.runOnIdle { nav.popBackStack() } + + assertEquals(NavRoutes.HomeRoute.name, currentRouteName()) + } + + @Test + fun aPageHasARailInAMediumWindow() { + showPage(WidthClass.Medium) + + waitForText("the page") + assertTrue(count(str(Res.string.main_drawer_settings)) > 0) + rule.onNodeWithText("the page").assertExists() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun aRightClickOnANoteOffersOpenInNewTabAndItAddsABackgroundTab() { + pointerFirst = true + showHome(WidthClass.Expanded, note(1, "Trip"), note(2, "Plan")) + openFromList("Trip") + + listCard("Plan").performMouseInput { rightClick() } + waitForText(str(Res.string.note_open_in_new_tab)) + rule.onNodeWithText(str(Res.string.note_open_in_new_tab)).performClick() + + waitFor { tabs("Plan").isNotEmpty() } + assertEquals(2, tabs().size) + // The note being read stays on screen. + waitFor { rule.onAllNodes(hasText("Trip") and hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun aClickWithTheCommandKeyAddsABackgroundTabInsteadOfOpeningTheNote() { + pointerFirst = true + showHome(WidthClass.Expanded, note(1, "Trip"), note(2, "Plan")) + openFromList("Trip") + + listCard("Plan").performKeyInput { keyDown(Key.CtrlLeft) } + listCard("Plan").performMouseInput { click() } + listCard("Plan").performKeyInput { keyUp(Key.CtrlLeft) } + + waitFor { tabs("Plan").isNotEmpty() } + assertEquals(2, tabs().size) + assertEquals(0, rule.onAllNodes(hasText("Plan") and hasSetTextAction()).fetchSemanticsNodes().size) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun moveToTrashInTheRightClickMenuAsksFirst() { + pointerFirst = true + showHome(WidthClass.Expanded, note(1, "Trip")) + waitFor { listCard("Trip").isDisplayedSafely() } + + listCard("Trip").performMouseInput { rightClick() } + waitForText(str(Res.string.swipe_choice_delete)) + rule.onNodeWithText(str(Res.string.swipe_choice_delete)).performClick() + + waitForText(str(Res.string.swipe_delete_title)) + rule.onNodeWithText(str(Res.string.dialog_dismiss)).performClick() + waitFor { countText(str(Res.string.swipe_delete_title)) == 0 } + assertTrue(app.notes.updatedNotes.none { it.isTrash }) + } + + @Test + fun theNoteShownInThePaneIsMarkedInTheList() { + showHome(WidthClass.Expanded, note(1, "Trip"), note(2, "Plan")) + + openFromList("Trip") + + waitFor { listCard("Trip").fetchSemanticsNode().config.getOrNull(SemanticsProperties.Selected) == true } + assertEquals(false, listCard("Plan").fetchSemanticsNode().config.getOrNull(SemanticsProperties.Selected)) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun withoutAPointerARightClickOpensNoMenuAndTheListHasNoNewTabEntry() { + showHome(WidthClass.Expanded, note(1, "Trip")) + waitFor { listCard("Trip").isDisplayedSafely() } + + listCard("Trip").performMouseInput { rightClick() } + rule.waitForIdle() + + assertEquals(0, countText(str(Res.string.note_open_in_new_tab))) + assertEquals(0, countText(str(Res.string.note_open))) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun aCompactWindowWithAMouseHasTheMenuButNoTabs() { + pointerFirst = true + showHome(WidthClass.Compact, note(1, "Trip")) + waitFor { listCard("Trip").isDisplayedSafely() } + + listCard("Trip").performMouseInput { rightClick() } + waitForText(str(Res.string.note_open)) + + assertEquals(0, countText(str(Res.string.note_open_in_new_tab))) + assertEquals(false, listCard("Trip").fetchSemanticsNode().config.getOrNull(SemanticsProperties.Selected)) + } + + private fun SemanticsNodeInteraction.isDisplayedSafely() = runCatching { assertExists() }.isSuccess +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AppLayoutDirectionTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AppLayoutDirectionTest.kt new file mode 100644 index 00000000..130be3af --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AppLayoutDirectionTest.kt @@ -0,0 +1,44 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.presentation.navigation.appLayoutDirection +import org.junit.Assert.assertEquals +import org.junit.Test +import org.robolectric.annotation.Config + +/** The layout direction follows the app's language on every host, not only where the platform derives it. */ +class AppLayoutDirectionTest : ComposeScreenTest() { + private fun directionWhenHostIs(host: LayoutDirection): LayoutDirection { + var direction: LayoutDirection? = null + setScreen { + CompositionLocalProvider(LocalLayoutDirection provides host) { direction = appLayoutDirection() } + } + return direction!! + } + + @Test + @Config(qualifiers = "ar-w411dp-h891dp-xxhdpi") + fun arabicIsLaidOutRightToLeftEvenWhereTheHostSaysOtherwise() { + assertEquals(LayoutDirection.Rtl, directionWhenHostIs(LayoutDirection.Ltr)) + } + + @Test + @Config(qualifiers = "en-w411dp-h891dp-xxhdpi") + fun englishIsLaidOutLeftToRightEvenWhereTheHostSaysOtherwise() { + assertEquals(LayoutDirection.Ltr, directionWhenHostIs(LayoutDirection.Rtl)) + } + + @Test + @Config(qualifiers = "ja-w411dp-h891dp-xxhdpi") + fun aLanguageTheAppDoesNotHaveKeepsTheHostsRightToLeftDirection() { + assertEquals(LayoutDirection.Rtl, directionWhenHostIs(LayoutDirection.Rtl)) + } + + @Test + @Config(qualifiers = "ja-w411dp-h891dp-xxhdpi") + fun aLanguageTheAppDoesNotHaveKeepsTheHostsLeftToRightDirection() { + assertEquals(LayoutDirection.Ltr, directionWhenHostIs(LayoutDirection.Ltr)) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/BackupScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/BackupScreenTest.kt new file mode 100644 index 00000000..bea356a3 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/BackupScreenTest.kt @@ -0,0 +1,255 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.backup_count_notes +import com.pasich.encly.core.resources.backup_decrypt +import com.pasich.encly.core.resources.backup_enter_phrase_title +import com.pasich.encly.core.resources.backup_error_io +import com.pasich.encly.core.resources.backup_error_wrong_phrase +import com.pasich.encly.core.resources.backup_export +import com.pasich.encly.core.resources.backup_import +import com.pasich.encly.core.resources.backup_import_desc +import com.pasich.encly.core.resources.backup_import_done +import com.pasich.encly.core.resources.backup_import_mode_title +import com.pasich.encly.core.resources.backup_intro_short +import com.pasich.encly.core.resources.backup_intro_title +import com.pasich.encly.core.resources.backup_merge +import com.pasich.encly.core.resources.backup_never_exported +import com.pasich.encly.core.resources.backup_reauth_subtitle +import com.pasich.encly.core.resources.backup_reauth_title +import com.pasich.encly.core.resources.backup_replace_all +import com.pasich.encly.core.resources.backup_replace_confirm_title +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.markdown_export +import com.pasich.encly.core.resources.markdown_export_desc +import com.pasich.encly.core.resources.markdown_export_warning_body +import com.pasich.encly.core.resources.markdown_export_warning_title +import com.pasich.encly.core.resources.recovery_phrase_checksum +import com.pasich.encly.core.security.Mnemonic +import com.pasich.encly.data.model.Note +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.backup.BackupScreen +import com.pasich.encly.presentation.viewmodel.BackupStep +import com.pasich.encly.presentation.viewmodel.BackupViewModel +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito.verifyNoInteractions + +class BackupScreenTest : ComposeScreenTest() { + private val words = String(Mnemonic.generate()) + private val app by lazy { TestApp().withWorkingVault(words) } + private lateinit var backup: BackupViewModel + + private fun show() { + backup = app.backup() + setNavScreen(viewModels(backup, app.markdownExport()), route = NavRoutes.BackupRoute.name) { nav -> + BackupScreen(nav) + } + } + + private fun step() = backup.uiState.value.step + + /** Import, the PIN, then the picked file: the 12-word step. */ + private fun openPhraseStep(vararg notes: Note = arrayOf(Note(title = "Plan", value = "[]", uid = "n-plan"))) { + rule.onNode(hasText(str(Res.string.backup_import)) and hasClickAction()).performClick() + waitForText(str(Res.string.backup_reauth_title)) + enterPin("123456") + waitFor(describe = { "${backup.uiState.value}" }) { step() == BackupStep.Idle && !backup.uiState.value.busy } + val file = runBlocking { app.backupFile(words, *notes) } + rule.runOnIdle { backup.importFlow.onFile(app.document("backup.enclybak", file)) } + waitFor(describe = { "${backup.uiState.value}" }) { step() is BackupStep.EnterImportPhrase } + } + + @Test + fun showsExportAndImportWithTheirExplanations() { + show() + + rule.onNodeWithText(str(Res.string.backup_intro_short)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.backup_export)).assertIsEnabled() + rule.onNodeWithText(str(Res.string.backup_never_exported)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.backup_import_desc)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.backup_intro_title)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 6) + } + + @Test + fun exportAsksForThePinFirst() { + show() + + rule.onNodeWithText(str(Res.string.backup_export)).performClick() + + waitForText(str(Res.string.backup_reauth_title)) + rule.onNodeWithText(str(Res.string.backup_reauth_subtitle)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.cancel)).performClick() + waitFor { step() == BackupStep.Idle } + } + + @Test + fun markdownExportWarnsThatTheFileIsNotEncryptedBeforeAnythingElse() { + show() + rule.onNodeWithText(str(Res.string.markdown_export_desc)).assertIsDisplayed() + + rule.onNode(hasText(str(Res.string.markdown_export)) and hasClickAction()).performClick() + + waitForText(str(Res.string.markdown_export_warning_title)) + rule.onNodeWithText(str(Res.string.markdown_export_warning_body)).assertIsDisplayed() + // No PIN prompt and nothing written yet. + assertEquals(0, countText(str(Res.string.backup_reauth_title))) + verifyNoInteractions(app.markdownDocuments) + rule.onNodeWithText(str(Res.string.cancel)).performClick() + waitFor { countText(str(Res.string.markdown_export_warning_title)) == 0 } + } + + @Test + fun markdownExportAsksForThePinAfterTheWarning() { + show() + + rule.onNode(hasText(str(Res.string.markdown_export)) and hasClickAction()).performClick() + waitForText(str(Res.string.markdown_export_warning_title)) + rule.onNodeWithText(str(Res.string.action_continue)).performClick() + + waitForText(str(Res.string.backup_reauth_title)) + rule.onNodeWithText(str(Res.string.backup_reauth_subtitle)).assertIsDisplayed() + verifyNoInteractions(app.markdownDocuments) + // The encrypted backup flow was not started by it. + assertEquals(BackupStep.Idle, step()) + } + + @Test + fun theImportPhraseStepHasTwelveCellsAndWaitsForAValidPhrase() { + show() + openPhraseStep() + + rule.onNodeWithText(str(Res.string.backup_enter_phrase_title)).assertIsDisplayed() + (1..12).forEach { phraseCell(it).assertExists() } + rule.onNodeWithText(str(Res.string.backup_decrypt)).assertIsNotEnabled() + + phraseCell(1).performTextReplacement(words.split(' ').take(6).joinToString(" ")) + assertEquals(words.split(' ').take(6), phraseCells().take(6)) + rule.onNodeWithText(str(Res.string.backup_decrypt)).assertIsNotEnabled() + + phraseCell(7).performTextReplacement(words.split(' ').drop(6).joinToString(" ")) + assertEquals(words.split(' '), phraseCells()) + rule.onNodeWithText(str(Res.string.backup_decrypt)).assertIsEnabled() + } + + @Test + fun twelveKnownWordsWithABadChecksumAreFlagged() { + show() + openPhraseStep() + + // Twelve real words, but "abandon" twelve times has no valid checksum. + phraseCell(1).performTextReplacement(List(12) { "abandon" }.joinToString(" ")) + + rule.onNodeWithText(str(Res.string.recovery_phrase_checksum)).performScrollTo().assertIsDisplayed() + rule.onNodeWithText(str(Res.string.backup_decrypt)).assertIsNotEnabled() + } + + @Test + fun aDifferentPhraseDoesNotOpenTheBackupAndKeepsTheWords() { + show() + openPhraseStep() + val other = PHRASE + + phraseCell(1).performTextReplacement(other) + rule.onNodeWithText(str(Res.string.backup_decrypt)).performClick() + + waitForText(str(Res.string.backup_error_wrong_phrase), timeoutMillis = LONG_WAIT_MS) + assertEquals(other.split(' '), phraseCells()) + + phraseCell(12).performTextInput("x") + waitFor { countText(str(Res.string.backup_error_wrong_phrase)) == 0 } + } + + @Test + fun theRightPhraseOffersMergeOrReplace() { + show() + openPhraseStep(Note(title = "One", value = "[]", uid = "a"), Note(title = "Two", value = "[]", uid = "b")) + phraseCell(1).performTextReplacement(words) + + rule.onNodeWithText(str(Res.string.backup_decrypt)).performClick() + + waitForText(str(Res.string.backup_import_mode_title), timeoutMillis = LONG_WAIT_MS) + rule.onNodeWithText(plural(Res.plurals.backup_count_notes, 2, 2), substring = true).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.backup_merge)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.backup_replace_all)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.cancel)).assertIsDisplayed() + } + + @Test + fun cancellingReplaceReturnsToTheChoice() { + show() + openPhraseStep() + phraseCell(1).performTextReplacement(words) + rule.onNodeWithText(str(Res.string.backup_decrypt)).performClick() + waitForText(str(Res.string.backup_import_mode_title), timeoutMillis = LONG_WAIT_MS) + + rule.onNodeWithText(str(Res.string.backup_replace_all)).performClick() + waitForText(str(Res.string.backup_replace_confirm_title)) + rule.onNode(hasText(str(Res.string.cancel)) and hasClickAction() and hasAnyAncestor(isDialog())).performClick() + + waitFor { step() is BackupStep.ChooseImportMode } + rule.onNodeWithText(str(Res.string.backup_merge)).assertIsDisplayed() + } + + @Test + fun mergeImportsAndReportsIt() { + show() + openPhraseStep() + phraseCell(1).performTextReplacement(words) + rule.onNodeWithText(str(Res.string.backup_decrypt)).performClick() + waitForText(str(Res.string.backup_import_mode_title), timeoutMillis = LONG_WAIT_MS) + + rule.onNodeWithText(str(Res.string.backup_merge)).performClick() + + waitFor(LONG_WAIT_MS, describe = { "${backup.uiState.value} ${runBlocking { app.store.snapshot() }}" }) { + runBlocking { app.store.snapshot() }.notes.any { it.title == "Plan" } + } + waitForText(str(Res.string.backup_import_done, 1, 0, 0, 0), timeoutMillis = LONG_WAIT_MS) + } + + private companion object { + const val LONG_WAIT_MS = 15_000L + } + + @Test + fun exportInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + show() + + rule.onNodeWithText(str(Res.string.backup_export)).performClick() + waitForText(str(Res.string.backup_reauth_title)) + enterPin("123456") + + waitForText(str(Res.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(Res.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/ColorPickerTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ColorPickerTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/ui/screens/ColorPickerTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ColorPickerTest.kt diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ComposeScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ComposeScreenTest.kt new file mode 100644 index 00000000..282855d2 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ComposeScreenTest.kt @@ -0,0 +1,337 @@ +package com.pasich.encly.ui.screens + +import android.app.Application +import android.content.Context +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasScrollToNodeAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.lock_unlock +import com.pasich.encly.core.resources.recovery_word_cell +import com.pasich.encly.domain.model.ThemeSettings +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.LocalAppInfo +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.PluralStringResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getPluralString +import org.jetbrains.compose.resources.getString +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.reflect.KClass + +/** + * Base for the Compose screen tests. They run on the JVM under Robolectric, on a phone-sized + * screen, with a plain Application (no dependency graph, no keystore). Every `metroViewModel()` in the + * composition resolves to a ViewModel the test built from fakes (see [FakeViewModels]). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +abstract class ComposeScreenTest { + @get:Rule + val rule = createComposeRule() + + protected val context: Context get() = ApplicationProvider.getApplicationContext() + + private val owners = mutableListOf() + + /** What the host says about this build (the flavor's flags); a test changes it before it sets its screen. */ + protected var appInfo: AppInfo = AppInfo.Default + + @After + fun clearViewModels() { + owners.forEach { it.viewModelStore.clear() } + } + + /** The string in the default locale, which is the language of the test (see LocalizedScreensTest). */ + protected fun str(id: StringResource, vararg args: Any): String = runBlocking { getString(id, *args) } + + protected fun plural(id: PluralStringResource, count: Int, vararg args: Any): String = + runBlocking { getPluralString(id, count, *args) } + + protected fun viewModels(vararg viewModels: ViewModel): FakeViewModels = + FakeViewModels(*viewModels).also(owners::add) + + /** [content] in the app theme, with [owner] answering every `metroViewModel()`. */ + protected fun setScreen( + owner: FakeViewModels = viewModels(), + settings: ThemeSettings = ThemeSettings(), + dark: Boolean = false, + content: @Composable () -> Unit, + ) { + rule.setContent { + CompositionLocalProvider(LocalAppInfo provides appInfo) { + EnclyTheme(settings = settings, dark = dark) { + ProvideViewModels(owner, content) + } + } + } + rule.waitForIdle() + } + + protected lateinit var nav: NavHostController + + /** + * [content] as the destination [route] of a graph that has every app route, the others as + * stubs, so the screen can navigate and a test can see where it went ([currentRoute]). + * [start] is where the back stack begins; tests reach [route] with [navigateTo] when it differs. + */ + protected fun setNavScreen( + owner: FakeViewModels = viewModels(), + route: String, + start: String = route, + settings: ThemeSettings = ThemeSettings(), + dark: Boolean = false, + liveTheme: StateFlow? = null, + content: @Composable (NavHostController) -> Unit, + ) { + rule.setContent { + val controller = rememberNavController() + nav = controller + // With [liveTheme] the app theme follows the stored settings, as AppTheme does. + val theme = liveTheme?.collectAsState()?.value ?: settings + CompositionLocalProvider(LocalAppInfo provides appInfo) { + EnclyTheme(settings = theme, dark = if (liveTheme != null) theme.isDark(false) else dark) { + NavHost(navController = controller, startDestination = start) { + TestRoutes.patterns.filter { it.pattern != route }.forEach { stub -> + composable(stub.pattern, arguments = stub.arguments) { + Text("stub:${stub.name}") + } + } + composable(route, arguments = TestRoutes.argumentsFor(route)) { + ProvideViewModels(owner) { content(controller) } + } + } + } + } + } + rule.waitForIdle() + } + + protected fun navigateTo(route: String) { + rule.runOnIdle { nav.navigate(route) } + rule.waitForIdle() + } + + /** The route pattern of the destination on top, e.g. `HomeRoute`. */ + protected fun currentRoute(): String? = rule.runOnIdle { nav.currentBackStackEntry?.destination?.route } + + /** The route's name without its arguments, e.g. `TasksRoute` for `TasksRoute?add={add}`. */ + protected fun currentRouteName(): String? = currentRoute()?.substringBefore('?')?.substringBefore('/') + + /** + * The screen shows real content, not a blank body: at least [minTexts] visible texts + * besides the top bar title, inside the window. + */ + protected fun assertBodyNotEmpty(minTexts: Int = 2) { + val root = rule.onRoot().fetchSemanticsNode() + val window = root.boundsInRoot + val texts = visibleTexts().filter { node -> + val bounds = node.boundsInRoot + bounds.height > 0f && bounds.width > 0f && bounds.overlaps(window) + } + assertTrue("expected at least $minTexts visible texts, found ${texts.map(::textOf)}", texts.size >= minTexts) + } + + protected fun visibleTexts(): List = rule.onAllNodes(hasAnyText, useUnmergedTree = true) + .fetchSemanticsNodes() + + protected fun textOf(node: SemanticsNode): String = node.config.getOrNull(SemanticsProperties.Text) + ?.joinToString { it.text } + ?: node.config.getOrNull(SemanticsProperties.EditableText)?.text + ?: "" + + /** + * Waits for [condition], letting both real threads (the ViewModels' IO and Default work) and + * the Compose clock (animations and `delay` in effects) move on. + */ + protected fun waitFor(timeoutMillis: Long = WAIT_MS, describe: () -> String = { "" }, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMillis + while (!condition()) { + if (System.currentTimeMillis() > + deadline + ) { + throw AssertionError("condition not met in $timeoutMillis ms ${describe()}") + } + rule.mainClock.advanceTimeBy(FRAME_MS) + rule.waitForIdle() + Thread.sleep(POLL_MS) + } + rule.waitForIdle() + } + + protected fun waitForText( + text: String, + substring: Boolean = false, + ignoreCase: Boolean = false, + timeoutMillis: Long = WAIT_MS, + ) { + waitFor(timeoutMillis, describe = { "for \"$text\"; shown: ${visibleTexts().map(::textOf)}" }) { + countText(text, substring, ignoreCase) > 0 + } + } + + protected fun countText(text: String, substring: Boolean = false, ignoreCase: Boolean = false): Int = + rule.onAllNodes(hasText(text, substring = substring, ignoreCase = ignoreCase), useUnmergedTree = true) + .fetchSemanticsNodes().size + + /** Taps [pin] on the on-screen keypad. */ + protected fun typePin(pin: String) { + pin.forEach { digit -> + rule.onNode(hasText(digit.toString()) and hasClickAction()).performClick() + rule.waitForIdle() + } + } + + /** + * [typePin] and then the screen's submit button: nothing is sent by itself, a PIN has no fixed length. The lock + * screen's button says Unlock, every other PIN screen's says Continue. + */ + protected fun enterPin(pin: String) { + typePin(pin) + val unlock = str(Res.string.lock_unlock) + val label = if (rule.onAllNodes(hasText(unlock)).fetchSemanticsNodes().isNotEmpty()) { + unlock + } else { + str(Res.string.action_continue) + } + rule.onNode(hasText(label) and hasClickAction()).performClick() + rule.waitForIdle() + } + + /** + * [content] under the app's keyboard host, with a command router that only runs the screens' own commands (a + * lock is counted in [locks]); what `EnclyApp` gives the screens, without the navigation around them. + */ + @Composable + protected fun WithShortcuts(content: @Composable () -> Unit) { + val scope = androidx.compose.runtime.rememberCoroutineScope() + val router = androidx.compose.runtime.remember { + com.pasich.encly.presentation.navigation.AppCommandRouter(scope, lockApp = { locks++ }) { false } + } + CompositionLocalProvider(com.pasich.encly.presentation.navigation.LocalAppCommands provides router) { + com.pasich.encly.presentation.designsystem.EnclyShortcutHost( + onCommand = router::run, + shortcuts = com.pasich.encly.presentation.designsystem.EnclyShortcuts(commandKeyIsMeta = false), + content = content, + ) + } + } + + /** How many times the shortcut host asked to lock the app. */ + protected var locks = 0 + + /** The recovery phrase cell [number] (1-based) of 12. */ + protected fun phraseCell(number: Int) = rule.onNodeWithContentDescription( + str(Res.string.recovery_word_cell, number, PHRASE_WORDS), + ) + + /** What the 12 cells hold, in order. */ + protected fun phraseCells(): List = (1..PHRASE_WORDS).map { number -> + phraseCell(number).fetchSemanticsNode().config.getOrNull(SemanticsProperties.EditableText)?.text.orEmpty() + } + + /** + * Brings [text] into view: in a scrolling column it is already composed; in a lazy list the + * first scrollable list is scrolled until it is. + */ + protected fun scrollToText(text: String, substring: Boolean = false, ignoreCase: Boolean = false) { + val matcher = hasText(text, substring = substring, ignoreCase = ignoreCase) + if (rule.onAllNodes(matcher).fetchSemanticsNodes().isEmpty()) { + rule.onAllNodes(hasScrollToNodeAction())[0].performScrollToNode(matcher) + } else { + rule.onAllNodes(matcher)[0].performScrollTo() + } + rule.waitForIdle() + } + + protected companion object { + const val WAIT_MS = 5_000L + const val PHRASE_WORDS = 12 + const val FRAME_MS = 16L + const val POLL_MS = 5L + + /** A valid BIP39 phrase (its checksum holds). */ + val PHRASE = (List(11) { "abandon" } + "about").joinToString(" ") + + val hasAnyText = SemanticsMatcher("has text") { + it.config.getOrNull(SemanticsProperties.Text).orEmpty().any { text -> text.isNotBlank() } || + !it.config.getOrNull(SemanticsProperties.EditableText)?.text.isNullOrBlank() + } + } +} + +/** + * A [ViewModelStoreOwner] holding ready-made ViewModels. It deliberately has no default factory: + * `metroViewModel()` then asks the store directly and gets the instance put here. + */ +class FakeViewModels(vararg viewModels: ViewModel) : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + + init { + viewModels.forEach { add(it) } + } + + fun add(viewModel: ViewModel) { + val factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: KClass, extras: CreationExtras): T = viewModel as T + } + ViewModelProvider.create(viewModelStore, factory)[viewModel::class] + } +} + +/** The factory the screens ask when the store has no instance yet; the tests always preload it. */ +private val NoViewModelFactory = object : MetroViewModelFactory() {} + +@Composable +fun ProvideViewModels(owner: FakeViewModels, content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides owner, + LocalMetroViewModelFactory provides NoViewModelFactory, + content = content, + ) +} + +/** The facts of one flavor of the app. */ +internal class StoreBuild(rating: Boolean, donations: Boolean) : AppInfo { + override val appName = "Encly" + override val versionName = "9.8.7" + override val storeRatingEnabled = rating + override val donationsEnabled = donations + override val storePageLinks = emptyList() +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt new file mode 100644 index 00000000..d48a9a67 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt @@ -0,0 +1,633 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.isFocused +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.text.TextRange +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.back +import com.pasich.encly.core.resources.backup_reauth_title +import com.pasich.encly.core.resources.block_bulleted_list +import com.pasich.encly.core.resources.block_checklist +import com.pasich.encly.core.resources.block_heading +import com.pasich.encly.core.resources.block_heading_level +import com.pasich.encly.core.resources.block_link +import com.pasich.encly.core.resources.block_more_tools +import com.pasich.encly.core.resources.block_move_down +import com.pasich.encly.core.resources.block_move_up +import com.pasich.encly.core.resources.block_numbered_list +import com.pasich.encly.core.resources.block_separator +import com.pasich.encly.core.resources.checklist_lock +import com.pasich.encly.core.resources.checklist_unlock +import com.pasich.encly.core.resources.close +import com.pasich.encly.core.resources.create_new_tag +import com.pasich.encly.core.resources.delete +import com.pasich.encly.core.resources.delete_from_trash +import com.pasich.encly.core.resources.dialog_title +import com.pasich.encly.core.resources.duplicate +import com.pasich.encly.core.resources.edit_tags +import com.pasich.encly.core.resources.editor_lock +import com.pasich.encly.core.resources.markdown_export +import com.pasich.encly.core.resources.markdown_export_warning_title +import com.pasich.encly.core.resources.more_options +import com.pasich.encly.core.resources.note_archive +import com.pasich.encly.core.resources.note_color_teal +import com.pasich.encly.core.resources.note_color_title +import com.pasich.encly.core.resources.note_discard_all_changes +import com.pasich.encly.core.resources.note_discard_confirm_message +import com.pasich.encly.core.resources.note_discard_confirm_title +import com.pasich.encly.core.resources.note_load_failed +import com.pasich.encly.core.resources.note_pin +import com.pasich.encly.core.resources.note_text_use_default +import com.pasich.encly.core.resources.note_title_placeholder +import com.pasich.encly.core.resources.note_unarchive +import com.pasich.encly.core.resources.quote +import com.pasich.encly.core.resources.redo +import com.pasich.encly.core.resources.restore +import com.pasich.encly.core.resources.tags_manage +import com.pasich.encly.core.resources.text +import com.pasich.encly.core.resources.undo +import com.pasich.encly.data.model.Note +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.presentation.editor.persistence.SaveStatusNote +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.editnote.EditNoteScreen +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import org.jetbrains.compose.resources.StringResource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.verifyNoInteractions + +class EditNoteScreenTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + private lateinit var editor: EditNoteViewModel + + /** The notes list with the editor opened on top of it, as from a tap on a card. */ + private fun open(noteId: Long = -1L, readTrashOnly: Boolean = false) { + editor = app.editor(noteId = noteId, readTrashOnly = readTrashOnly) + setNavScreen( + viewModels(editor, app.tagList(), app.markdownExport()), + route = TestRoutes.EDIT_NOTE, + start = NavRoutes.HomeRoute.name, + ) { nav -> EditNoteScreen(nav) } + navigateTo("${NavRoutes.EditNoteRoute.name}/$noteId?isReadTrashOnly=$readTrashOnly") + waitFor { editor.status.value != SaveStatusNote.LOADING } + } + + private fun stored(vararg blocks: Block, title: String = "Trip", id: Long = NOTE_ID) = Note( + id = id, + title = title, + value = BlockConverter.blocksToJson(blocks.toList()), + date = 1_700_000_000_000, + ).also { app.withNotes(it) } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun field(index: Int) = rule.onAllNodes(hasSetTextAction())[index] + + private fun tool(id: StringResource) = + rule.onNode(hasContentDescription(str(id)) and hasClickAction() and !hasTestTag(LOCK_TOGGLE_TAG)) + + private fun hasTool(label: String) = + rule.onAllNodes(hasContentDescription(label) and hasClickAction() and !hasTestTag(LOCK_TOGGLE_TAG)) + .fetchSemanticsNodes().isNotEmpty() + + private fun moreToolsToggles() = + rule.onAllNodes(hasContentDescription(str(Res.string.block_more_tools))).fetchSemanticsNodes() + + private fun back() = rule.onNodeWithContentDescription(str(Res.string.back)).performClick() + + @Test + fun aNewNoteShowsTheTitleAParagraphAndTheToolbar() { + open() + + rule.onNodeWithText(str(Res.string.note_title_placeholder), useUnmergedTree = true).assertExists() + listOf( + Res.string.block_more_tools, + Res.string.block_heading, + Res.string.block_checklist, + Res.string.block_bulleted_list, + Res.string.block_numbered_list, + Res.string.quote, + Res.string.undo, + Res.string.redo, + ).forEach { tool(it).assertExists() } + assertTrue(rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size >= 2) + rule.onNodeWithContentDescription(str(Res.string.edit_tags)).assertExists() + rule.onNodeWithContentDescription(str(Res.string.editor_lock)).assertExists() + } + + @Test + fun typingAndBackSavesTheNewNote() { + open() + + field(0).performTextInput("Shopping") + field(1).performTextInput("eggs and flour") + back() + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + val saved = app.notes.insertedNotes.last() + assertEquals("Shopping", saved.title) + assertTrue(saved.value, saved.value.contains("eggs and flour")) + } + + @Test + fun aBlankNewNoteIsNotSaved() { + open() + + back() + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + assertEquals(0, app.notes.insertCalls) + } + + @Test + fun twoQuickBacksLeaveOnlyTheEditor() { + stored(text("body")) + open(NOTE_ID) + + // Both taps land before the first save finishes, as a fast double tap does. + val tap = rule.onNodeWithContentDescription(str(Res.string.back)).fetchSemanticsNode() + .config[SemanticsActions.OnClick].action + rule.runOnUiThread { + tap?.invoke() + tap?.invoke() + } + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + rule.mainClock.advanceTimeBy(LEAVE_MS) + rule.waitForIdle() + assertEquals(NavRoutes.HomeRoute.name, currentRoute()) + } + + @Test + fun anExistingNoteShowsItsContentAndWhenItWasEdited() { + stored(text("first line"), text("second line"), title = "Trip plan") + open(NOTE_ID) + + waitForText("Trip plan") + rule.onNodeWithText("first line").assertExists() + rule.onNodeWithText("second line").assertExists() + assertTrue(visibleTexts().map(::textOf).any { it.contains("2023") }) + assertBodyNotEmpty(minTexts = 3) + } + + @Test + fun theChecklistToolTurnsTheParagraphIntoAChecklist() { + stored(text("milk")) + open(NOTE_ID) + field(1).performClick() + + tool(Res.string.block_checklist).performClick() + + waitFor { editor.blocks.first() is Block.ListBlock } + assertEquals(BlockType.LIST_CHECK, (editor.blocks.first() as Block.ListBlock).blockType) + rule.onNodeWithText("milk").assertExists() + rule.onNode(isCheckbox and hasContentDescription("milk")).assertExists() + } + + @Test + fun aChecklistItemCanBeTicked() { + stored( + Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("milk"))), blockType = BlockType.LIST_CHECK), + ) + open(NOTE_ID) + + rule.onNode(isCheckbox and hasContentDescription("milk")).performClick() + + waitFor { + (editor.blocks.first() as Block.ListBlock).items.value.single().isCheck + } + assertEquals( + ToggleableState.On, + rule.onNode( + isCheckbox and hasContentDescription("milk"), + ).fetchSemanticsNode().config[SemanticsProperties.ToggleableState], + ) + } + + @Test + fun aLockedChecklistStillTicksButItsTextTakesNoFocus() { + stored( + Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("milk"))), blockType = BlockType.LIST_CHECK), + ) + open(NOTE_ID) + field(1).performClick() + tool(Res.string.block_more_tools).performClick() + waitFor { hasTool(str(Res.string.checklist_lock)) } + + // The submenu scrolls sideways; the lock is past the edge of a phone-wide row. + tool(Res.string.checklist_lock).performScrollTo().performClick() + + val list = editor.blocks.first() as Block.ListBlock + waitFor { list.locked.value } + waitFor { hasTool(str(Res.string.checklist_unlock)) } + rule.onNode(isCheckbox and hasContentDescription("milk")).performClick() + waitFor { list.items.value.single().isCheck } + assertEquals( + 0, + rule.onAllNodes(hasSetTextAction() and hasText("milk") and isEnabled()).fetchSemanticsNodes().size, + ) + } + + @Test + fun aLockedChecklistCanBeUnlockedAfterWorkingElsewhere() { + stored( + Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("milk"))), blockType = BlockType.LIST_CHECK), + text("after"), + ) + open(NOTE_ID) + field(1).performClick() + tool(Res.string.block_more_tools).performClick() + waitFor { hasTool(str(Res.string.checklist_lock)) } + tool(Res.string.checklist_lock).performScrollTo().performClick() + val list = editor.blocks.first() as Block.ListBlock + waitFor { list.locked.value } + rule.onNode(hasText("after") and hasSetTextAction()).performClick() + waitFor { !hasTool(str(Res.string.checklist_unlock)) } + + // A tap on the locked list makes it the block the toolbar acts on again. + rule.onNode(isCheckbox and hasContentDescription("milk")).performClick() + + waitFor { hasTool(str(Res.string.checklist_unlock)) } + tool(Res.string.checklist_unlock).performScrollTo().performClick() + waitFor { !list.locked.value } + } + + @Test + fun theHeadingToolTurnsTheParagraphIntoAHeading() { + stored(text("Plan")) + open(NOTE_ID) + field(1).performClick() + + tool(Res.string.block_heading).performClick() + + waitFor { editor.blocks.first() is Block.HBlock } + rule.onNodeWithText("Plan").assertExists() + } + + @Test + fun addBlockOpensTheSecondRowAndCloseReturns() { + open() + + tool(Res.string.block_more_tools).performClick() + + val second = (1..4).map { str(Res.string.block_heading_level, it) } + + listOf( + Res.string.text, + Res.string.block_link, + Res.string.block_separator, + Res.string.block_move_up, + Res.string.block_move_down, + ).map(::str) + second.forEach { label -> waitFor { hasTool(label) } } + tool(Res.string.close).performClick() + waitFor { hasTool(str(Res.string.block_checklist)) } + } + + @Test + fun aHeadingLevelFromTheSecondRowIsApplied() { + stored(text("Plan")) + open(NOTE_ID) + field(1).performClick() + tool(Res.string.block_more_tools).performClick() + waitFor { hasTool(str(Res.string.block_heading_level, 3)) } + + rule.onNode(hasContentDescription(str(Res.string.block_heading_level, 3)) and hasClickAction()).performClick() + + waitFor { (editor.blocks.first() as? Block.HBlock)?.blockType == BlockType.H3 } + } + + @Test + fun theSeparatorToolAddsASeparator() { + stored(text("above")) + open(NOTE_ID) + field(1).performClick() + tool(Res.string.block_more_tools).performClick() + waitFor { hasTool(str(Res.string.block_separator)) } + + tool(Res.string.block_separator).performClick() + + waitFor { editor.blocks.any { it is Block.SeparatorBlock } } + } + + @Test + fun lockingHidesTheToolbarAndKeepsTheText() { + stored(text("secret plan")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.editor_lock)).performClick() + + waitFor { moreToolsToggles().isEmpty() } + rule.onNodeWithText("secret plan").assertExists() + assertTrue(editor.lockEditor.value) + } + + @Test + fun aNoteInTheTrashIsReadOnlyWithRestoreAndDelete() { + stored(text("old")) + open(NOTE_ID, readTrashOnly = true) + + rule.onNodeWithText(str(Res.string.restore)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.delete_from_trash)).assertIsDisplayed() + assertEquals(0, moreToolsToggles().size) + rule.onNodeWithText("old").assertExists() + } + + @Test + fun deletingFromTheTrashAsksFirst() { + stored(text("old")) + open(NOTE_ID, readTrashOnly = true) + + rule.onNodeWithText(str(Res.string.delete_from_trash)).performClick() + + waitForText(str(Res.string.dialog_title)) + } + + @Test + fun theMoreMenuOffersDuplicateDiscardAndDelete() { + stored(text("body")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + + waitForText(str(Res.string.duplicate)) + rule.onNodeWithText(str(Res.string.note_discard_all_changes)).assertExists() + rule.onNodeWithText(str(Res.string.delete)).assertExists() + } + + @Test + fun theMoreMenuOffersArchiveColourAndMarkdownExportButNoPin() { + stored(text("body")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + + waitForText(str(Res.string.note_archive)) + rule.onNodeWithText(str(Res.string.markdown_export)).assertExists() + // Pinning orders the notes list, so it is offered there, not inside a note. + assertEquals(0, countText(str(Res.string.note_pin))) + // Section headings are drawn in capitals. + rule.onNodeWithText(str(Res.string.note_color_title).uppercase()).assertExists() + rule.onNodeWithContentDescription(str(Res.string.note_color_teal)).assertExists() + } + + @Test + fun useDefaultTextAppearsOnlyWhileTheNoteHasItsOwn() { + stored(text("body")) + open(NOTE_ID) + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + waitForText(str(Res.string.note_archive)) + assertEquals(0, countText(str(Res.string.note_text_use_default))) + + rule.runOnIdle { editor.updateNoteLineSpacing(com.pasich.encly.domain.model.NoteLineSpacing.COMFORTABLE) } + + waitForText(str(Res.string.note_text_use_default)) + rule.onNodeWithText(str(Res.string.note_text_use_default)).performScrollTo().performClick() + waitFor { countText(str(Res.string.note_text_use_default)) == 0 } + } + + @Test + fun archivingFromTheMenuStoresTheNoteArchivedAndLeaves() { + stored(text("body")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + waitForText(str(Res.string.note_archive)) + rule.onNodeWithText(str(Res.string.note_archive)).performScrollTo().performClick() + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + assertEquals(listOf("archived:$NOTE_ID=true"), app.notes.columnWrites) + } + + @Test + fun anArchivedNoteOffersUnarchive() { + Note(id = NOTE_ID, title = "Trip", value = "[]", date = 1L, isArchived = true).also { app.withNotes(it) } + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + + waitForText(str(Res.string.note_unarchive)) + rule.onNodeWithText(str(Res.string.note_unarchive)).performScrollTo().performClick() + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + assertEquals(listOf("archived:$NOTE_ID=false"), app.notes.columnWrites) + } + + @Test + fun colouringIsSavedWithTheNote() { + stored(text("body")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + waitForText(str(Res.string.note_archive)) + rule.onNodeWithContentDescription(str(Res.string.note_color_teal)).performScrollTo().performClick() + waitFor { editor.state.value.note.color == "teal" } + // Leaving the editor saves the note, with its colour. + rule.onNodeWithContentDescription(str(Res.string.back)).performClick() + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + + assertEquals("teal", app.notes.updatedNotes.last().color) + } + + @Test + fun exportingAsMarkdownSavesTheNoteThenWarnsAndTouchesNoFile() { + stored(text("body")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + waitForText(str(Res.string.markdown_export)) + rule.onNodeWithText(str(Res.string.markdown_export)).performScrollTo().performClick() + + waitForText(str(Res.string.markdown_export_warning_title)) + assertEquals(0, countText(str(Res.string.backup_reauth_title))) + verifyNoInteractions(app.markdownDocuments) + // The editor stays open behind the warning. + assertEquals(NavRoutes.EditNoteRoute.name, currentRoute()?.substringBefore('/')) + } + + private fun anyTextFieldFocused() = + rule.onAllNodes(hasSetTextAction() and isFocused()).fetchSemanticsNodes().isNotEmpty() + + /** Waits until block [type] is the note's first and its field really has the focus (not just been asked to). */ + private fun settleOn(isList: Boolean) { + waitFor { (editor.blocks.first() is Block.ListBlock) == isList } + rule.mainClock.advanceTimeBy(SETTLE_MS) + waitFor { editor.focusedBlockId.value == editor.blocks.first().id } + } + + /** Runs [action] a frame at a time and says whether a text field (so the keyboard) had focus in every frame. */ + private fun focusNeverLapsesDuring(action: () -> Unit): Boolean { + rule.mainClock.autoAdvance = false + action() + var always = true + repeat(FRAMES_TO_WATCH) { + rule.mainClock.advanceTimeByFrame() + always = always && anyTextFieldFocused() + } + rule.mainClock.autoAdvance = true + return always + } + + @Test + fun convertingTheFocusedParagraphToAListNeverLeavesNothingFocused() { + stored(text("milk")) + open(NOTE_ID) + field(1).performClick() + waitFor { anyTextFieldFocused() } + + val kept = focusNeverLapsesDuring { tool(Res.string.block_checklist).performClick() } + + assertTrue("a frame had no focused text field", kept) + // The list's own field has the focus in the end, the anchor has let go. + settleOn(isList = true) + } + + @Test + @OptIn(ExperimentalTestApi::class) + fun deletingTheLastListItemWithBackspaceNeverLeavesNothingFocused() { + stored( + Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("one"), ItemListBlock("two"))), + blockType = BlockType.LIST_BULLET, + ), + ) + open(NOTE_ID) + rule.onNode(hasSetTextAction() and hasText("two")).performClick() + rule.onNode(hasSetTextAction() and hasText("two")).performTextInputSelection(TextRange(0)) + waitFor { editor.focusedBlockId.value == editor.blocks.first().id } + + val kept = focusNeverLapsesDuring { + rule.onNode(hasSetTextAction() and hasText("two")).performKeyInput { pressKey(Key.Backspace) } + } + + assertTrue("a frame had no focused text field", kept) + waitFor { (editor.blocks.first() as Block.ListBlock).items.value.size == 1 } + } + + @Test + fun undoingTheConversionAndRedoingItNeverLeavesNothingFocusedEither() { + stored(text("milk")) + open(NOTE_ID) + field(1).performClick() + tool(Res.string.block_checklist).performClick() + settleOn(isList = true) + + assertTrue(focusNeverLapsesDuring { tool(Res.string.undo).performClick() }) + settleOn(isList = false) + assertTrue(focusNeverLapsesDuring { tool(Res.string.redo).performClick() }) + settleOn(isList = true) + } + + @Test + @OptIn(ExperimentalTestApi::class) + fun controlZInTheTitleLeavesTheEditorHistoryAlone() { + stored(text("body")) + open(NOTE_ID) + field(1).performTextInput(" changed") + field(0).performClick() + + field(0).performKeyInput { + keyDown(Key.CtrlLeft) + pressKey(Key.Z) + keyUp(Key.CtrlLeft) + } + + assertEquals("body changed", (editor.blocks.first() as Block.TextBlock).text.value) + } + + @Test + fun discardingEditsAsksFirst() { + stored(text("body")) + open(NOTE_ID) + field(1).performTextInput(" changed") + + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + waitForText(str(Res.string.note_discard_all_changes)) + rule.onNodeWithText(str(Res.string.note_discard_all_changes)).performClick() + + waitForText(str(Res.string.note_discard_confirm_title)) + rule.onNodeWithText(str(Res.string.note_discard_confirm_message)).assertIsDisplayed() + } + + @Test + fun discardingWithoutEditsJustLeaves() { + stored(text("body")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.more_options)).performClick() + waitForText(str(Res.string.note_discard_all_changes)) + rule.onNodeWithText(str(Res.string.note_discard_all_changes)).performClick() + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + assertEquals(0, countText(str(Res.string.note_discard_confirm_title))) + } + + @Test + fun anUnreadableNoteSaysSoAndCannotBeUnlocked() { + Note(id = NOTE_ID, title = "Broken", value = "{not json", date = 1L).also { app.withNotes(it) } + open(NOTE_ID) + + waitForText(str(Res.string.note_load_failed)) + assertEquals(0, rule.onAllNodes(hasContentDescription(str(Res.string.editor_lock))).fetchSemanticsNodes().size) + } + + @Test + fun theTagButtonOffersToCreateATagWhenThereAreNone() { + stored(text("body")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.edit_tags)).performClick() + + waitFor { countText(str(Res.string.create_new_tag)) + countText(str(Res.string.tags_manage)) > 0 } + } + + @Test + fun choosingATagTagsTheNote() { + app.withTags(com.pasich.encly.data.model.Tag(id = 4, nameTag = "Travel")) + stored(text("body")) + open(NOTE_ID) + + rule.onNodeWithContentDescription(str(Res.string.edit_tags)).performClick() + waitForText("Travel") + rule.onNode(hasText("Travel") and hasClickAction()).performClick() + + waitFor { 4L in editor.state.value.note.tagIds } + waitForText("TRAVEL", substring = true) + } + + private companion object { + /** The lock toggle beside a checklist (the editor's own); the toolbar's lock is the tool. */ + const val LOCK_TOGGLE_TAG = "checklist-lock-toggle" + + const val NOTE_ID = 91L + const val LEAVE_MS = 1_000L + const val FRAMES_TO_WATCH = 40 + const val SETTLE_MS = 1_000L + val isCheckbox = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorPlaceholdersTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorPlaceholdersTest.kt new file mode 100644 index 00000000..48163071 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorPlaceholdersTest.kt @@ -0,0 +1,79 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.performClick +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.block_heading_level +import com.pasich.encly.core.resources.list_item_placeholder +import com.pasich.encly.core.resources.quote +import com.pasich.encly.data.model.Note +import com.pasich.encly.presentation.editor.persistence.SaveStatusNote +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.editnote.EditNoteScreen +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import org.junit.Assert.assertEquals +import org.junit.Test + +class EditorPlaceholdersTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + private lateinit var editor: EditNoteViewModel + + private fun open(json: String) { + app.withNotes(Note(id = NOTE_ID, title = "Trip", value = json, date = 1L)) + editor = app.editor(noteId = NOTE_ID) + setNavScreen( + viewModels(editor, app.tagList(), app.markdownExport()), + route = TestRoutes.EDIT_NOTE, + start = NavRoutes.HomeRoute.name, + ) { nav -> EditNoteScreen(nav) } + navigateTo("${NavRoutes.EditNoteRoute.name}/$NOTE_ID?isReadTrashOnly=false") + waitFor { editor.status.value != SaveStatusNote.LOADING } + } + + private fun field(index: Int) = rule.onAllNodes(hasSetTextAction())[index] + + private fun hints() = listOf( + Res.string.list_item_placeholder, + Res.string.quote, + Res.string.block_heading_level, + ).map { id -> if (id == Res.string.block_heading_level) str(id, 1) else str(id) } + + @Test + fun emptyBlocksShowNoHintUntilTheyAreFocused() { + open(EMPTY_BLOCKS) + + hints().forEach { assertEquals(it, 0, countText(it)) } + } + + @Test + fun theFocusedEmptyBlockShowsItsHintAndOthersStayQuiet() { + open(EMPTY_BLOCKS) + + field(2).performClick() + + waitForText(str(Res.string.list_item_placeholder)) + assertEquals(0, countText(str(Res.string.quote))) + assertEquals(0, countText(str(Res.string.block_heading_level, 1))) + } + + @Test + fun anEmptyQuoteAndHeadingShowTheirHintWhenFocused() { + open(EMPTY_BLOCKS) + + field(3).performClick() + waitForText(str(Res.string.quote)) + assertEquals(0, countText(str(Res.string.list_item_placeholder))) + + field(4).performClick() + waitForText(str(Res.string.block_heading_level, 1)) + waitFor { countText(str(Res.string.quote)) == 0 } + } + + private companion object { + const val NOTE_ID = 7L + const val EMPTY_BLOCKS = """[{"blockType":"TEXT","text":"a"}, + {"blockType":"LIST_BULLET","items":[{"value":"","isCheck":false}]}, + {"blockType":"QUOTE","text":""}, + {"blockType":"H1","text":""}]""" + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorToolbarScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorToolbarScreenTest.kt new file mode 100644 index 00000000..fef33dc5 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorToolbarScreenTest.kt @@ -0,0 +1,135 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.settings_editor_toolbar +import com.pasich.encly.core.resources.toolbar_move_down +import com.pasich.encly.core.resources.toolbar_move_up +import com.pasich.encly.core.resources.toolbar_reset +import com.pasich.encly.core.resources.toolbar_section_hidden +import com.pasich.encly.core.resources.toolbar_section_main +import com.pasich.encly.core.resources.toolbar_section_more +import com.pasich.encly.core.resources.toolbar_tool_hide +import com.pasich.encly.core.resources.toolbar_tool_show +import com.pasich.encly.domain.model.ToolbarLayout +import com.pasich.encly.domain.model.ToolbarTool +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.settings.AppearanceScreen +import com.pasich.encly.presentation.screen.settings.EditorToolbarScreen +import com.pasich.encly.presentation.viewmodel.EditorToolbarViewModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditorToolbarScreenTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private fun show() { + setNavScreen(viewModels(EditorToolbarViewModel(app.editorToolbar)), route = NavRoutes.EditorToolbarRoute.name) { + EditorToolbarScreen(it) + } + } + + private val layout get() = app.editorToolbar.layout.value + + private fun hasMoveActions() = SemanticsMatcher.keyIsDefined(SemanticsActions.CustomActions) + + private fun customAction(row: Int, label: String) { + val node = rule.onAllNodes(hasMoveActions())[row].fetchSemanticsNode() + val action = node.config[SemanticsActions.CustomActions].first { it.label == label } + rule.runOnUiThread { action.action() } + rule.waitForIdle() + } + + @Test + fun itListsTheThreeSectionsAndEveryTool() { + show() + + listOf(Res.string.toolbar_section_main, Res.string.toolbar_section_more, Res.string.toolbar_section_hidden) + .forEach { rule.onNodeWithText(str(it)).assertExists() } + assertEquals(ToolbarTool.entries.size, rule.onAllNodes(hasMoveActions()).fetchSemanticsNodes().size) + } + + @Test + fun theEyeHidesAToolAndBringsItBack() { + show() + + rule.onAllNodesWithContentDescription(str(Res.string.toolbar_tool_hide))[0].performClick() + rule.waitForIdle() + assertEquals(listOf(ToolbarTool.HEADING), layout.hidden) + + rule.onAllNodes(hasContentDescription(str(Res.string.toolbar_tool_show)))[0].performScrollTo().performClick() + rule.waitForIdle() + // Back in its section, at the end of it. + assertEquals(ToolbarTool.HEADING, layout.main.last()) + assertTrue(layout.hidden.isEmpty()) + } + + @Test + fun resetRestoresTheShippedToolbarAndIsOfferedOnlyAfterAChange() { + show() + rule.onNodeWithText(str(Res.string.toolbar_reset)).performClick() + assertEquals(ToolbarLayout.DEFAULT, layout) + + rule.onAllNodesWithContentDescription(str(Res.string.toolbar_tool_hide))[0].performClick() + rule.waitForIdle() + rule.onNodeWithText(str(Res.string.toolbar_reset)).performClick() + rule.waitForIdle() + + assertEquals(ToolbarLayout.DEFAULT, layout) + } + + @Test + fun aToolMovesDownAndUpForTalkBack() { + show() + + customAction(row = 0, label = str(Res.string.toolbar_move_down)) + assertEquals(ToolbarTool.LIST_CHECK, layout.main.first()) + + customAction(row = 1, label = str(Res.string.toolbar_move_up)) + assertEquals(ToolbarTool.HEADING, layout.main.first()) + } + + @Test + fun draggingAToolByItsHandleMovesItThroughTheList() { + show() + + rule.onAllNodes(hasMoveActions())[0].performTouchInput { + down(Offset(HANDLE_X, center.y)) + moveBy(Offset(0f, ROWS_DOWN * height)) + up() + } + rule.waitForIdle() + + assertEquals(2, layout.main.indexOf(ToolbarTool.HEADING)) + } + + @Test + fun appearanceOpensTheEditorToolbar() { + setNavScreen( + viewModels(app.appearance()), + route = NavRoutes.AppearanceRoute.name, + liveTheme = app.settings.theme, + ) { + AppearanceScreen(it) + } + + scrollToText(str(Res.string.settings_editor_toolbar)) + rule.onNodeWithText(str(Res.string.settings_editor_toolbar)).performClick() + + waitFor { currentRoute() == NavRoutes.EditorToolbarRoute.name } + } + + private companion object { + const val HANDLE_X = 30f + const val ROWS_DOWN = 2f + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorWritingTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorWritingTest.kt new file mode 100644 index 00000000..ca508010 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorWritingTest.kt @@ -0,0 +1,237 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.core.resources.Res +import com.pasich.encly.core.resources.block_heading_level +import com.pasich.encly.core.resources.dialog_confirm +import com.pasich.encly.core.resources.dialog_dismiss +import com.pasich.encly.core.resources.find_close +import com.pasich.encly.core.resources.find_count +import com.pasich.encly.core.resources.find_next +import com.pasich.encly.core.resources.keyboard_show +import com.pasich.encly.core.resources.search_notes +import com.pasich.encly.core.resources.swipe_delete_title +import com.pasich.encly.data.model.Note +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.presentation.editor.persistence.SaveStatusNote +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.editnote.EditNoteScreen +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Writing in the editor as a user does: Enter, a typed mark, a paste, Backspace, undo. */ +@OptIn(ExperimentalTestApi::class) +class EditorWritingTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + private lateinit var editor: EditNoteViewModel + + private fun open(vararg blocks: Block) { + Note(id = NOTE_ID, title = "Trip", value = BlockConverter.blocksToJson(blocks.toList()), date = 1L) + .also { app.withNotes(it) } + editor = app.editor(noteId = NOTE_ID) + setNavScreen( + viewModels(editor, app.tagList(), app.markdownExport()), + route = TestRoutes.EDIT_NOTE, + start = NavRoutes.HomeRoute.name, + ) { nav -> WithShortcuts { EditNoteScreen(nav) } } + navigateTo("${NavRoutes.EditNoteRoute.name}/$NOTE_ID?isReadTrashOnly=false") + waitFor { editor.status.value != SaveStatusNote.LOADING } + } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun deleteChord(node: androidx.compose.ui.test.SemanticsNodeInteraction) = node.performKeyInput { + withKeyDown(Key.CtrlLeft) { withKeyDown(Key.ShiftLeft) { pressKey(Key.Backspace) } } + } + + private fun paragraph(value: String) = rule.onNode(hasSetTextAction() and hasText(value)) + + private fun texts(): List = editor.blocks.map { + (it as? Block.TextBlock)?.text?.value + ?: it::class.simpleName.orEmpty() + } + + @Test + fun enterSplitsTheParagraphAtTheCursor() { + open(text("Hello world")) + paragraph("Hello world").performClick() + paragraph("Hello world").performTextInputSelection(TextRange(5)) + + paragraph("Hello world").performTextInput("\n") + + waitFor { editor.blocks.size == 2 } + assertEquals(listOf("Hello", " world"), texts()) + } + + @Test + fun aTypedMarkTurnsTheParagraphIntoAHeading() { + open(text("")) + val field = rule.onAllNodes(hasSetTextAction())[1] + field.performClick() + + field.performTextInput("#") + field.performTextInput(" ") + + waitFor { editor.blocks.first() is Block.HBlock } + assertEquals(BlockType.H1, (editor.blocks.first() as Block.HBlock).blockType) + } + + @Test + fun pastedLinesBecomeBlocksAndOneUndoTakesThemBack() { + open(text("Start")) + paragraph("Start").performClick() + + paragraph("Start").performTextInput(" one\n- milk\n- eggs\nlast") + + waitFor { editor.blocks.size == 3 } + assertEquals(listOf("Start one", "ListBlock", "last"), texts()) + rule.runOnUiThread { editor.undo() } + waitFor { editor.blocks.size == 1 } + rule.onNodeWithText("Start").assertExists() + } + + @Test + fun backspaceAtTheStartJoinsTheParagraphToTheOneAbove() { + open(text("Hello"), text(" world")) + paragraph(" world").performClick() + paragraph(" world").performTextInputSelection(TextRange(0)) + + paragraph(" world").performKeyInput { pressKey(Key.Backspace) } + + waitFor { editor.blocks.size == 1 } + 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(Res.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(Res.string.search_notes))) + + query.performTextInput("cat") + + rule.onNodeWithText(str(Res.string.find_count, 1, 2)).assertExists() + rule.onNode(hasContentDescription(str(Res.string.find_next)) and hasClickAction()).performClick() + rule.onNodeWithText(str(Res.string.find_count, 2, 2)).assertExists() + assertEquals(listOf("the cat sat", "another cat"), texts()) + rule.onNode(hasContentDescription(str(Res.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(Res.string.search_notes))) + .performKeyInput { pressKey(Key.Escape) } + waitFor { !editor.find.value.isOpen } + } + + @Test + fun theDeleteShortcutAsksBeforeMovingTheNoteToTheTrash() { + open(text("body")) + paragraph("body").performClick() + + deleteChord(paragraph("body")) + + waitForText(str(Res.string.swipe_delete_title)) + rule.onNodeWithText(str(Res.string.dialog_dismiss)).performClick() + waitFor { countText(str(Res.string.swipe_delete_title)) == 0 } + assertEquals(NavRoutes.EditNoteRoute.name, currentRouteName()) + } + + @Test + fun confirmingTheDeleteShortcutLeavesTheNote() { + open(text("body")) + paragraph("body").performClick() + deleteChord(paragraph("body")) + waitForText(str(Res.string.swipe_delete_title)) + + rule.onNodeWithText(str(Res.string.dialog_confirm)).performClick() + + waitFor { currentRouteName() == NavRoutes.HomeRoute.name } + } + + @Test + fun theDeleteShortcutIsLeftAloneOnABlankNewNote() { + editor = app.editor(noteId = -1L) + setNavScreen( + viewModels(editor, app.tagList(), app.markdownExport()), + route = TestRoutes.EDIT_NOTE, + start = NavRoutes.HomeRoute.name, + ) { nav -> WithShortcuts { EditNoteScreen(nav) } } + navigateTo("${NavRoutes.EditNoteRoute.name}/-1?isReadTrashOnly=false") + waitFor { editor.status.value != SaveStatusNote.LOADING } + + deleteChord(rule.onAllNodes(hasSetTextAction())[0]) + rule.waitForIdle() + + assertEquals(0, countText(str(Res.string.swipe_delete_title))) + } + + @Test + fun theKeyboardToggleFocusesTheWorkingBlock() { + open(text("body")) + + rule.onNode(hasContentDescription(str(Res.string.keyboard_show)) and hasClickAction()).performClick() + + waitFor { editor.focusedBlockId.value == editor.blocks.first().id } + paragraph("body").assertIsFocused() + } + + private companion object { + const val NOTE_ID = 92L + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EnclyAppTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EnclyAppTest.kt new file mode 100644 index 00000000..534304f8 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EnclyAppTest.kt @@ -0,0 +1,96 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.lifecycle.ViewModel +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.empty_notes +import com.pasich.encly.core.resources.lock_title +import com.pasich.encly.core.resources.main_drawer_settings +import com.pasich.encly.core.resources.settings_language +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.presentation.navigation.EnclyApp +import com.pasich.encly.presentation.viewmodel.ThemeViewModel +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito.`when` +import kotlin.reflect.KClass + +/** + * The app root the host draws: where it starts for the vault's state, and the guards that keep the vault's + * screens away from a closed vault (a re-lock, a stale navigation, a session unlocked elsewhere). + */ +class EnclyAppTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + /** `metroViewModel()` in any destination answers with the ViewModels built from fakes. */ + private class Prebuilt(vararg viewModels: ViewModel) : MetroViewModelFactory() { + override val viewModelProviders: Map, () -> ViewModel> = + viewModels.associate { it::class to { it } } + } + + private fun show(status: InitialStatus?, unlocked: Boolean = true) { + `when`(app.security.isDatabaseUnlocked()).thenReturn(unlocked) + `when`(app.security.hasRecoverySeed()).thenReturn(true) + val factory = Prebuilt(ThemeViewModel(app.settings), app.lock(), *app.home()) + rule.setContent { + CompositionLocalProvider(LocalMetroViewModelFactory provides factory) { + EnclyApp(status, app.security, app.sessionLock) + } + } + rule.waitForIdle() + } + + private fun onLockScreen() = countText(str(Res.string.lock_title)) > 0 + + private fun onHome() = countText(str(Res.string.empty_notes)) > 0 + + @Test + fun anOpenVaultStartsOnHome() { + show(InitialStatus.MAIN) + + waitFor { onHome() } + assertEquals(0, countText(str(Res.string.lock_title))) + } + + @Test + fun aCommittedVaultThatIsNotOpenStartsOnTheLockScreen() { + show(InitialStatus.AUTH, unlocked = false) + + waitFor { onLockScreen() } + } + + @Test + fun nothingIsDrawnUntilTheVaultStateIsKnown() { + show(null) + + assertEquals(0, visibleTexts().size) + } + + @Test + fun aReLockReplacesTheScreenWithTheLockScreenAndNothingOfTheVaultStaysDrawn() { + show(InitialStatus.MAIN) + waitFor { onHome() } + + rule.runOnIdle { app.sessionLock.requireUnlock() } + + waitFor { onLockScreen() } + assertEquals(0, countText(str(Res.string.empty_notes))) + } + + @Test + fun aNavigationToAVaultScreenWhileTheVaultIsClosedIsSentToTheLockScreen() { + show(InitialStatus.MAIN) + waitFor { onHome() } + + // Something closed the database without the lock event arriving yet; the next vault screen must not show. + `when`(app.security.isDatabaseUnlocked()).thenReturn(false) + rule.onNodeWithContentDescription(str(Res.string.main_drawer_settings)).performClick() + + waitFor { onLockScreen() } + assertEquals(0, countText(str(Res.string.settings_language))) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt new file mode 100644 index 00000000..47c2dfc8 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt @@ -0,0 +1,41 @@ +package com.pasich.encly.ui.screens + +import androidx.lifecycle.SavedStateHandle +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.handoff_error_vault +import com.pasich.encly.core.resources.lock_title +import com.pasich.encly.platform.CacheDirectory +import com.pasich.encly.presentation.screen.handoff.ImportFromMyNotesScreen +import com.pasich.encly.presentation.viewmodel.ImportFromMyNotesViewModel +import com.pasich.encly.testutil.FakeDocuments +import kotlinx.coroutines.Dispatchers +import kotlinx.io.files.Path +import org.junit.Test + +/** The hand-off screen while the vault is closed. */ +class ImportFromMyNotesScreenTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + @Test + fun aVaultNothingCanOpenIsReportedInsteadOfTheLockScreen() { + val handoff = ImportFromMyNotesViewModel( + documents = FakeDocuments(), + cache = object : CacheDirectory { + override val path = Path(context.cacheDir.path) + }, + securityManager = app.security, + sessionLockManager = app.sessionLock, + backupManager = app.backupManager, + ioDispatcher = Dispatchers.Main, + savedStateHandle = SavedStateHandle(), + ) + app.sessionLock.requireUnlock() + setScreen(viewModels(handoff, app.lock())) { ImportFromMyNotesScreen(onClose = {}) } + waitForText(str(Res.string.lock_title)) + + // The lock screen found that the PIN key is gone and there is nothing else to unlock with. + rule.runOnIdle { handoff.onVaultLost() } + + waitForText(str(Res.string.handoff_error_vault)) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt new file mode 100644 index 00000000..1b5bb2c9 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt @@ -0,0 +1,296 @@ +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.core.resources.Res +import com.pasich.encly.core.resources.backup_count_notes +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.done +import com.pasich.encly.core.resources.import_action_import_count +import com.pasich.encly.core.resources.import_badge_in_vault +import com.pasich.encly.core.resources.import_choose_again +import com.pasich.encly.core.resources.import_choose_files +import com.pasich.encly.core.resources.import_choose_folder +import com.pasich.encly.core.resources.import_choose_source_title +import com.pasich.encly.core.resources.import_detected +import com.pasich.encly.core.resources.import_done_body +import com.pasich.encly.core.resources.import_done_hint +import com.pasich.encly.core.resources.import_done_title +import com.pasich.encly.core.resources.import_error_encrypted_nn +import com.pasich.encly.core.resources.import_error_encrypted_sn +import com.pasich.encly.core.resources.import_error_failed +import com.pasich.encly.core.resources.import_error_no_picker +import com.pasich.encly.core.resources.import_error_nothing +import com.pasich.encly.core.resources.import_error_title +import com.pasich.encly.core.resources.import_error_too_large +import com.pasich.encly.core.resources.import_error_unreadable +import com.pasich.encly.core.resources.import_error_unsupported +import com.pasich.encly.core.resources.import_error_vault +import com.pasich.encly.core.resources.import_filter_new +import com.pasich.encly.core.resources.import_intro +import com.pasich.encly.core.resources.import_joplin_name +import com.pasich.encly.core.resources.import_keep_name +import com.pasich.encly.core.resources.import_loss_pinned +import com.pasich.encly.core.resources.import_loss_unsupported +import com.pasich.encly.core.resources.import_preview_title +import com.pasich.encly.core.resources.import_review_selected +import com.pasich.encly.core.resources.import_select_all +import com.pasich.encly.core.resources.import_select_none +import com.pasich.encly.core.resources.import_stat_checklists +import com.pasich.encly.core.resources.import_stat_destination +import com.pasich.encly.core.resources.import_untitled +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", + tags = if (uid == "a") listOf("home") else emptyList(), + 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(Res.string.import_intro)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.import_choose_files)).assertIsEnabled().performClick() + rule.onNodeWithText(str(Res.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() + // How to export and what is lost unfold on a tap, so the list stays short. + rule.onNodeWithText(str(source.howTo)).assertDoesNotExist() + rule.onNodeWithText(str(source.title)).performClick() + 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(Res.string.import_preview_title)).assertIsDisplayed() + rule.onNodeWithText( + str( + Res.string.import_detected, + str(Res.string.import_keep_name), + plural(Res.plurals.backup_count_notes, 4, 4), + ), + ).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.import_stat_checklists, 1)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.import_stat_destination)).assertExists() + rule.onNode(hasText(str(Res.string.import_loss_pinned, 2), substring = true)).assertExists() + rule.onNode(hasText(str(Res.string.import_loss_unsupported, 1), substring = true)).assertExists() + rule.onNodeWithText(str(Res.string.import_review_selected, 3, 3)).assertIsDisplayed() + scrollTo("Groceries") + rule.onNodeWithText("Groceries").assertIsDisplayed() + } + + @Test + fun newNotesAreTickedAndAnExistingOneIsNotAndCannotBe() { + show(review()) + + scrollTo(str(Res.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(Res.string.import_badge_in_vault), substring = true)).assertExists() + rule.onNodeWithText(str(Res.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(Res.string.import_action_import_count, 0)).assertIsNotEnabled() + rule.onNodeWithText(str(Res.string.import_select_all)).performClick() + assertEquals(listOf("all"), calls) + } + + @Test + fun whenEveryNoteIsAlreadyHereThereIsNothingToSelect() { + val seen = listOf(row("c", "Seen before", existing = true)) + show(ImportStep.Preview(plain, ReviewState(seen, emptySet(), ReviewFilter.ALL))) + + rule.onNodeWithText(str(Res.string.import_review_selected, 0, 0)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.import_select_all)).assertDoesNotExist() + rule.onNodeWithText(str(Res.string.import_select_none)).assertDoesNotExist() + rule.onNodeWithText(str(Res.string.import_action_import_count, 0)).assertIsNotEnabled() + } + + @Test + fun withASelectionImportIsEnabledAndCountsIt() { + show(review()) + + rule.onNodeWithText(str(Res.string.import_select_none)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.import_action_import_count, 3)).assertIsEnabled().performClick() + rule.onNodeWithText(str(Res.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(Res.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(Res.string.import_choose_source_title)).assertIsDisplayed() + ImportSource.entries.forEach { rule.onNodeWithText(str(it.title)).assertExists() } + rule.onNodeWithText(str(Res.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(Res.string.import_done_title)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.import_done_body, 3, 1, 2)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.import_done_hint)).assertIsDisplayed() + rule.onNode(hasText(str(Res.string.done)) and hasClickAction()).performClick() + assertEquals(listOf("cancel"), calls) + } + + @Test + fun everyErrorHasItsOwnMessageAndWayBack() { + val messages = mapOf( + ImportFailure.ENCRYPTED_STANDARD_NOTES to Res.string.import_error_encrypted_sn, + ImportFailure.ENCRYPTED_NOTESNOOK to Res.string.import_error_encrypted_nn, + ImportFailure.UNSUPPORTED to Res.string.import_error_unsupported, + ImportFailure.TOO_LARGE to Res.string.import_error_too_large, + ImportFailure.NOTHING_FOUND to Res.string.import_error_nothing, + ImportFailure.UNREADABLE to Res.string.import_error_unreadable, + ImportFailure.NO_PICKER to Res.string.import_error_no_picker, + ImportFailure.VAULT to Res.string.import_error_vault, + ImportFailure.IMPORT to Res.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(Res.string.import_error_title)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.import_choose_again)).performClick() + assertEquals(listOf("cancel"), calls) + } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/InfoScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/InfoScreensTest.kt new file mode 100644 index 00000000..899a0c42 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/InfoScreensTest.kt @@ -0,0 +1,180 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import com.pasich.encly.core.LINK_REPOSITORY +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.about_data_protection +import com.pasich.encly.core.resources.about_privacy_policy +import com.pasich.encly.core.resources.about_rate_app +import com.pasich.encly.core.resources.about_source_code +import com.pasich.encly.core.resources.about_source_code_desc +import com.pasich.encly.core.resources.about_support_dev_desc +import com.pasich.encly.core.resources.about_write_developer +import com.pasich.encly.core.resources.donation_title +import com.pasich.encly.core.resources.faq_forgot_pin_a +import com.pasich.encly.core.resources.faq_forgot_pin_q +import com.pasich.encly.core.resources.legacy_vault_title +import com.pasich.encly.core.resources.licenses_fonts +import com.pasich.encly.core.resources.licenses_heading +import com.pasich.encly.core.resources.licenses_libraries +import com.pasich.encly.core.resources.licenses_title +import com.pasich.encly.core.resources.loss_hold_to_wipe +import com.pasich.encly.core.resources.loss_title +import com.pasich.encly.core.resources.support_browser_note +import com.pasich.encly.core.resources.support_method_kofi_hint +import com.pasich.encly.core.resources.vault_reset_restore_hint +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.AboutScreen +import com.pasich.encly.presentation.screen.FaqScreen +import com.pasich.encly.presentation.screen.LicensesScreen +import com.pasich.encly.presentation.screen.LossReason +import com.pasich.encly.presentation.screen.LossRecoveryScreen +import com.pasich.encly.presentation.screen.SupportScreen +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InfoScreensTest : ComposeScreenTest() { + + @Test + fun aboutShowsTheAppItsProtectionAndItsLinks() { + setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } + + rule.onNodeWithText(str(Res.string.about_data_protection)).assertIsDisplayed() + scrollToText(str(Res.string.about_privacy_policy)) + scrollToText(str(Res.string.about_write_developer)) + scrollToText(str(Res.string.licenses_title)) + assertTrue(visibleTexts().map(::textOf).any { it.contains(appInfo.versionName) }) + assertBodyNotEmpty(minTexts = 6) + } + + @Test + fun aboutLinksTheSourceCodeInEveryBuild() { + setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } + + scrollToText(str(Res.string.about_source_code)) + scrollToText(str(Res.string.about_source_code_desc)) + assertEquals("https://github.com/pasichDev/Encly", LINK_REPOSITORY) + } + + @Test + fun aboutOffersARatingInTheStoreBuild() { + appInfo = StoreBuild(rating = true, donations = false) + setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } + + assertEquals(1, countText(str(Res.string.about_rate_app))) + } + + @Test + fun aboutOffersNoRatingInTheOtherBuild() { + appInfo = StoreBuild(rating = false, donations = true) + setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } + + assertEquals(0, countText(str(Res.string.about_rate_app))) + } + + @Test + fun aboutLinksTheSupportPageInTheFdroidBuild() { + appInfo = StoreBuild(rating = false, donations = true) + setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } + + assertEquals(1, countText(str(Res.string.about_support_dev_desc))) + } + + @Test + fun aboutHasNoSupportPageInTheStoreBuild() { + appInfo = StoreBuild(rating = true, donations = false) + setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } + + assertEquals(0, countText(str(Res.string.about_support_dev_desc))) + } + + @Test + fun aboutLeadsToTheLicenses() { + setNavScreen(route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } + scrollToText(str(Res.string.licenses_title)) + + rule.onNodeWithText(str(Res.string.licenses_title)).performClick() + + waitFor { currentRoute() == NavRoutes.LicensesRoute.name } + } + + @Test + fun licensesListLibrariesAndFonts() { + setNavScreen(route = NavRoutes.LicensesRoute.name) { nav -> LicensesScreen(nav) } + + rule.onNodeWithText(str(Res.string.licenses_heading)).assertIsDisplayed() + scrollToText(str(Res.string.licenses_libraries), ignoreCase = true) + scrollToText(str(Res.string.licenses_fonts), ignoreCase = true) + assertBodyNotEmpty(minTexts = 8) + } + + @Test + fun faqAnswersOpenOnTap() { + setNavScreen(route = NavRoutes.FaqRoute.name) { nav -> FaqScreen(nav) } + scrollToText(str(Res.string.faq_forgot_pin_q)) + assertFalse(countText(str(Res.string.faq_forgot_pin_a)) > 0 && isShown(str(Res.string.faq_forgot_pin_a))) + + rule.onNodeWithText(str(Res.string.faq_forgot_pin_q)).performClick() + + waitFor { isShown(str(Res.string.faq_forgot_pin_a)) } + assertBodyNotEmpty(minTexts = 6) + } + + @Test + fun supportExplainsTheDonation() { + setNavScreen(route = NavRoutes.SupportRoute.name) { nav -> SupportScreen(nav) } + + rule.onNodeWithText(str(Res.string.donation_title)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 4) + } + + @Test + fun supportOffersKofi() { + setNavScreen(route = NavRoutes.SupportRoute.name) { nav -> SupportScreen(nav) } + + scrollToText("Ko-fi") + scrollToText(str(Res.string.support_method_kofi_hint)) + scrollToText(str(Res.string.support_browser_note)) + } + + @Test + fun damagedDataExplainsAndWipesOnlyAfterAHold() { + var wiped = false + setScreen { LossRecoveryScreen(onRecoveryConfirm = { wiped = true }, reason = LossReason.DAMAGED) } + + rule.onNodeWithText(str(Res.string.loss_title)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.vault_reset_restore_hint)).assertIsDisplayed() + val button = rule.onNodeWithText(str(Res.string.loss_hold_to_wipe)) + button.performClick() + rule.mainClock.advanceTimeBy(HOLD_MS) + assertFalse("a tap must not wipe", wiped) + + button.performTouchInput { down(center) } + rule.mainClock.advanceTimeBy(HOLD_MS + 500) + button.performTouchInput { up() } + rule.waitForIdle() + + assertTrue(wiped) + } + + @Test + fun dataFromAnOlderVersionSaysSo() { + setScreen { LossRecoveryScreen(onRecoveryConfirm = {}, reason = LossReason.OLDER_VERSION) } + + rule.onNodeWithText(str(Res.string.legacy_vault_title)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 3) + } + + private fun isShown(text: String): Boolean = rule.onAllNodes(androidx.compose.ui.test.hasText(text)) + .fetchSemanticsNodes().any { it.boundsInRoot.height > 0f } + + private companion object { + const val HOLD_MS = 4_000L + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt new file mode 100644 index 00000000..6d7708e2 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt @@ -0,0 +1,174 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.allStringResources +import com.pasich.encly.core.resources.backup_intro_short +import com.pasich.encly.core.resources.block_more_tools +import com.pasich.encly.core.resources.empty_notes +import com.pasich.encly.core.resources.lock_recover_access +import com.pasich.encly.core.resources.lock_title +import com.pasich.encly.core.resources.lock_use_recovery_phrase +import com.pasich.encly.core.resources.onboarding_welcome_headline +import com.pasich.encly.core.resources.palette_midnight +import com.pasich.encly.core.resources.security_encryption_active +import com.pasich.encly.core.resources.settings_appearance +import com.pasich.encly.core.resources.task_empty_title +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.LockScreen +import com.pasich.encly.presentation.screen.MainRootScreen +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.onboarding.OnboardingScreen +import com.pasich.encly.presentation.screen.settings.AppearanceScreen +import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen +import com.pasich.encly.presentation.screen.settings.SettingsScreen +import com.pasich.encly.testutil.TestLocale +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +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.`when` +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.Locale + +/** + * Every main screen in each of the nine languages: it renders, shows its own text, and shows no + * English string that this language translates (a hardcoded or untranslated string). + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +class LocalizedScreensTest(private val language: String) : ComposeScreenTest() { + private val app by lazy { TestApp() } + + /** English texts that [language] translates differently: none of them may be shown. */ + private val untranslated: Set by lazy { + val english = TestLocale.within("en") { everyString() } + val translated = everyString() + english.mapNotNull { (key, en) -> + en.takeIf { language != "en" && it.length > 3 && it != translated[key] } + }.flatMap { listOf(it, it.uppercase()) }.toSet() + } + + /** Every string resource in the current locale, by key. */ + private fun everyString(): Map = + runBlocking { Res.allStringResources.mapValues { getString(it.value) } } + + @After + fun restoreLanguage() = TestLocale.set(Locale.US.toLanguageTag()) + + @Before + fun useLanguage() { + RuntimeEnvironment.setQualifiers("+$language") + TestLocale.set(language) + assertEquals(language, context.resources.configuration.locales[0].language) + if (language != "en") assertTrue("nothing is translated into $language", untranslated.size > MIN_TRANSLATED) + } + + private fun assertInLanguage(expected: StringResource) { + waitForText(str(expected), ignoreCase = true) + val leaks = visibleTexts().map(::textOf).filter { it in untranslated } + assertTrue("[$language] English text on screen: $leaks", leaks.isEmpty()) + assertBodyNotEmpty() + } + + @Test + fun welcome() { + setScreen(viewModels(app.onboarding(), app.authSetup())) { OnboardingScreen(onComplete = {}) } + assertInLanguage(Res.string.onboarding_welcome_headline) + } + + @Test + fun notes() { + setNavScreen(viewModels(*app.home()), route = NavRoutes.HomeRoute.name) { nav -> MainRootScreen(nav) } + assertInLanguage(Res.string.empty_notes) + } + + @Test + fun tasks() { + setNavScreen(viewModels(app.tasksVm()), route = TestRoutes.TASKS) { nav -> TasksScreen(nav) } + assertInLanguage(Res.string.task_empty_title) + } + + @Test + fun editor() { + val editor = app.editor() + setNavScreen( + viewModels(editor, app.tagList(), app.markdownExport()), + route = TestRoutes.EDIT_NOTE, + start = NavRoutes.HomeRoute.name, + ) { nav -> + EditNoteScreen(nav) + } + navigateTo("${NavRoutes.EditNoteRoute.name}/-1") + waitFor { + rule.onAllNodes( + androidx.compose.ui.test.hasContentDescription(str(Res.string.block_more_tools)), + ).fetchSemanticsNodes().isNotEmpty() + } + val leaks = visibleTexts().map(::textOf).filter { it in untranslated } + assertTrue("[$language] English text on screen: $leaks", leaks.isEmpty()) + } + + @Test + fun settings() { + setNavScreen(viewModels(app.settingsVm()), route = NavRoutes.SettingsRoute.name) { nav -> SettingsScreen(nav) } + assertInLanguage(Res.string.settings_appearance) + } + + @Test + fun appearance() { + setNavScreen(viewModels(app.appearance()), route = NavRoutes.AppearanceRoute.name) { nav -> + AppearanceScreen(nav) + } + assertInLanguage(Res.string.palette_midnight) + } + + @Test + fun security() { + setNavScreen( + viewModels(app.securitySettings(), app.notePreviewsVm(), app.toolbarVm(), app.backup()), + route = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> + SecuritySettingsScreen(nav) + } + assertInLanguage(Res.string.security_encryption_active) + } + + @Test + fun backup() { + setNavScreen(viewModels(app.backup(), app.markdownExport()), route = NavRoutes.BackupRoute.name) { nav -> + BackupScreen(nav) + } + assertInLanguage(Res.string.backup_intro_short) + } + + @Test + fun lockAndRecovery() { + `when`(app.security.hasRecoverySeed()).thenReturn(true) + setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> LockScreen(nav) } + assertInLanguage(Res.string.lock_title) + + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + + assertInLanguage(Res.string.lock_recover_access) + (1..PHRASE_WORDS).forEach { phraseCell(it).assertExists() } + } + + companion object { + /** Far fewer translated strings than this means the language did not load. */ + private const val MIN_TRANSLATED = 300 + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun languages(): List> = + listOf("en", "uk", "de", "es", "fr", "it", "nl", "pl", "pt", "ar").map { arrayOf(it) } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenAutoPromptTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenAutoPromptTest.kt new file mode 100644 index 00000000..f4d8c605 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenAutoPromptTest.kt @@ -0,0 +1,100 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.lifecycle.Lifecycle +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.pin_unlock_with_fingerprint +import com.pasich.encly.platform.LocalBiometricHost +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.LockScreen +import com.pasich.encly.testutil.TestScreen +import com.pasich.encly.testutil.anyCallback +import com.pasich.encly.testutil.eqValue +import org.junit.Test +import org.mockito.Mockito.mockingDetails +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +/** + * The lock screen's automatic fingerprint prompt: the auto-lock puts the lock screen up while the + * app is in the background, where the system would cancel a prompt unseen and leave the ViewModel + * believing one is open. The prompt waits for the window to be in front, and the button works + * whenever no prompt is really showing. + */ +@OptIn(ExperimentalTestApi::class) +class LockScreenAutoPromptTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private fun show(screen: TestScreen) { + `when`(app.security.isBiometricEnabled()).thenReturn(true) + `when`(app.security.biometricAvailable()).thenReturn(true) + setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> + CompositionLocalProvider(LocalBiometricHost provides screen.host) { LockScreen(nav) } + } + } + + private fun promptsShown(): Int = mockingDetails(app.security).invocations + .count { it.method.name == "requestBiometricKey" } + + private fun onUi(block: () -> Unit) = rule.runOnIdle(block).also { rule.waitForIdle() } + + @Test + fun aLockScreenShownInTheBackgroundPromptsOnceTheAppIsResumed() { + val screen = TestScreen(Lifecycle.State.CREATED) + show(screen) + rule.waitForIdle() + verify(app.security, never()).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + + onUi { screen.resume() } + + rule.waitUntil(WAIT_MS) { promptsShown() == 1 } + } + + @Test + fun aStartedButNotResumedActivityGetsNoAutomaticPrompt() { + // The lock screen composes at ON_START after a background auto-lock; the reporter's fix was to wait + // for RESUMED. + val screen = TestScreen(Lifecycle.State.STARTED) + show(screen) + rule.waitForIdle() + verify(app.security, never()).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + + onUi { screen.resume() } + + rule.waitUntil(WAIT_MS) { promptsShown() == 1 } + } + + @Test + fun aPromptIsNotRepeatedWhenTheAppComesBackAgain() { + val screen = TestScreen() + show(screen) + rule.waitUntil(WAIT_MS) { promptsShown() == 1 } + + onUi { screen.stop() } + onUi { screen.resume() } + + verify(app.security, times(1)).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + } + + @Test + fun theFingerprintButtonPromptsAgainAfterAPromptWasCutOff() { + val screen = TestScreen() + show(screen) + rule.waitUntil(WAIT_MS) { promptsShown() == 1 } + onUi { screen.stop() } + onUi { screen.resume() } + + rule.onNodeWithContentDescription(str(Res.string.pin_unlock_with_fingerprint)).performClick() + + rule.waitUntil(WAIT_MS) { promptsShown() == 2 } + } + + private companion object { + const val WAIT_MS = 5_000L + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt similarity index 80% rename from app/src/test/java/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt index 8664710a..5285595c 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt @@ -4,16 +4,20 @@ import android.app.Application import android.content.Context import android.content.pm.ActivityInfo import android.os.Bundle +import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.junit4.createEmptyComposeRule -import androidx.fragment.app.FragmentActivity import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.core.security.BiometricHost import com.pasich.encly.domain.model.ThemeSettings +import com.pasich.encly.platform.LocalBiometricHost +import com.pasich.encly.platform.ScreenBiometricHost import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.screen.LockScreen import com.pasich.encly.presentation.viewmodel.LockViewModel @@ -49,7 +53,7 @@ class LockScreenRecreationTest { val rule = createEmptyComposeRule() private val context: Context get() = ApplicationProvider.getApplicationContext() - private val app by lazy { TestApp(context) } + private val app by lazy { TestApp() } private lateinit var lock: LockViewModel private var scenario: ActivityScenario? = null @@ -81,7 +85,7 @@ class LockScreenRecreationTest { fun aRecreationDuringTheBiometricPromptPromptsAgain() { rule.waitUntil(WAIT_MS) { lock.biometricInFlight } val first = currentActivity() - verify(app.security).requestBiometricKey(eqValue(first), anyCallback()) + verify(app.security).requestBiometricKey(eqValue(TestActivityHost(first)), anyCallback()) checkNotNull(scenario).recreate() val second = currentActivity() @@ -90,7 +94,7 @@ class LockScreenRecreationTest { // The recreated screen asks again, from the new activity, instead of waiting forever for // an answer the destroyed one can no longer deliver. rule.waitUntil(WAIT_MS) { promptsShown() == 2 } - verify(app.security).requestBiometricKey(eqValue(second), anyCallback()) + verify(app.security).requestBiometricKey(eqValue(TestActivityHost(second)), anyCallback()) assertTrue(lock.biometricInFlight) } @@ -110,7 +114,7 @@ class LockScreenRecreationTest { } /** Hosts [LockScreen] the way MainActivity does, with ViewModels that outlive the activity. */ -class LockHostActivity : FragmentActivity() { +class LockHostActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val owner = checkNotNull(viewModels) @@ -119,7 +123,14 @@ class LockHostActivity : FragmentActivity() { val nav = rememberNavController() NavHost(navController = nav, startDestination = NavRoutes.LockRoute.name) { // Inside the destination, which is itself a ViewModelStoreOwner. - composable(NavRoutes.LockRoute.name) { ProvideViewModels(owner) { LockScreen(nav) } } + composable(NavRoutes.LockRoute.name) { + ProvideViewModels(owner) { + CompositionLocalProvider( + LocalBiometricHost provides + ScreenBiometricHost(TestActivityHost(this@LockHostActivity), this@LockHostActivity), + ) { LockScreen(nav) } + } + } composable(NavRoutes.HomeRoute.name) { Text("stub:${NavRoutes.HomeRoute.name}") } } } @@ -130,3 +141,6 @@ class LockHostActivity : FragmentActivity() { var viewModels: FakeViewModels? = null } } + +/** The host the prompt is shown on: one per Activity, equal for the same one. */ +data class TestActivityHost(val activity: ComponentActivity) : BiometricHost diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenTest.kt new file mode 100644 index 00000000..5fc57d62 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenTest.kt @@ -0,0 +1,305 @@ +package com.pasich.encly.ui.screens + +import android.app.Activity +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.pressKey +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_back_to_pin +import com.pasich.encly.core.resources.lock_recover_access +import com.pasich.encly.core.resources.lock_recovery_title +import com.pasich.encly.core.resources.lock_subtitle +import com.pasich.encly.core.resources.lock_title +import com.pasich.encly.core.resources.lock_use_recovery_phrase +import com.pasich.encly.core.resources.lock_wrong_pin +import com.pasich.encly.core.resources.lock_wrong_recovery_phrase +import com.pasich.encly.core.resources.pin_delete_digit +import com.pasich.encly.core.resources.pin_digits_count +import com.pasich.encly.core.resources.recovery_word_unknown +import com.pasich.encly.core.security.RecoveryStage +import com.pasich.encly.core.security.VaultUnlockResult +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.LockScreen +import com.pasich.encly.testutil.anyCharArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.`when` +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalTestApi::class) +class LockScreenTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private fun show(recovery: Boolean = true) { + `when`(app.security.hasRecoverySeed()).thenReturn(recovery) + setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> + back = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher + activity = LocalActivity.current + LockScreen(nav) + } + } + + private lateinit var back: OnBackPressedDispatcher + private var activity: Activity? = null + + /** + * The lock screen over a vault screen, as after a re-lock whose navigation left Home below it: + * the worst case, where a Back that got through would reveal the notes. + */ + private fun showOverTheVault() { + `when`(app.security.hasRecoverySeed()).thenReturn(true) + setNavScreen( + viewModels(app.lock()), + route = NavRoutes.LockRoute.name, + start = NavRoutes.HomeRoute.name, + ) { nav -> + back = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher + activity = LocalActivity.current + LockScreen(nav) + } + navigateTo(NavRoutes.LockRoute.name) + assertStillLocked() + } + + private fun pressBack() { + rule.runOnIdle { back.onBackPressed() } + rule.waitForIdle() + } + + /** Still on the lock screen; the vault screen below was never drawn and the app did not close. */ + private fun assertStillLocked() { + assertEquals(NavRoutes.LockRoute.name, currentRoute()) + assertEquals(0, countText("stub:${NavRoutes.HomeRoute.name}")) + assertFalse(activity?.isFinishing == true) + } + + private fun anyChars(): CharArray = ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0) + + @Test + fun showsThePinPad() { + show() + + rule.onNodeWithText(str(Res.string.lock_title)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.lock_subtitle)).assertIsDisplayed() + (0..9).forEach { rule.onNodeWithText(it.toString()).assertIsDisplayed() } + rule.onNodeWithContentDescription(str(Res.string.pin_delete_digit)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 10) + } + + @Test + fun withoutARecoveryPhraseThereIsNoRecoveryLink() { + show(recovery = false) + + assertEquals(0, countText(str(Res.string.lock_use_recovery_phrase))) + } + + @Test + fun theDotsFollowTheDigitsAndBackspace() { + show() + + typePin("12") + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 2)).assertExists() + rule.onNodeWithContentDescription(str(Res.string.pin_delete_digit)).performClick() + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 1)).assertExists() + } + + @Test + fun aWrongPinSaysSoAndStays() { + `when`(app.security.unlockWithPin(anyCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) + show() + + enterPin("000000") + + waitForText(str(Res.string.lock_wrong_pin)) + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 0)).assertExists() + assertEquals(NavRoutes.LockRoute.name, currentRoute()) + } + + @Test + fun theRightPinOpensTheNotes() { + `when`(app.security.unlockWithPin(anyCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + show() + + enterPin("123456") + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + } + + @Test + fun theRecoveryFormHasTwelveCellsAndWaitsForAValidPhrase() { + show() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + + rule.onNodeWithText(str(Res.string.lock_recovery_title)).assertIsDisplayed() + (1..12).forEach { phraseCell(it).assertExists() } + rule.onNodeWithText(str(Res.string.lock_recover_access)).assertIsNotEnabled() + + phraseCell(1).performTextReplacement(PHRASE) + + assertEquals(PHRASE.split(' '), phraseCells()) + rule.onNodeWithText(str(Res.string.lock_recover_access)).assertIsEnabled() + } + + @Test + fun spaceMovesOnAndBackspaceInAnEmptyCellGoesBack() { + show() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + phraseCell(1).performClick() + + phraseCell(1).performTextInput("abandon ") + phraseCell(2).assertIsFocused() + assertEquals("abandon", phraseCells()[0]) + + phraseCell(2).performKeyInput { pressKey(Key.Backspace) } + phraseCell(1).assertIsFocused() + } + + @Test + fun nextOnTheKeyboardMovesToTheNextCell() { + show() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + phraseCell(4).performTextInput("zoo") + + phraseCell(4).performImeAction() + + phraseCell(5).assertIsFocused() + } + + @Test + fun capitalsAndDigitsAreCleanedFromAWord() { + show() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + + phraseCell(1).performTextInput("Zoo1") + + assertEquals("zoo", phraseCells()[0]) + } + + @Test + fun aWordOutsideTheWordlistIsFlagged() { + show() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + + phraseCell(3).performTextInput("qqq") + + rule.onNodeWithText(str(Res.string.recovery_word_unknown, 3)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.lock_recover_access)).assertIsNotEnabled() + } + + @Test + fun aWrongPhraseSaysSoAndKeepsTheWords() { + `when`(app.security.unlockWithSeed(anyChars())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) + show() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + phraseCell(1).performTextReplacement(PHRASE) + + rule.onNodeWithText(str(Res.string.lock_recover_access)).performClick() + + waitForText(str(Res.string.lock_wrong_recovery_phrase)) + assertEquals(PHRASE.split(' '), phraseCells()) + } + + @Test + fun theRightPhraseAsksForANewPinWhenNoneCanBeUsed() { + `when`(app.security.unlockWithSeed(anyChars())).thenReturn(VaultUnlockResult.SUCCESS) + `when`(app.security.recoveryStage()).thenReturn(RecoveryStage.MUST_SET_PIN) + show() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + phraseCell(1).performTextReplacement(PHRASE) + + rule.onNodeWithText(str(Res.string.lock_recover_access)).performClick() + + waitFor { currentRoute() == NavRoutes.PinCodeConfig.name } + } + + @Test + fun backOnThePinPadNeverLeavesTheLockScreen() { + showOverTheVault() + typePin("12") + + repeat(3) { + pressBack() + assertStillLocked() + } + rule.onNodeWithText(str(Res.string.lock_title)).assertIsDisplayed() + } + + @Test + fun backWhenTheLockScreenIsAllThereIsDoesNotCloseTheApp() { + show() + + pressBack() + + assertStillLocked() + rule.onNodeWithText(str(Res.string.lock_title)).assertIsDisplayed() + } + + @Test + fun backFromTheRecoveryFormReturnsToThePinPadNotTheVault() { + showOverTheVault() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + rule.onNodeWithText(str(Res.string.lock_recovery_title)).assertIsDisplayed() + + pressBack() + + assertStillLocked() + rule.onNodeWithText(str(Res.string.lock_title)).assertIsDisplayed() + assertEquals(0, countText(str(Res.string.lock_recovery_title))) + + pressBack() + assertStillLocked() + } + + @Test + fun backWhileThePinIsCheckedStaysOnTheLockScreen() { + val checking = CountDownLatch(1) + val release = CountDownLatch(1) + `when`(app.security.unlockWithPin(anyCharArray())).thenAnswer { + checking.countDown() + release.await(WAIT_MS, TimeUnit.MILLISECONDS) + VaultUnlockResult.INVALID_CREDENTIAL + } + showOverTheVault() + enterPin("000000") + assertTrue(checking.await(WAIT_MS, TimeUnit.MILLISECONDS)) + + pressBack() + assertStillLocked() + + release.countDown() + waitForText(str(Res.string.lock_wrong_pin)) + assertStillLocked() + } + + @Test + fun backToPinDropsTheWords() { + show() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + phraseCell(1).performTextReplacement(PHRASE) + + rule.onNodeWithText(str(Res.string.lock_back_to_pin)).performClick() + rule.onNodeWithText(str(Res.string.lock_title)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + + assertTrue(phraseCells().all(String::isEmpty)) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/MainScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/MainScreenTest.kt new file mode 100644 index 00000000..5225f99b --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/MainScreenTest.kt @@ -0,0 +1,323 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.about +import com.pasich.encly.core.resources.delete +import com.pasich.encly.core.resources.duplicate +import com.pasich.encly.core.resources.edit +import com.pasich.encly.core.resources.empty_notes +import com.pasich.encly.core.resources.empty_notes_desc +import com.pasich.encly.core.resources.empty_notes_tagged +import com.pasich.encly.core.resources.home_tasks_none_open +import com.pasich.encly.core.resources.import_title +import com.pasich.encly.core.resources.main_drawer_archive +import com.pasich.encly.core.resources.main_drawer_faq +import com.pasich.encly.core.resources.main_drawer_settings +import com.pasich.encly.core.resources.main_drawer_support +import com.pasich.encly.core.resources.main_drawer_tags +import com.pasich.encly.core.resources.main_drawer_tasks +import com.pasich.encly.core.resources.main_drawer_trash +import com.pasich.encly.core.resources.note_add +import com.pasich.encly.core.resources.open_menu +import com.pasich.encly.core.resources.search_clear +import com.pasich.encly.core.resources.search_nothing_matches +import com.pasich.encly.core.resources.search_placeholder +import com.pasich.encly.core.resources.show_all_notes +import com.pasich.encly.core.resources.tap_again_to_delete +import com.pasich.encly.core.resources.task_add +import com.pasich.encly.core.resources.task_filter_all +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.data.model.Task +import com.pasich.encly.domain.repository.NotesRepository +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.navigation.TASKS_ADD_ARG +import com.pasich.encly.presentation.screen.MainRootScreen +import com.pasich.encly.testutil.TestNotesRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flow +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.IOException + +class MainScreenTest : ComposeScreenTest() { + private companion object { + const val FADE_MS = 1_000L + } + + private val app by lazy { TestApp() } + + private fun show(notesRepository: NotesRepository = app.notes) { + setNavScreen(viewModels(*app.home(app.noteList(notesRepository))), route = NavRoutes.HomeRoute.name) { nav -> + MainRootScreen(nav) + } + } + + private fun note(id: Long, title: String, text: String = "") = Note( + id = id, + title = title, + value = BlockConverter.blocksToJson(listOf(Block.TextBlock(kotlinx.coroutines.flow.MutableStateFlow(text)))), + date = 1_700_000_000_000 + id, + ) + + @Test + fun noNotesShowsTheEmptyStateUnderTheHeader() { + show() + + waitForText(str(Res.string.empty_notes)) + rule.mainClock.advanceTimeBy(FADE_MS) + rule.onNodeWithText(str(Res.string.empty_notes_desc)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.search_placeholder)).assertExists() + rule.onNodeWithText(str(Res.string.note_add), useUnmergedTree = true).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 4) + } + + @Test + fun oneNoteIsListed() { + app.withNotes(note(1, "Groceries", "milk, bread")) + show() + + waitForText("Groceries") + assertEquals(0, countText(str(Res.string.empty_notes))) + } + + @Test + fun manyNotesAreListed() { + app.withNotes(*Array(12) { note(it + 1L, "Note ${it + 1}", "body ${it + 1}") }) + show() + + waitForText("Note 12") + assertBodyNotEmpty(minTexts = 8) + } + + @Test + fun aFailedReadIsAnErrorNeverAnEmptyVault() { + val failing = object : NotesRepository by TestNotesRepository() { + override fun getAllNotesWithTag(): Flow> = flow { throw IOException("vault closed") } + } + show(failing) + + waitFor { visibleTexts().size > 3 } + assertEquals(0, countText(str(Res.string.empty_notes))) + assertBodyNotEmpty(minTexts = 4) + } + + @Test + fun whileLoadingNeitherEmptyNorErrorIsShown() { + val slow = object : NotesRepository by TestNotesRepository() { + override fun getAllNotesWithTag(): Flow> = MutableSharedFlow() + } + show(slow) + + rule.onNodeWithText(str(Res.string.search_placeholder)).assertExists() + assertEquals(0, countText(str(Res.string.empty_notes))) + } + + @Test + fun longPressOpensTheNoteActions() { + app.withNotes(note(1, "Groceries")) + show() + waitForText("Groceries") + + rule.onNode(hasText("Groceries") and hasClickAction()).performTouchInput { longClick() } + + waitForText(str(Res.string.duplicate)) + rule.onNodeWithText(str(Res.string.edit)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.delete)).assertIsDisplayed() + } + + @Test + fun deletingFromTheSheetMovesTheNoteToTheTrash() { + app.withNotes(note(1, "Groceries")) + show() + waitForText("Groceries") + rule.onNode(hasText("Groceries") and hasClickAction()).performTouchInput { longClick() } + waitForText(str(Res.string.delete)) + + rule.onNodeWithText(str(Res.string.delete)).performClick() + rule.onNodeWithText(str(Res.string.tap_again_to_delete)).assertIsDisplayed() + assertEquals(emptyList(), app.notes.updatedNotes) + rule.onNodeWithText(str(Res.string.tap_again_to_delete)).performClick() + + waitFor(describe = { "${visibleTexts().map(::textOf)} ${app.notes.updatedNotes}" }) { + app.notes.updatedNotes.any { it.id == 1L && it.isTrash } + } + } + + @Test + fun editFromTheSheetOpensTheEditor() { + app.withNotes(note(7, "Groceries")) + show() + waitForText("Groceries") + rule.onNode(hasText("Groceries") and hasClickAction()).performTouchInput { longClick() } + waitForText(str(Res.string.edit)) + + rule.onNodeWithText(str(Res.string.edit)).performClick() + + waitFor { currentRoute() == TestRoutes.EDIT_NOTE } + assertEquals(7L, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getLong("idNote") }) + } + + @Test + fun tappingANoteOpensIt() { + app.withNotes(note(3, "Groceries")) + show() + waitForText("Groceries") + + rule.onNode(hasText("Groceries") and hasClickAction()).performClick() + + waitFor { currentRoute() == TestRoutes.EDIT_NOTE } + assertEquals(3L, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getLong("idNote") }) + } + + @Test + fun theFabStartsANewNote() { + show() + waitForText(str(Res.string.empty_notes)) + + rule.onNodeWithText(str(Res.string.note_add), useUnmergedTree = true).performClick() + + waitFor { currentRoute() == TestRoutes.EDIT_NOTE } + assertEquals(-1L, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getLong("idNote") }) + } + + @Test + fun searchShowsMatchesAndSaysWhenNothingMatches() { + app.withNotes(note(1, "Groceries", "milk"), note(2, "Trip", "tickets")) + show() + waitForText("Trip") + + rule.onNode(hasSetTextAction()).performTextInput("zzz") + + waitForText(str(Res.string.search_nothing_matches, "zzz")) + rule.onNodeWithContentDescription(str(Res.string.search_clear)).performClick() + rule.onNode(hasSetTextAction()).performTextInput("tick") + waitFor(describe = { + "${visibleTexts().map(::textOf)}" + }) { countText("Groceries") == 0 && countText("Trip") > 0 } + } + + @Test + fun withNoOpenTasksTheCardOffersANewOne() { + show() + + waitForText(str(Res.string.home_tasks_none_open), ignoreCase = true) + rule.onNodeWithText(str(Res.string.task_add)).performClick() + + waitFor { currentRoute() == TestRoutes.TASKS } + assertEquals(true, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getBoolean(TASKS_ADD_ARG) }) + } + + @Test + fun openTasksArePreviewedOnTheCard() { + app.withTasks(Task(id = 1, title = "Pay rent", priority = 2)) + show() + + waitForText("Pay rent") + rule.onNodeWithText(str(Res.string.task_filter_all)).assertIsDisplayed() + } + + @Test + fun theTagChipsFilterAndAnEmptyTagOffersShowAll() { + val work = Tag(id = 5, nameTag = "Work") + app.withTags(work) + app.withNotes(note(1, "Groceries")) + show() + waitForText("Groceries") + + rule.onNode(hasText("Work") and hasClickAction()).performClick() + + waitForText(str(Res.string.empty_notes_tagged, "Work")) + rule.onNodeWithText(str(Res.string.show_all_notes)).performClick() + waitForText("Groceries") + } + + @Test + fun theDrawerLeadsToEverySection() { + show() + waitForText(str(Res.string.empty_notes)) + + rule.onNodeWithContentDescription(str(Res.string.open_menu)).performClick() + + waitForText(str(Res.string.main_drawer_trash)) + listOf(Res.string.main_drawer_tasks, Res.string.main_drawer_tags, Res.string.main_drawer_faq, Res.string.about) + .forEach { rule.onNodeWithText(str(it)).assertExists() } + rule.onNodeWithText(str(Res.string.main_drawer_trash)).performClick() + waitFor { currentRoute() == NavRoutes.TrashRoute.name } + } + + @Test + fun theDrawerOffersArchiveAndImportAndLeadsToThem() { + show() + waitForText(str(Res.string.empty_notes)) + + rule.onNodeWithContentDescription(str(Res.string.open_menu)).performClick() + waitForText(str(Res.string.main_drawer_archive)) + rule.onNodeWithText(str(Res.string.import_title)).assertExists() + rule.onNodeWithText(str(Res.string.main_drawer_archive)).performClick() + + waitFor { currentRoute() == NavRoutes.ArchiveRoute.name } + } + + @Test + fun theDrawerImportOpensTheImportScreen() { + show() + waitForText(str(Res.string.empty_notes)) + + rule.onNodeWithContentDescription(str(Res.string.open_menu)).performClick() + waitForText(str(Res.string.import_title)) + rule.onNodeWithText(str(Res.string.import_title)).performClick() + + waitFor { currentRoute() == NavRoutes.ImportNotesRoute.name } + } + + @Test + fun theDrawerShowsSupportAsAButtonWhereDonationsAreAllowed() { + appInfo = StoreBuild(rating = false, donations = true) + show() + waitForText(str(Res.string.empty_notes)) + + rule.onNodeWithContentDescription(str(Res.string.open_menu)).performClick() + waitForText(str(Res.string.main_drawer_faq)) + + rule.onAllNodesWithText(str(Res.string.main_drawer_support)).assertCountEquals(1)[0].assertHasClickAction() + } + + @Test + fun theDrawerHasNoSupportEntryWhereDonationsAreNotAllowed() { + appInfo = StoreBuild(rating = true, donations = false) + show() + waitForText(str(Res.string.empty_notes)) + + rule.onNodeWithContentDescription(str(Res.string.open_menu)).performClick() + waitForText(str(Res.string.main_drawer_faq)) + + rule.onAllNodesWithText(str(Res.string.main_drawer_support)).assertCountEquals(0) + } + + @Test + fun theSettingsButtonOpensSettings() { + show() + + rule.onNodeWithContentDescription(str(Res.string.main_drawer_settings)).performClick() + + waitFor { currentRoute() == NavRoutes.SettingsRoute.name } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt new file mode 100644 index 00000000..d4c8f6d4 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt @@ -0,0 +1,142 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.security_previews_title +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.domain.model.NoteListItem +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.presentation.components.tiles.LocalShowNotePreviews +import com.pasich.encly.presentation.components.tiles.NoteItem +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.MainRootScreen +import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** "Show note previews": off, a card is the title, tag and date; search still reads the whole note. */ +class NotePreviewsTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private fun note(id: Long, title: String, text: String) = Note( + id = id, + title = title, + value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow(text)))), + date = 1_700_000_000_000 + id, + ) + + private fun showCard(show: Boolean) { + val home = Tag(id = 1, nameTag = "Home") + val item = NoteListItem.of(NoteWithTags(note(1, "Groceries", "milk and bread"), listOf(home))) + setScreen { + CompositionLocalProvider(LocalShowNotePreviews provides show) { + NoteItem(onItemClick = {}, onItemLongClick = {}, item = item) + } + } + } + + @Test + fun aCardNamesTwoTagsAndCountsTheRest() { + val tags = listOf( + Tag(id = 1, nameTag = "Home"), + Tag(id = 2, nameTag = "Work", position = 1), + Tag(id = 3, nameTag = "Trips", position = 2), + ) + val item = NoteListItem.of(NoteWithTags(note(1, "Groceries", "milk"), tags)) + setScreen { NoteItem(onItemClick = {}, onItemLongClick = {}, item = item) } + + rule.onNodeWithText("Home, Work").assertIsDisplayed() + rule.onNodeWithText("+1").assertIsDisplayed() + assertEquals(0, rule.onAllNodesWithText("Trips").fetchSemanticsNodes().size) + } + + @Test + fun aCardShowsTheStartOfTheTextByDefault() { + showCard(show = true) + + rule.onNodeWithText("Groceries").assertIsDisplayed() + rule.onNodeWithText("milk and bread").assertIsDisplayed() + } + + @Test + fun withPreviewsOffACardIsTheTitleAndTheTag() { + showCard(show = false) + + rule.onNodeWithText("Groceries").assertIsDisplayed() + rule.onNodeWithText("Home").assertIsDisplayed() + assertEquals(0, countText("milk and bread")) + } + + @Test + fun aDescriptionIsAPreviewToo() { + val summarized = note(1, "Plan", "body").copy(description = "A short summary") + val item = NoteListItem.of(NoteWithTags(summarized, emptyList())) + setScreen { + CompositionLocalProvider(LocalShowNotePreviews provides false) { + NoteItem(onItemClick = {}, onItemLongClick = {}, item = item) + } + } + + rule.onNodeWithText("Plan").assertIsDisplayed() + assertEquals(0, countText("A short summary")) + } + + @Test + fun theTrashAndAnyBareNoteHidesItsTextToo() { + setScreen { + CompositionLocalProvider(LocalShowNotePreviews provides false) { + val trashed = note(1, "Old", "secret text") + NoteItem(onItemClick = {}, onItemLongClick = {}, itemNote = trashed, dimmed = true) + } + } + + rule.onNodeWithText("Old").assertIsDisplayed() + assertEquals(0, countText("secret text")) + } + + @Test + fun theHomeListHidesTheTextButSearchStillFindsIt() { + app.withNotes(note(1, "Groceries", "milk"), note(2, "Trip", "tickets")) + setNavScreen(viewModels(*app.home()), route = NavRoutes.HomeRoute.name) { nav -> + CompositionLocalProvider(LocalShowNotePreviews provides false) { MainRootScreen(nav) } + } + waitForText("Trip") + assertEquals(0, countText("tickets")) + + rule.onNode(hasSetTextAction()).performTextInput("tick") + + waitFor { countText("Groceries") == 0 && countText("Trip") > 0 } + assertEquals(0, countText("tickets")) + } + + @Test + fun theSecuritySwitchTurnsPreviewsOffAndOn() { + setNavScreen( + viewModels(app.securitySettings(), app.notePreviewsVm(), app.toolbarVm(), app.backup()), + route = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> SecuritySettingsScreen(nav) } + val title = str(Res.string.security_previews_title) + waitForText(title) + assertTrue(app.notePreviews.show.value) + + rule.onNode(isToggleable() and hasText(title)).performScrollTo().performClick() + waitFor { !app.notePreviews.show.value } + + rule.onNode(isToggleable() and hasText(title)).performScrollTo().performClick() + waitFor { app.notePreviews.show.value } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/OnboardingScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/OnboardingScreenTest.kt new file mode 100644 index 00000000..9a2a5561 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/OnboardingScreenTest.kt @@ -0,0 +1,228 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.done +import com.pasich.encly.core.resources.language_dialog_title +import com.pasich.encly.core.resources.language_name_uk +import com.pasich.encly.core.resources.onboarding_fact_offline_title +import com.pasich.encly.core.resources.onboarding_get_started +import com.pasich.encly.core.resources.onboarding_have_backup +import com.pasich.encly.core.resources.onboarding_phrase_done +import com.pasich.encly.core.resources.onboarding_phrase_title +import com.pasich.encly.core.resources.onboarding_pin_again_title +import com.pasich.encly.core.resources.onboarding_pin_title +import com.pasich.encly.core.resources.onboarding_ready_no_phrase_title +import com.pasich.encly.core.resources.onboarding_ready_phrase_title +import com.pasich.encly.core.resources.onboarding_ready_title +import com.pasich.encly.core.resources.onboarding_recovery_create +import com.pasich.encly.core.resources.onboarding_recovery_skip +import com.pasich.encly.core.resources.onboarding_recovery_title +import com.pasich.encly.core.resources.onboarding_restore_title +import com.pasich.encly.core.resources.onboarding_verify_confirm +import com.pasich.encly.core.resources.onboarding_verify_title +import com.pasich.encly.core.resources.onboarding_verify_wrong +import com.pasich.encly.core.resources.onboarding_welcome_headline +import com.pasich.encly.core.resources.restore +import com.pasich.encly.core.security.Mnemonic +import com.pasich.encly.data.model.Note +import com.pasich.encly.presentation.screen.onboarding.OnboardingScreen +import com.pasich.encly.presentation.viewmodel.OnboardingStep +import com.pasich.encly.presentation.viewmodel.OnboardingViewModel +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.StringResource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OnboardingScreenTest : ComposeScreenTest() { + private val words = String(Mnemonic.generate()) + private val app by lazy { TestApp().withWorkingVault(words) } + private lateinit var onboarding: OnboardingViewModel + private var completed = false + + private fun show() { + onboarding = app.onboarding() + setScreen(viewModels(onboarding, app.authSetup())) { OnboardingScreen(onComplete = { completed = true }) } + } + + private fun primary(id: StringResource) = rule.onNodeWithText(str(id)) + + private fun choosePin(pin: String = "246810") { + waitForText(str(Res.string.onboarding_pin_title)) + primary(Res.string.action_continue).assertIsNotEnabled() + typePin(pin) + primary(Res.string.action_continue).assertIsEnabled().performClick() + waitForText(str(Res.string.onboarding_pin_again_title)) + typePin(pin) + primary(Res.string.action_continue).performClick() + } + + @Test + fun welcomeShowsTheFactsAndBothWaysIn() { + show() + + rule.onNodeWithText(str(Res.string.onboarding_welcome_headline)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.onboarding_fact_offline_title)).assertIsDisplayed() + primary(Res.string.onboarding_get_started).assertIsEnabled() + primary(Res.string.onboarding_have_backup).assertIsEnabled() + assertBodyNotEmpty(minTexts = 8) + } + + @Test + fun theLanguagePillOpensTheLanguageChoice() { + show() + + rule.onNodeWithContentDescription(str(Res.string.language_dialog_title), substring = true).performClick() + + waitForText(str(Res.string.language_name_uk)) + rule.onNodeWithText(str(Res.string.done)).assertIsDisplayed() + } + + @Test + fun theWholeCreatePathEndsReady() { + show() + primary(Res.string.onboarding_get_started).performClick() + choosePin() + + waitForText(str(Res.string.onboarding_recovery_title)) + primary(Res.string.onboarding_recovery_create).performClick() + + waitForText(str(Res.string.onboarding_phrase_title)) + words.split(' ').forEach { word -> assertTrue(word, countText(word) > 0) } + primary(Res.string.onboarding_phrase_done).performClick() + + waitForText(str(Res.string.onboarding_verify_title)) + primary(Res.string.onboarding_verify_confirm).assertIsNotEnabled() + val fields = rule.onAllNodes(hasSetTextAction()) + onboarding.uiState.value.verificationWords.forEachIndexed { position, (_, word) -> + fields[position].performTextInput(word) + } + primary(Res.string.onboarding_verify_confirm).assertIsEnabled().performClick() + + waitForText(str(Res.string.onboarding_ready_title), timeoutMillis = LONG_WAIT_MS) + rule.onNodeWithText(str(Res.string.onboarding_ready_phrase_title)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 5) + } + + @Test + fun aWrongVerificationWordIsFlagged() { + show() + primary(Res.string.onboarding_get_started).performClick() + choosePin() + waitForText(str(Res.string.onboarding_recovery_title)) + primary(Res.string.onboarding_recovery_create).performClick() + waitForText(str(Res.string.onboarding_phrase_title)) + primary(Res.string.onboarding_phrase_done).performClick() + waitForText(str(Res.string.onboarding_verify_title)) + + val (index, word) = onboarding.uiState.value.verificationWords.first() + rule.onAllNodes(hasSetTextAction())[0].performTextInput("z".repeat(word.length)) + + rule.onNodeWithText(str(Res.string.onboarding_verify_wrong, index + 1)).assertIsDisplayed() + primary(Res.string.onboarding_verify_confirm).assertIsNotEnabled() + } + + @Test + fun skippingThePhraseEndsReadyWithoutOne() { + show() + primary(Res.string.onboarding_get_started).performClick() + choosePin() + waitForText(str(Res.string.onboarding_recovery_skip)) + + primary(Res.string.onboarding_recovery_skip).performClick() + + waitForText(str(Res.string.onboarding_ready_no_phrase_title), timeoutMillis = LONG_WAIT_MS) + } + + @Test + fun mismatchedPinsStartThePinAgain() { + show() + primary(Res.string.onboarding_get_started).performClick() + waitForText(str(Res.string.onboarding_pin_title)) + typePin("111111") + primary(Res.string.action_continue).performClick() + waitForText(str(Res.string.onboarding_pin_again_title)) + + typePin("222222") + primary(Res.string.action_continue).performClick() + + waitForText(str(Res.string.onboarding_pin_title)) + } + + @Test + fun restoreHasTwelveCellsAndWaitsForAFileAndAValidPhrase() { + show() + primary(Res.string.onboarding_have_backup).performClick() + + waitForText(str(Res.string.onboarding_restore_title)) + (1..12).forEach { phraseCell(it).assertExists() } + primary(Res.string.restore).assertIsNotEnabled() + + phraseCell(1).performTextReplacement(words) + assertEquals(words.split(' '), phraseCells()) + primary(Res.string.restore).assertIsNotEnabled() + + pickBackup() + rule.onNodeWithText("notes.enclybak").assertIsDisplayed() + primary(Res.string.restore).assertIsEnabled() + } + + @Test + fun restoreTypedWordByWordMovesAcrossTheCells() { + show() + primary(Res.string.onboarding_have_backup).performClick() + waitForText(str(Res.string.onboarding_restore_title)) + + phraseCell(1).performTextInput("${words.split(' ')[0]} ") + rule.waitForIdle() + + assertEquals(words.split(' ')[0], phraseCells()[0]) + assertTrue(phraseCells().drop(1).all(String::isEmpty)) + } + + @Test + fun aRestoredBackupGoesOnToThePin() { + show() + primary(Res.string.onboarding_have_backup).performClick() + waitForText(str(Res.string.onboarding_restore_title)) + pickBackup() + phraseCell(1).performTextReplacement(words) + + primary(Res.string.restore).performClick() + + waitFor(LONG_WAIT_MS) { onboarding.uiState.value.step == OnboardingStep.PIN } + waitForText(str(Res.string.onboarding_pin_title)) + } + + @Test + fun backFromThePinReturnsToWelcome() { + show() + primary(Res.string.onboarding_get_started).performClick() + waitForText(str(Res.string.onboarding_pin_title)) + + rule.runOnIdle { onboarding.back() } + + waitForText(str(Res.string.onboarding_welcome_headline)) + assertTrue(!completed) + } + + private fun pickBackup() { + val file = runBlocking { app.backupFile(words, Note(title = "Plan", value = "[]", uid = "n-plan")) } + rule.runOnIdle { onboarding.onRestoreFilePicked(app.document("notes.enclybak", file)) } + waitFor(describe = { "${onboarding.uiState.value}" }) { onboarding.uiState.value.restoreFileReady } + } + + private companion object { + const val LONG_WAIT_MS = 15_000L + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/PinKeypadTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinKeypadTest.kt similarity index 77% rename from app/src/test/java/com/pasich/encly/ui/screens/PinKeypadTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinKeypadTest.kt index 49bf1f0a..379c8c9f 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/PinKeypadTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinKeypadTest.kt @@ -5,8 +5,10 @@ import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput -import com.pasich.encly.R -import com.pasich.encly.presentation.designsystem.PIN_LENGTH +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.pin_delete_digit +import com.pasich.encly.core.resources.pin_digits_count +import com.pasich.encly.core.security.MAX_PIN_LENGTH import com.pasich.encly.presentation.designsystem.PinKeypad import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.screen.LockScreen @@ -16,7 +18,7 @@ import org.mockito.Mockito.`when` class PinKeypadTest : ComposeScreenTest() { - private fun delete() = rule.onNodeWithContentDescription(str(R.string.pin_delete_digit)) + private fun delete() = rule.onNodeWithContentDescription(str(Res.string.pin_delete_digit)) @Test fun aTapOnDeleteRemovesOneDigit() { @@ -41,13 +43,13 @@ class PinKeypadTest : ComposeScreenTest() { } @Test - fun withoutAClearActionALongPressDeletesAWholePin() { + fun withoutAClearActionALongPressDeletesTheLongestPin() { var deleted = 0 setScreen { PinKeypad(onDigit = {}, onBackspace = { deleted++ }) } delete().performTouchInput { longClick() } - assertEquals(PIN_LENGTH, deleted) + assertEquals(MAX_PIN_LENGTH, deleted) } @Test @@ -62,15 +64,15 @@ class PinKeypadTest : ComposeScreenTest() { @Test fun onTheLockScreenALongPressOnDeleteEmptiesTheDots() { - val app = TestApp(context) + val app = TestApp() `when`(app.security.hasRecoverySeed()).thenReturn(true) setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> LockScreen(nav) } typePin("123") - rule.onNodeWithContentDescription(str(R.string.pin_digits_entered, 3, PIN_LENGTH)).assertIsDisplayed() + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 3)).assertIsDisplayed() delete().performTouchInput { longClick() } rule.waitForIdle() - rule.onNodeWithContentDescription(str(R.string.pin_digits_entered, 0, PIN_LENGTH)).assertIsDisplayed() + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 0)).assertIsDisplayed() } } diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinLengthScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinLengthScreensTest.kt new file mode 100644 index 00000000..9c49fe42 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinLengthScreensTest.kt @@ -0,0 +1,146 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_recover_access +import com.pasich.encly.core.resources.lock_subtitle +import com.pasich.encly.core.resources.lock_unlock +import com.pasich.encly.core.resources.lock_use_recovery_phrase +import com.pasich.encly.core.resources.pin_digits_count +import com.pasich.encly.core.resources.recovery_choice_body_pin +import com.pasich.encly.core.resources.recovery_keep_pin +import com.pasich.encly.core.resources.recovery_set_new_pin +import com.pasich.encly.core.security.RecoveryStage +import com.pasich.encly.core.security.VaultUnlockResult +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.LockScreen +import com.pasich.encly.presentation.screen.RecoveryChoiceScreen +import com.pasich.encly.testutil.anyCharArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +/** + * The PIN of 6 to 12 digits and the choice after the recovery phrase. The PIN screens never submit by themselves; + * a button does. + */ +@OptIn(ExperimentalTestApi::class) +class PinLengthScreensTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private fun showLock() { + `when`(app.security.hasRecoverySeed()).thenReturn(true) + setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> LockScreen(nav) } + } + + private fun anyChars(): CharArray = org.mockito.ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0) + + // --- PIN length on the lock screen --------------------------------------------------------- + + @Test + fun theUnlockButtonWaitsForSixDigitsAndNothingIsSentByItself() { + `when`(app.security.unlockWithPin(anyCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + showLock() + rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsNotEnabled() + + typePin("12345") + rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsNotEnabled() + + typePin("6") + rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsEnabled() + rule.waitForIdle() + verify(app.security, never()).unlockWithPin(anyCharArray()) + assertEquals(NavRoutes.LockRoute.name, currentRoute()) + } + + @Test + fun aPinOfEightDigitsIsSentWhole() { + var sent = "" + `when`(app.security.unlockWithPin(anyCharArray())).thenAnswer { + sent = String(it.getArgument(0)) + VaultUnlockResult.SUCCESS + } + showLock() + + enterPin("12345678") + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + assertEquals("12345678", sent) + } + + @Test + fun theDotsGrowPastSixAndStopAtTwelve() { + showLock() + + typePin("1234567") + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 7)).assertExists() + + typePin("890123456") + + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 12)).assertExists() + } + + @Test + fun theSubtitleDoesNotSayHowLongThePinIs() { + showLock() + + rule.onNodeWithText(str(Res.string.lock_subtitle)).assertIsDisplayed() + assertTrue(str(Res.string.lock_subtitle).none(Char::isDigit)) + } + + // --- after the recovery phrase --------------------------------------------------------------------- + + @Test + fun aWorkingPinOffersTheChoiceAfterTheRecoveryPhrase() { + `when`(app.security.unlockWithSeed(anyChars())).thenReturn(VaultUnlockResult.SUCCESS) + `when`(app.security.recoveryStage()).thenReturn(RecoveryStage.CHOOSE) + showLock() + rule.onNodeWithText(str(Res.string.lock_use_recovery_phrase)).performClick() + phraseCell(1).performTextReplacement(PHRASE) + + rule.onNodeWithText(str(Res.string.lock_recover_access)).performClick() + + waitFor { currentRoute() == NavRoutes.RecoveryChoiceRoute.name } + } + + @Test + fun keepingThePinGoesStraightIntoTheApp() { + `when`(app.security.recoveryStage()).thenReturn(RecoveryStage.CHOOSE) + `when`(app.security.keepUnlockMethodsAfterRecovery()).thenReturn(true) + setNavScreen( + viewModels(app.lock()), + route = NavRoutes.RecoveryChoiceRoute.name, + ) { nav -> RecoveryChoiceScreen(nav) } + + rule.onNodeWithText(str(Res.string.recovery_choice_body_pin)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.recovery_set_new_pin)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.recovery_keep_pin)).performClick() + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + verify(app.security).keepUnlockMethodsAfterRecovery() + } + + @Test + fun choosingANewPinOpensThePinResetWithoutKeeping() { + `when`(app.security.recoveryStage()).thenReturn(RecoveryStage.CHOOSE) + setNavScreen( + viewModels(app.lock()), + route = NavRoutes.RecoveryChoiceRoute.name, + ) { nav -> RecoveryChoiceScreen(nav) } + + rule.onNodeWithText(str(Res.string.recovery_set_new_pin)).performClick() + + waitFor { currentRoute() == NavRoutes.PinCodeConfig.name } + verify(app.security, never()).keepUnlockMethodsAfterRecovery() + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PointerMenusTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PointerMenusTest.kt new file mode 100644 index 00000000..734dd02a --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PointerMenusTest.kt @@ -0,0 +1,192 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.rightClick +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.duplicate +import com.pasich.encly.core.resources.note_archive +import com.pasich.encly.core.resources.note_color_title +import com.pasich.encly.core.resources.note_open +import com.pasich.encly.core.resources.note_open_in_new_tab +import com.pasich.encly.core.resources.note_pin +import com.pasich.encly.core.resources.note_unarchive +import com.pasich.encly.core.resources.note_unpin +import com.pasich.encly.core.resources.subtask_add +import com.pasich.encly.core.resources.subtask_first_step +import com.pasich.encly.core.resources.subtasks_show +import com.pasich.encly.core.resources.swipe_choice_delete +import com.pasich.encly.core.resources.task_edit_placeholder +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Task +import com.pasich.encly.domain.model.NoteColor +import com.pasich.encly.presentation.components.tiles.NoteContextActions +import com.pasich.encly.presentation.components.tiles.NoteItem +import com.pasich.encly.presentation.dialogs.NoteAction +import com.pasich.encly.presentation.screen.TasksScreen +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * What a mouse adds to the lists, with the touch behaviour as it was: a right click on a note or a task opens + * a menu of the actions the long-press sheet and TalkBack have, and a finger's tap and long press open none. + */ +@OptIn(ExperimentalTestApi::class) +class PointerMenusTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private val actions = mutableListOf() + private var opened = 0 + private var longPressed = 0 + private var newTab = 0 + + private fun showNote( + note: Note = Note(id = 1, title = "Plan"), + menu: Boolean = true, + tabs: Boolean = false, + dimmed: Boolean = false, + ) { + setScreen { + NoteItem( + onItemClick = { opened++ }, + onItemLongClick = { longPressed++ }, + itemNote = note, + dimmed = dimmed, + contextActions = NoteContextActions( + onAction = { actions += it }, + onOpenInNewTab = if (tabs) ({ newTab++ }) else null, + ).takeIf { menu }, + ) + } + } + + private fun rightClickNote() { + rule.onNodeWithText("Plan").performMouseInput { rightClick() } + rule.waitForIdle() + } + + @Test + fun aRightClickOnANoteOffersItsActionsAndRunsThePickedOne() { + showNote() + + rightClickNote() + rule.onNodeWithText(str(Res.string.note_open)).assertExists() + rule.onNodeWithText(str(Res.string.note_archive)).assertExists() + rule.onNodeWithText(str(Res.string.duplicate)).assertExists() + rule.onNodeWithText(str(Res.string.swipe_choice_delete)).assertExists() + rule.onNodeWithText(str(Res.string.note_open_in_new_tab)).assertDoesNotExist() + rule.onNodeWithText(str(Res.string.note_pin)).performClick() + rule.waitForIdle() + + assertEquals(listOf(NoteAction.Pin(true)), actions) + assertEquals("the card is not opened by the right click", 0, opened) + } + + @Test + fun everyEntryIsTheSheetsAction() { + showNote(Note(id = 1, title = "Plan"), tabs = true) + + val picks = listOf( + str(Res.string.note_open), + str(Res.string.note_open_in_new_tab), + str(Res.string.note_archive), + str(Res.string.note_color_title) + ": " + str(NoteColor.RED.labelRes), + str(Res.string.duplicate), + str(Res.string.swipe_choice_delete), + ) + picks.forEach { label -> + rightClickNote() + rule.onNodeWithText(label).performClick() + rule.waitForIdle() + } + + assertEquals( + listOf( + NoteAction.Edit, + NoteAction.Archive(true), + NoteAction.ChangeColor(NoteColor.RED), + NoteAction.Duplicate, + NoteAction.Delete, + ), + actions, + ) + assertEquals(1, newTab) + } + + @Test + fun aPinnedNoteOffersUnpinAndAnArchivedOneUnarchiveAndNoPin() { + showNote(Note(id = 1, title = "Plan", isPinned = true)) + rightClickNote() + rule.onNodeWithText(str(Res.string.note_unpin)).assertExists() + } + + @Test + fun anArchivedNoteOffersUnarchiveAndNoPin() { + showNote(Note(id = 1, title = "Plan", isArchived = true)) + + rightClickNote() + + rule.onNodeWithText(str(Res.string.note_unarchive)).assertExists() + rule.onNodeWithText(str(Res.string.note_pin)).assertDoesNotExist() + rule.onNodeWithText(str(Res.string.note_unpin)).assertDoesNotExist() + } + + @Test + fun withoutActionsOrInTheTrashARightClickOpensNothing() { + showNote(menu = false) + rightClickNote() + rule.onNodeWithText(str(Res.string.note_open)).assertDoesNotExist() + } + + @Test + fun theTrashHasNoMenu() { + showNote(dimmed = true) + + rightClickNote() + + rule.onNodeWithText(str(Res.string.note_open)).assertDoesNotExist() + } + + @Test + fun aFingerStillOpensAndLongPressesTheNoteAndSeesNoMenu() { + showNote() + + rule.onNodeWithText("Plan").performClick() + rule.onNodeWithText("Plan").performTouchInput { longClick() } + rule.waitForIdle() + + assertEquals(1, opened) + assertEquals(1, longPressed) + rule.onNodeWithText(str(Res.string.note_open)).assertDoesNotExist() + assertTrue(actions.isEmpty()) + } + + // --- tasks --------------------------------------------------------------------------------- + + private fun showTasks() { + app.withTasks(Task(id = 1, title = "Call mum")) + setNavScreen(viewModels(app.tasksVm()), route = TestRoutes.TASKS) { nav -> + TasksScreen(nav, openNewTask = false) + } + waitForText("Call mum") + } + + @Test + fun aRightClickOnATaskOffersWhatTalkBackHas() { + showTasks() + + rule.onNodeWithText("Call mum").performMouseInput { rightClick() } + rule.waitForIdle() + + rule.onNodeWithText(str(Res.string.task_edit_placeholder)).assertExists() + rule.onNodeWithText(str(Res.string.subtask_add)).assertExists() + // Picking the first one opens the task's tree, as a tap on the row does. + rule.onNodeWithText(str(Res.string.subtasks_show)).performClick() + waitForText(str(Res.string.subtask_first_step)) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/RtlScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/RtlScreensTest.kt new file mode 100644 index 00000000..9aa4723c --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/RtlScreensTest.kt @@ -0,0 +1,201 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.empty_notes +import com.pasich.encly.core.resources.note_add +import com.pasich.encly.core.resources.open_menu +import com.pasich.encly.core.resources.pin_delete_digit +import com.pasich.encly.core.resources.settings_appearance +import com.pasich.encly.core.resources.settings_general +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.WordGrid +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.LockScreen +import com.pasich.encly.presentation.screen.MainRootScreen +import com.pasich.encly.presentation.screen.settings.SettingsScreen +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.`when` + +/** + * The key screens in a right-to-left layout. Each test lays the screen out left to right, notes where + * things are, flips the same composition to right to left and expects the layout to be its mirror image + * (what sits at the start sits at the start of the other side), except what must not move: the PIN keypad + * and the recovery words. + */ +class RtlScreensTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private var direction by mutableStateOf(LayoutDirection.Ltr) + + private fun flip() { + direction = LayoutDirection.Rtl + rule.waitForIdle() + } + + private fun bounds(node: SemanticsNodeInteraction): Rect = node.fetchSemanticsNode().boundsInRoot + + private fun windowWidth(): Float = bounds(rule.onRoot()).width + + /** [find] sits on the mirror image of where it was, once the layout is flipped. */ + private fun assertMirrored(name: String, find: () -> SemanticsNodeInteraction) { + val before = bounds(find()) + val width = windowWidth() + flip() + val after = bounds(find()) + assertEquals("$name: left edge", width - before.right, after.left, TOLERANCE) + assertEquals("$name: width", before.width, after.width, TOLERANCE) + assertEquals("$name: top", before.top, after.top, TOLERANCE) + direction = LayoutDirection.Ltr + rule.waitForIdle() + } + + /** [find] stays exactly where it was, once the layout is flipped. */ + private fun assertStays(name: String, find: () -> SemanticsNodeInteraction) { + val before = bounds(find()) + flip() + val after = bounds(find()) + assertEquals("$name: left edge", before.left, after.left, TOLERANCE) + assertEquals("$name: top", before.top, after.top, TOLERANCE) + direction = LayoutDirection.Ltr + rule.waitForIdle() + } + + // --- the notes list -------------------------------------------------------------------------- + + private fun showHome() { + setNavScreen(viewModels(*app.home(app.noteList(app.notes))), route = NavRoutes.HomeRoute.name) { nav -> + CompositionLocalProvider(LocalLayoutDirection provides direction) { MainRootScreen(nav) } + } + waitForText(str(Res.string.empty_notes)) + } + + @Test + fun theNotesListMirrorsItsMenuButtonAndItsNewNoteButton() { + showHome() + + assertMirrored("menu button") { rule.onNodeWithContentDescription(str(Res.string.open_menu)) } + assertMirrored("new note button") { + rule.onNodeWithText(str(Res.string.note_add), useUnmergedTree = true) + } + } + + @Test + fun theMenuButtonIsOnTheLeftInALeftToRightLayoutAndOnTheRightInARightToLeftOne() { + showHome() + val window = windowWidth() + assertTrue(bounds(rule.onNodeWithContentDescription(str(Res.string.open_menu))).center.x < window / 2) + + flip() + + assertTrue(bounds(rule.onNodeWithContentDescription(str(Res.string.open_menu))).center.x > window / 2) + } + + // --- settings ----------------------------------------------------------------------------------- + + @Test + fun settingsMirrorTheirSectionsAndRows() { + setNavScreen(viewModels(app.settingsVm()), route = NavRoutes.SettingsRoute.name) { nav -> + CompositionLocalProvider(LocalLayoutDirection provides direction) { SettingsScreen(nav) } + } + + assertMirrored("section title") { + rule.onAllNodesWithText(str(Res.string.settings_general), ignoreCase = true).onFirst() + } + assertMirrored("row title") { + rule.onAllNodesWithText(str(Res.string.settings_appearance)).onFirst() + } + } + + // --- the lock screen ------------------------------------------------------------------------------ + + private fun showLock() { + `when`(app.security.hasRecoverySeed()).thenReturn(true) + setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> + CompositionLocalProvider(LocalLayoutDirection provides direction) { LockScreen(nav) } + } + } + + private fun key(digit: Int) = rule.onNode(hasText(digit.toString()) and hasClickAction()) + + @Test + fun thePinKeypadKeepsItsDigitsInPlaceInARightToLeftLayout() { + showLock() + + (0..9).forEach { digit -> assertStays("key $digit") { key(digit) } } + } + + @Test + fun thePinKeypadReadsOneTwoThreeFromLeftToRightAndDeleteSitsAtTheBottomRight() { + showLock() + flip() + + assertTrue(bounds(key(1)).center.x < bounds(key(2)).center.x) + assertTrue(bounds(key(2)).center.x < bounds(key(3)).center.x) + assertTrue(bounds(key(7)).center.y > bounds(key(1)).center.y) + val delete = bounds(rule.onNodeWithContentDescription(str(Res.string.pin_delete_digit))) + assertTrue(delete.center.x > bounds(key(0)).center.x) + assertEquals(bounds(key(0)).center.y, delete.center.y, TOLERANCE) + } + + // --- the recovery words ----------------------------------------------------------------------------- + + @Test + fun theRecoveryWordsKeepTheirGridInARightToLeftLayout() { + setScreen { + CompositionLocalProvider(LocalLayoutDirection provides direction) { + WordGrid(words = WORDS, hidden = false) + } + } + + WORDS.forEach { word -> assertStays(word) { rule.onNodeWithText(word) } } + flip() + assertTrue(bounds(rule.onNodeWithText(WORDS[0])).center.x < bounds(rule.onNodeWithText(WORDS[1])).center.x) + assertEquals( + bounds(rule.onNodeWithText(WORDS[0])).center.y, + bounds(rule.onNodeWithText(WORDS[1])).center.y, + TOLERANCE, + ) + } + + // --- icons ------------------------------------------------------------------------------------------------ + + @Test + fun theDirectionalIconsMirrorAndTheOthersDoNot() { + listOf( + EnclyIcons.Back, + EnclyIcons.Chevron, + EnclyIcons.Undo, + EnclyIcons.Redo, + EnclyIcons.Send, + EnclyIcons.ListBullet, + EnclyIcons.Checklist, + EnclyIcons.Quote, + EnclyIcons.External, + ).forEach { assertTrue(it.name, it.autoMirror) } + listOf(EnclyIcons.Plus, EnclyIcons.Check, EnclyIcons.Close, EnclyIcons.ArrowUp, EnclyIcons.ChevronDown) + .forEach { assertFalse(it.name, it.autoMirror) } + } + + private companion object { + const val TOLERANCE = 2f + val WORDS = listOf("abandon", "ability", "able", "about", "above", "absent") + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt new file mode 100644 index 00000000..e5fe7f08 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt @@ -0,0 +1,217 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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 com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.backup_reauth_title +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.security_encryption_active +import com.pasich.encly.core.resources.security_screenshots_title +import com.pasich.encly.core.resources.security_screenshots_warning_title +import com.pasich.encly.core.security.BiometricStatus +import com.pasich.encly.core.security.Mnemonic +import com.pasich.encly.platform.LocalScreenshotAllowance +import com.pasich.encly.presentation.designsystem.ProtectFromCapture +import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput +import com.pasich.encly.presentation.designsystem.RecoveryPhraseState +import com.pasich.encly.presentation.designsystem.WordGrid +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.LockScreen +import com.pasich.encly.presentation.screen.PinCodeConfigScreen +import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold +import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "Allow screenshots until the app locks": the switch on the Security page (warning, then the PIN, then on), + * where it is not offered, and the screens that stay protected while it is on. + */ +class ScreenshotAllowanceScreensTest : ComposeScreenTest() { + private val words = String(Mnemonic.generate()) + private val app by lazy { TestApp().withWorkingVault(words) } + private val switchTitle by lazy { str(Res.string.security_screenshots_title) } + + private fun showSecurity(offered: Boolean = true) { + app.withAuth(recoveryPhrase = true, biometric = BiometricStatus.UNAVAILABLE) + setNavScreen( + viewModels(app.securitySettings(), app.notePreviewsVm(), app.toolbarVm(), app.backup()), + route = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> + CompositionLocalProvider(LocalScreenshotAllowance provides app.screenshots.takeIf { offered }) { + SecuritySettingsScreen(nav) + } + } + waitForText(str(Res.string.security_encryption_active)) + } + + private fun switch() = rule.onNode(isToggleable() and hasText(switchTitle)) + + @Test + fun screenshotsAreOffByDefaultAndTheSwitchSaysSo() { + showSecurity() + + assertFalse(app.screenshots.allowed.value) + switch().assertExists() + } + + @Test + fun aHostThatDoesNotOfferItShowsNoSwitch() { + showSecurity(offered = false) + + assertEquals(0, countText(switchTitle)) + } + + @Test + fun turningItOnWarnsThenAsksForThePinAndOnlyThenAllows() { + showSecurity() + + switch().performScrollTo().performClick() + waitForText(str(Res.string.security_screenshots_warning_title)) + assertFalse(app.screenshots.allowed.value) + + rule.onNodeWithText(str(Res.string.action_continue)).performClick() + waitForText(str(Res.string.backup_reauth_title)) + assertFalse(app.screenshots.allowed.value) + + enterPin("123456") + waitFor(describe = { "allowed=${app.screenshots.allowed.value}" }) { app.screenshots.allowed.value } + } + + @Test + fun cancellingTheWarningOrThePinAllowsNothing() { + showSecurity() + + switch().performScrollTo().performClick() + waitForText(str(Res.string.security_screenshots_warning_title)) + rule.onNodeWithText(str(Res.string.cancel)).performClick() + rule.waitForIdle() + assertFalse(app.screenshots.allowed.value) + + switch().performScrollTo().performClick() + waitForText(str(Res.string.security_screenshots_warning_title)) + rule.onNodeWithText(str(Res.string.action_continue)).performClick() + waitForText(str(Res.string.backup_reauth_title)) + rule.onNodeWithText(str(Res.string.cancel)).performClick() + rule.waitForIdle() + assertFalse(app.screenshots.allowed.value) + } + + @Test + fun turningItOffNeedsNoPinAndEndsTheAllowance() { + app.screenshots.allow() + showSecurity() + + switch().performScrollTo().performClick() + + waitFor(describe = { "allowed=${app.screenshots.allowed.value}" }) { !app.screenshots.allowed.value } + assertEquals(0, countText(str(Res.string.backup_reauth_title))) + } + + // --- screens with a secret stay protected while screenshots are allowed ------------------- + + @Test + fun theLockScreenStaysProtectedWhileAllowed() { + app.screenshots.allow() + assertTrue(app.screenshots.capturable) + + setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> + CompositionLocalProvider(LocalScreenshotAllowance provides app.screenshots) { LockScreen(nav) } + } + + assertTrue(app.screenshots.allowed.value) + assertFalse(app.screenshots.capturable) + } + + @Test + fun pinEntryStaysProtectedWhileAllowedAndTheWindowOpensAgainWhenItIsGone() { + app.screenshots.allow() + var shown by mutableStateOf(true) + setScreen { + CompositionLocalProvider(LocalScreenshotAllowance provides app.screenshots) { + if (shown) PinEntryScaffold(title = "t", subtitle = "s") {} + } + } + assertFalse(app.screenshots.capturable) + + rule.runOnIdle { shown = false } + rule.waitForIdle() + + assertTrue(app.screenshots.capturable) + } + + @Test + fun thePinChangeScreenStaysProtectedWhileAllowed() { + app.screenshots.allow() + setNavScreen( + viewModels(app.securitySettings()), + route = NavRoutes.PinCodeConfig.name, + ) { nav -> + CompositionLocalProvider(LocalScreenshotAllowance provides app.screenshots) { PinCodeConfigScreen(nav) } + } + + assertFalse(app.screenshots.capturable) + } + + @Test + fun recoveryWordsStayProtectedWhileAllowedWhetherShownOrTyped() { + app.screenshots.allow() + var mode by mutableStateOf(Words.NONE) + setScreen { + CompositionLocalProvider(LocalScreenshotAllowance provides app.screenshots) { + when (mode) { + Words.SHOWN -> WordGrid(words = words.split(' '), hidden = false) + Words.TYPED -> RecoveryPhraseInput(RecoveryPhraseState()) + Words.NONE -> Unit + } + } + } + assertTrue(app.screenshots.capturable) + + rule.runOnIdle { mode = Words.SHOWN } + rule.waitForIdle() + assertFalse(app.screenshots.capturable) + + rule.runOnIdle { mode = Words.NONE } + rule.waitForIdle() + assertTrue(app.screenshots.capturable) + + rule.runOnIdle { mode = Words.TYPED } + rule.waitForIdle() + assertFalse(app.screenshots.capturable) + } + + @Test + fun theHoldIsReleasedWhenTheScreenLeavesEvenIfAskedTwice() { + app.screenshots.allow() + var count by mutableIntStateOf(2) + setScreen { + CompositionLocalProvider(LocalScreenshotAllowance provides app.screenshots) { + repeat(count) { key(it) { ProtectFromCapture() } } + } + } + assertFalse(app.screenshots.capturable) + + rule.runOnIdle { count = 1 } + rule.waitForIdle() + assertFalse(app.screenshots.capturable) + + rule.runOnIdle { count = 0 } + rule.waitForIdle() + assertTrue(app.screenshots.capturable) + } + + private enum class Words { NONE, SHOWN, TYPED } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt new file mode 100644 index 00000000..4f96da08 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt @@ -0,0 +1,610 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.auth_method_pin_title +import com.pasich.encly.core.resources.auto_lock_title +import com.pasich.encly.core.resources.backup_error_phrase_setup +import com.pasich.encly.core.resources.backup_needs_phrase_title +import com.pasich.encly.core.resources.backup_phrase_exists +import com.pasich.encly.core.resources.backup_phrase_written +import com.pasich.encly.core.resources.backup_reauth_title +import com.pasich.encly.core.resources.duress_pin_confirm_title +import com.pasich.encly.core.resources.duress_pin_current_subtitle +import com.pasich.encly.core.resources.duress_pin_failed +import com.pasich.encly.core.resources.duress_pin_intro +import com.pasich.encly.core.resources.duress_pin_new_title +import com.pasich.encly.core.resources.duress_pin_remove +import com.pasich.encly.core.resources.duress_pin_remove_confirm +import com.pasich.encly.core.resources.duress_pin_remove_confirm_body +import com.pasich.encly.core.resources.duress_pin_removed +import com.pasich.encly.core.resources.duress_pin_row_desc +import com.pasich.encly.core.resources.duress_pin_same_as_pin +import com.pasich.encly.core.resources.duress_pin_saved +import com.pasich.encly.core.resources.duress_pin_saved_kept +import com.pasich.encly.core.resources.duress_pin_set +import com.pasich.encly.core.resources.duress_pin_title +import com.pasich.encly.core.resources.onboarding_verify_label +import com.pasich.encly.core.resources.onboarding_verify_title +import com.pasich.encly.core.resources.pin_change_bar_title +import com.pasich.encly.core.resources.pin_change_confirm_title +import com.pasich.encly.core.resources.pin_change_current_title +import com.pasich.encly.core.resources.pin_changed +import com.pasich.encly.core.resources.pin_create_title +import com.pasich.encly.core.resources.pin_current_wrong +import com.pasich.encly.core.resources.pin_in_use +import com.pasich.encly.core.resources.pin_mismatch_retry +import com.pasich.encly.core.resources.pin_reset_subtitle +import com.pasich.encly.core.resources.pin_update_failed +import com.pasich.encly.core.resources.security_biometric_not_enrolled +import com.pasich.encly.core.resources.security_biometric_title +import com.pasich.encly.core.resources.security_biometric_unavailable +import com.pasich.encly.core.resources.security_encryption_active +import com.pasich.encly.core.resources.security_encryption_pin_plain +import com.pasich.encly.core.resources.security_encryption_seed_plain +import com.pasich.encly.core.resources.security_erase_desc +import com.pasich.encly.core.resources.security_erase_failed +import com.pasich.encly.core.resources.security_erase_title +import com.pasich.encly.core.resources.security_keyboard_strict_title +import com.pasich.encly.core.resources.security_protection_section +import com.pasich.encly.core.resources.security_recovery_missing +import com.pasich.encly.core.resources.security_recovery_replace_title +import com.pasich.encly.core.resources.security_recovery_status_set +import com.pasich.encly.core.resources.wipe_pin_backups +import com.pasich.encly.core.resources.wipe_pin_confirm_title +import com.pasich.encly.core.resources.wipe_pin_current_subtitle +import com.pasich.encly.core.resources.wipe_pin_failed +import com.pasich.encly.core.resources.wipe_pin_intro +import com.pasich.encly.core.resources.wipe_pin_new_title +import com.pasich.encly.core.resources.wipe_pin_remove +import com.pasich.encly.core.resources.wipe_pin_removed +import com.pasich.encly.core.resources.wipe_pin_row_desc +import com.pasich.encly.core.resources.wipe_pin_same_as_pin +import com.pasich.encly.core.resources.wipe_pin_saved +import com.pasich.encly.core.resources.wipe_pin_set +import com.pasich.encly.core.resources.wipe_pin_title +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.Mnemonic +import com.pasich.encly.core.security.PinChange +import com.pasich.encly.core.security.WipePinChange +import com.pasich.encly.presentation.navigation.NavRoutes +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 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class SecurityScreensTest : ComposeScreenTest() { + private val words = String(Mnemonic.generate()) + private val app by lazy { TestApp().withWorkingVault(words) } + private lateinit var vault: BackupViewModel + + private fun showSecurity(recoveryPhrase: Boolean, biometric: BiometricStatus = BiometricStatus.UNAVAILABLE) { + app.withAuth(recoveryPhrase, biometric) + vault = app.backup() + val owner = viewModels(app.securitySettings(), app.notePreviewsVm(), app.toolbarVm(), vault) + setNavScreen(owner, route = NavRoutes.SecuritySettingsRoute.name) { nav -> + SecuritySettingsScreen(nav) + } + waitForText(str(Res.string.security_encryption_active)) + } + + private fun showPinChange(requiresCurrent: Boolean = true) { + `when`(app.security.canResetPinWithoutCurrent()).thenReturn(!requiresCurrent) + setNavScreen( + viewModels(app.securitySettings()), + route = NavRoutes.PinCodeConfig.name, + start = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> PinCodeConfigScreen(nav) } + navigateTo(NavRoutes.PinCodeConfig.name) + } + + @Test + fun withoutAPhraseSecurityOffersToSetOneUp() { + showSecurity(recoveryPhrase = false) + + rule.onNodeWithText(str(Res.string.auth_method_pin_title)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.security_recovery_missing)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.security_encryption_pin_plain)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 8) + } + + @Test + fun withAPhraseSecurityShowsItIsSetUp() { + showSecurity(recoveryPhrase = true) + + rule.onNodeWithText(str(Res.string.security_recovery_status_set)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.security_encryption_seed_plain)).assertIsDisplayed() + assertEquals(0, countText(str(Res.string.security_recovery_missing))) + } + + @Test + fun thePinRowOpensThePinChange() { + showSecurity(recoveryPhrase = true) + + rule.onNodeWithText(str(Res.string.auth_method_pin_title)).performClick() + + waitFor { currentRoute() == NavRoutes.PinCodeConfig.name } + } + + @Test + fun settingUpAPhraseShowsTwelveWordsThenAsksForThree() { + showSecurity(recoveryPhrase = false) + + rule.onNodeWithText(str(Res.string.security_recovery_missing)).performClick() + waitForText(str(Res.string.backup_reauth_title)) + enterPin("123456") + + waitFor(describe = { "${vault.uiState.value}" }) { vault.uiState.value.step is BackupStep.ShowNewPhrase } + words.split(' ').forEach { word -> waitForText(word) } + rule.onNodeWithText(str(Res.string.backup_phrase_written)).performClick() + + waitForText(str(Res.string.onboarding_verify_title)) + val step = vault.uiState.value.step as BackupStep.CheckNewPhrase + step.positions.forEach { rule.onNodeWithText(str(Res.string.onboarding_verify_label, it + 1)).assertExists() } + } + + @Test + fun aPhraseThatAlreadyExistsIsReported() { + showSecurity(recoveryPhrase = false) + `when`(app.security.hasRecoverySeed()).thenReturn(true) + + rule.onNodeWithText(str(Res.string.security_recovery_missing)).performClick() + waitForText(str(Res.string.backup_reauth_title)) + enterPin("123456") + + waitForText(str(Res.string.backup_phrase_exists)) + } + + @Test + fun theEraseRowNeedsThePinFirst() { + showSecurity(recoveryPhrase = true) + + scrollToText(str(Res.string.security_erase_desc)) + rule.onNodeWithText(str(Res.string.security_erase_desc)).assertIsDisplayed() + } + + @Test + fun changingThePinStartsWithTheCurrentOne() { + showPinChange() + + rule.onNodeWithText(str(Res.string.pin_change_current_title)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.pin_change_bar_title)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 10) + } + + @Test + fun aWrongCurrentPinIsRefused() { + `when`(app.security.verifyPin(anyCharArray())).thenReturn(false) + showPinChange() + + enterPin("000000") + + waitForText(str(Res.string.pin_current_wrong)) + rule.onNodeWithText(str(Res.string.pin_change_current_title)).assertIsDisplayed() + } + + @Test + fun aNewPinIsConfirmedAndSaved() { + val saved = mutableListOf() + val passed = mutableListOf() + `when`(app.security.changePin(anyCharArray())).thenAnswer { + val pin = it.getArgument(0) + passed += pin + saved += String(pin) + PinChange.SET + } + showPinChange() + + enterPin("123456") + waitForText(str(Res.string.pin_create_title)) + enterPin("654321") + waitForText(str(Res.string.pin_change_confirm_title)) + enterPin("654321") + + waitFor { + rule.onAllNodesWithContentDescription(str(Res.string.pin_changed)).fetchSemanticsNodes().isNotEmpty() + } + assertEquals(listOf("654321"), saved) + waitFor { currentRoute() == NavRoutes.SecuritySettingsRoute.name } + // The PIN handed over was wiped once stored. + assertTrue(passed.single().all { it == '\u0000' }) + } + + @Test + fun theBiometricSwitchIsOffLimitsWithoutUsableHardware() { + showSecurity(recoveryPhrase = true, biometric = BiometricStatus.UNAVAILABLE) + + biometricSwitch().assertIsNotEnabled() + rule.onNodeWithText(str(Res.string.security_biometric_unavailable)).assertExists() + } + + @Test + fun theBiometricSwitchIsOffLimitsWithoutAnEnrolledFingerprint() { + showSecurity(recoveryPhrase = true, biometric = BiometricStatus.NOT_ENROLLED) + + biometricSwitch().assertIsNotEnabled() + rule.onNodeWithText(str(Res.string.security_biometric_not_enrolled)).assertExists() + } + + @Test + fun theBiometricSwitchWorksWithUsableHardware() { + showSecurity(recoveryPhrase = true, biometric = BiometricStatus.AVAILABLE) + + biometricSwitch().assertIsEnabled() + } + + private fun biometricSwitch() = rule.onNode(isToggleable() and hasText(str(Res.string.security_biometric_title))) + + @Test + fun aMismatchedConfirmationStartsTheNewPinAgain() { + showPinChange() + + enterPin("123456") + waitForText(str(Res.string.pin_create_title)) + enterPin("111111") + waitForText(str(Res.string.pin_change_confirm_title)) + enterPin("222222") + + waitForText(str(Res.string.pin_mismatch_retry)) + rule.onNodeWithText(str(Res.string.pin_create_title)).assertIsDisplayed() + } + + @Test + fun afterARecoveryUnlockTheNewPinComesFirstWithNoWayBack() { + showPinChange(requiresCurrent = false) + + rule.onNodeWithText(str(Res.string.pin_create_title)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.pin_reset_subtitle)).assertIsDisplayed() + assertEquals(0, countText(str(Res.string.pin_change_bar_title))) + } + + // --- wipe PIN ------------------------------------------------------------------------- + + private fun showWipePin(pinIsRight: Boolean = true) { + `when`(app.security.verifyPin(anyCharArray())).thenReturn(pinIsRight) + setNavScreen( + viewModels(app.securitySettings()), + route = NavRoutes.WipePinRoute.name, + start = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> WipePinScreen(nav) } + navigateTo(NavRoutes.WipePinRoute.name) + } + + @Test + fun theWipePinRowOpensTheWipePinPage() { + showSecurity(recoveryPhrase = true) + + scrollToText(str(Res.string.wipe_pin_row_desc)) + rule.onNodeWithText(str(Res.string.wipe_pin_row_desc)).performClick() + + waitFor { currentRoute() == NavRoutes.WipePinRoute.name } + } + + @Test + fun theWipePinPageAsksForThePinFirst() { + showWipePin(pinIsRight = false) + + rule.onNodeWithText(str(Res.string.wipe_pin_current_subtitle)).assertIsDisplayed() + enterPin("000000") + + waitForText(str(Res.string.pin_current_wrong)) + assertEquals(0, countText(str(Res.string.wipe_pin_set))) + } + + @Test + fun aWipePinIsConfirmedAndSaved() { + val saved = mutableListOf() + val passed = mutableListOf() + showWipePin() + `when`(app.security.configureWipePin(anyCharArray())).thenAnswer { + val pin = it.getArgument(0) + passed += pin + saved += String(pin) + pin.fill('\u0000') + WipePinChange.SET + } + + enterPin("123456") + waitForText(str(Res.string.wipe_pin_intro)) + rule.onNodeWithText(str(Res.string.wipe_pin_backups)).assertIsDisplayed() + scrollToText(str(Res.string.wipe_pin_set)) + rule.onNodeWithText(str(Res.string.wipe_pin_set)).performClick() + waitForText(str(Res.string.wipe_pin_new_title)) + enterPin("864200") + waitForText(str(Res.string.wipe_pin_confirm_title)) + enterPin("864200") + + waitForText(str(Res.string.wipe_pin_saved)) + assertEquals(listOf("864200"), saved) + assertTrue(passed.single().all { it == '\u0000' }) + } + + @Test + fun aWipePinEqualToThePinIsRefused() { + showWipePin() + `when`(app.security.configureWipePin(anyCharArray())).thenReturn(WipePinChange.SAME_AS_PIN) + + enterPin("123456") + waitForText(str(Res.string.wipe_pin_intro)) + scrollToText(str(Res.string.wipe_pin_set)) + rule.onNodeWithText(str(Res.string.wipe_pin_set)).performClick() + enterPin("123456") + waitForText(str(Res.string.wipe_pin_confirm_title)) + enterPin("123456") + + waitForText(str(Res.string.wipe_pin_same_as_pin)) + rule.onNodeWithText(str(Res.string.wipe_pin_new_title)).assertIsDisplayed() + } + + @Test + fun theWipePinCanBeTurnedOff() { + showWipePin() + `when`(app.security.removeWipePin()).thenReturn(true) + + enterPin("123456") + waitForText(str(Res.string.wipe_pin_intro)) + scrollToText(str(Res.string.wipe_pin_remove)) + rule.onNodeWithText(str(Res.string.wipe_pin_remove)).performClick() + + waitForText(str(Res.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) + enterPin("123456") + waitForText(str(Res.string.duress_pin_intro)) + scrollToText(str(Res.string.duress_pin_set)) + rule.onNodeWithText(str(Res.string.duress_pin_set)).performClick() + waitForText(str(Res.string.duress_pin_new_title)) + enterPin("864200") + waitForText(str(Res.string.duress_pin_confirm_title)) + enterPin("864200") + } + + @Test + fun theDuressPinRowOpensTheDuressPinPage() { + showSecurity(recoveryPhrase = true) + + scrollToText(str(Res.string.duress_pin_row_desc)) + rule.onNodeWithText(str(Res.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(Res.string.duress_pin_current_subtitle)).assertIsDisplayed() + enterPin("000000") + + waitForText(str(Res.string.pin_current_wrong)) + assertEquals(0, countText(str(Res.string.duress_pin_set))) + } + + @Test + fun aNewDuressPinIsSaved() { + showDuressPin() + + enterDuressPin(DuressPinChange.SET_NEW_VAULT) + + waitForText(str(Res.string.duress_pin_saved)) + } + + @Test + fun aChangedDuressPinSaysTheDecoyNotesStay() { + showDuressPin() + + enterDuressPin(DuressPinChange.SET) + + waitForText(str(Res.string.duress_pin_saved_kept)) + } + + @Test + fun aDuressPinEqualToThePinIsRefused() { + showDuressPin() + + enterDuressPin(DuressPinChange.SAME_AS_PIN) + + waitForText(str(Res.string.duress_pin_same_as_pin)) + rule.onNodeWithText(str(Res.string.duress_pin_new_title)).assertIsDisplayed() + } + + @Test + fun aDuressPinInUseIsRefusedGenerically() { + showDuressPin() + + enterDuressPin(DuressPinChange.IN_USE) + + waitForText(str(Res.string.pin_in_use)) + } + + @Test + fun turningTheDuressPinOffAsksForConfirmation() { + showDuressPin(isSet = true) + `when`(app.security.removeDuressPin()).thenReturn(true) + + enterPin("123456") + waitForText(str(Res.string.duress_pin_intro)) + scrollToText(str(Res.string.duress_pin_remove)) + rule.onNodeWithText(str(Res.string.duress_pin_remove)).performClick() + waitForText(str(Res.string.duress_pin_remove_confirm_body)) + verify(app.security, never()).removeDuressPin() + rule.onNodeWithText(str(Res.string.duress_pin_remove_confirm)).performClick() + + waitForText(str(Res.string.duress_pin_removed)) + } + + @Test + fun aPinChangeToTheDuressPinIsRefused() { + `when`(app.security.changePin(anyCharArray())).thenReturn(PinChange.IN_USE) + showPinChange() + + enterPin("123456") + waitForText(str(Res.string.pin_create_title)) + enterPin("654321") + waitForText(str(Res.string.pin_change_confirm_title)) + enterPin("654321") + + waitForText(str(Res.string.pin_in_use)) + } + + // --- 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( + Res.string.security_encryption_active, + Res.string.auth_method_pin_title, + Res.string.security_biometric_title, + Res.string.wipe_pin_title, + Res.string.duress_pin_title, + Res.string.auto_lock_title, + Res.string.backup_needs_phrase_title, + Res.string.security_recovery_replace_title, + Res.string.security_keyboard_strict_title, + Res.string.security_protection_section, + Res.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 theDecoySecurityPageListsTheSameRowsAsTheRealVault() { + app.inDecoySession() + showSecurity(recoveryPhrase = true) + + assertSecurityRowsListed() + } + + @Test + fun aPinChangeInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + showPinChange() + + enterPin("123456") + waitForText(str(Res.string.pin_create_title)) + enterPin("654321") + waitForText(str(Res.string.pin_change_confirm_title)) + enterPin("654321") + + waitForText(str(Res.string.pin_update_failed)) + assertEquals(NavRoutes.PinCodeConfig.name, currentRoute()) + } + + @Test + fun aWipePinInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + showWipePin() + + enterPin("123456") + waitForText(str(Res.string.wipe_pin_intro)) + scrollToText(str(Res.string.wipe_pin_set)) + rule.onNodeWithText(str(Res.string.wipe_pin_set)).performClick() + enterPin("864200") + waitForText(str(Res.string.wipe_pin_confirm_title)) + enterPin("864200") + + waitForText(str(Res.string.wipe_pin_failed)) + } + + @Test + fun turningTheWipePinOffInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + showWipePin() + + enterPin("123456") + waitForText(str(Res.string.wipe_pin_intro)) + scrollToText(str(Res.string.wipe_pin_remove)) + rule.onNodeWithText(str(Res.string.wipe_pin_remove)).performClick() + + waitForText(str(Res.string.wipe_pin_failed)) + } + + @Test + fun aDuressPinInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + showDuressPin() + + enterDuressPin(DuressPinChange.FAILED) + + waitForText(str(Res.string.duress_pin_failed)) + } + + @Test + fun eraseInTheDecoyVaultFailsAndKeepsTheVault() { + app.inDecoySession() + showSecurity(recoveryPhrase = true) + + rule.runOnIdle { vault.start(BackupAction.ERASE) } + waitForText(str(Res.string.backup_reauth_title)) + enterPin("123456") + + waitForText(str(Res.string.security_erase_failed)) + assertEquals(false, vault.uiState.value.erased) + assertEquals(NavRoutes.SecuritySettingsRoute.name, currentRoute()) + assertTrue(countText(str(Res.string.security_erase_title)) > 0) + } + + @Test + fun aNewRecoveryPhraseInTheDecoyVaultFailsAtTheEnd() { + app.inDecoySession(recoveryPhrase = false) + showSecurity(recoveryPhrase = false) + + rule.onNodeWithText(str(Res.string.security_recovery_missing)).performClick() + waitForText(str(Res.string.backup_reauth_title)) + enterPin("123456") + waitFor(describe = { "${vault.uiState.value}" }) { vault.uiState.value.step is BackupStep.ShowNewPhrase } + rule.onNodeWithText(str(Res.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) } + + waitForText(str(Res.string.backup_error_phrase_setup)) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SettingsScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SettingsScreensTest.kt new file mode 100644 index 00000000..a99b3c4d --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SettingsScreensTest.kt @@ -0,0 +1,284 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.appearance_mode_note_dark +import com.pasich.encly.core.resources.appearance_mode_note_midnight +import com.pasich.encly.core.resources.appearance_text_desc +import com.pasich.encly.core.resources.appearance_text_section +import com.pasich.encly.core.resources.backup_title +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.done +import com.pasich.encly.core.resources.font_size_title +import com.pasich.encly.core.resources.font_size_value +import com.pasich.encly.core.resources.font_style_tech +import com.pasich.encly.core.resources.language_dialog_title +import com.pasich.encly.core.resources.language_name_uk +import com.pasich.encly.core.resources.note_line_spacing_title +import com.pasich.encly.core.resources.palette_forest +import com.pasich.encly.core.resources.palette_graphite +import com.pasich.encly.core.resources.palette_midnight +import com.pasich.encly.core.resources.palette_ocean +import com.pasich.encly.core.resources.palette_paper +import com.pasich.encly.core.resources.security_title +import com.pasich.encly.core.resources.settings_appearance +import com.pasich.encly.core.resources.settings_backup +import com.pasich.encly.core.resources.settings_general +import com.pasich.encly.core.resources.settings_language +import com.pasich.encly.core.resources.settings_notes_and_tasks +import com.pasich.encly.core.resources.settings_privacy +import com.pasich.encly.core.resources.settings_show_tasks +import com.pasich.encly.core.resources.settings_swipe_enabled +import com.pasich.encly.core.resources.settings_swipe_left +import com.pasich.encly.core.resources.settings_swipe_right +import com.pasich.encly.core.resources.settings_swipe_section +import com.pasich.encly.core.resources.swipe_choice_color +import com.pasich.encly.core.resources.swipe_choice_pin +import com.pasich.encly.core.resources.theme_type_dark +import com.pasich.encly.core.resources.theme_type_light +import com.pasich.encly.core.resources.theme_type_system +import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.SwipeAction +import com.pasich.encly.domain.model.ThemePalette +import com.pasich.encly.domain.model.ThemeType +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.settings.AppearanceScreen +import com.pasich.encly.presentation.screen.settings.SettingsScreen +import com.pasich.encly.ui.theme.colorSchemeFor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class SettingsScreensTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + private var surface: Color = Color.Unspecified + + private fun showSettings() { + setNavScreen(viewModels(app.settingsVm()), route = NavRoutes.SettingsRoute.name) { nav -> SettingsScreen(nav) } + } + + private fun showAppearance() { + setNavScreen( + viewModels(app.appearance()), + route = NavRoutes.AppearanceRoute.name, + liveTheme = app.settings.theme, + ) { nav -> + val shown = MaterialTheme.colorScheme.surface + SideEffect { surface = shown } + AppearanceScreen(nav) + } + } + + @Test + fun settingsListsEverySection() { + showSettings() + + listOf( + Res.string.settings_general, + Res.string.settings_notes_and_tasks, + Res.string.settings_privacy, + Res.string.settings_backup, + ).forEach { scrollToText(str(it), ignoreCase = true) } + scrollToText(str(Res.string.settings_appearance)) + scrollToText(str(Res.string.settings_language)) + assertBodyNotEmpty(minTexts = 6) + } + + @Test + fun theDecoyVaultListsTheSameSectionsAsTheRealOne() { + app.inDecoySession() + showSettings() + + listOf( + Res.string.settings_general, + Res.string.settings_notes_and_tasks, + Res.string.settings_privacy, + Res.string.settings_backup, + ).forEach { scrollToText(str(it), ignoreCase = true) } + scrollToText(str(Res.string.backup_title)) + rule.onNodeWithText(str(Res.string.backup_title)).performClick() + + waitFor { currentRoute() == NavRoutes.BackupRoute.name } + } + + @Test + fun theAppearanceRowOpensAppearance() { + showSettings() + + rule.onNodeWithText(str(Res.string.settings_appearance)).performClick() + + waitFor { currentRoute() == NavRoutes.AppearanceRoute.name } + } + + @Test + fun theSecurityRowOpensSecurity() { + showSettings() + scrollToText(str(Res.string.security_title)) + + rule.onNodeWithText(str(Res.string.security_title)).performClick() + + waitFor { currentRoute() == NavRoutes.SecuritySettingsRoute.name } + } + + @Test + fun theShowTasksSwitchIsSaved() { + showSettings() + scrollToText(str(Res.string.settings_show_tasks)) + + rule.onNodeWithText(str(Res.string.settings_show_tasks)).performClick() + + waitFor { !app.settings.showTasks.value } + } + + @Test + fun theSwipeSectionListsTheMasterSwitchAndBothDirections() { + showSettings() + + scrollToText(str(Res.string.settings_swipe_section), ignoreCase = true) + scrollToText(str(Res.string.settings_swipe_enabled)) + scrollToText(str(Res.string.settings_swipe_right)) + scrollToText(str(Res.string.settings_swipe_left)) + // The defaults: pin on the right, archive on the left. + scrollToText(str(Res.string.swipe_choice_pin)) + } + + @Test + fun theSwipeSwitchIsSavedAndHidesTheDirectionRows() { + showSettings() + scrollToText(str(Res.string.settings_swipe_enabled)) + + rule.onNodeWithText(str(Res.string.settings_swipe_enabled)).performClick() + + waitFor { !app.settings.swipe.value.enabled } + waitFor { countText(str(Res.string.settings_swipe_right)) == 0 } + } + + @Test + fun theActionOfADirectionIsChosenInADialogAndSaved() { + showSettings() + scrollToText(str(Res.string.settings_swipe_left)) + + rule.onNodeWithText(str(Res.string.settings_swipe_left)).performClick() + waitForText(str(Res.string.swipe_choice_color)) + rule.onNodeWithText(str(Res.string.swipe_choice_color)).performClick() + rule.onNodeWithText(str(Res.string.done)).performClick() + + waitFor { app.settings.swipe.value.left == SwipeAction.NEXT_COLOR } + assertEquals(SwipeAction.PIN, app.settings.swipe.value.right) + } + + @Test + fun theLanguageIsSwitchedInTheApp() { + showSettings() + scrollToText(str(Res.string.settings_language)) + + rule.onNodeWithText(str(Res.string.settings_language)).performClick() + waitForText(str(Res.string.language_dialog_title)) + rule.onNodeWithText(str(Res.string.language_name_uk)).performClick() + rule.onNodeWithText(str(Res.string.done)).performClick() + + waitFor { app.locales.current() == AppLanguage.UKRAINIAN } + assertEquals(0, countText(str(Res.string.language_dialog_title))) + } + + @Test + fun cancellingTheLanguageChoiceKeepsTheLanguage() { + showSettings() + scrollToText(str(Res.string.settings_language)) + rule.onNodeWithText(str(Res.string.settings_language)).performClick() + waitForText(str(Res.string.language_dialog_title)) + + rule.onNodeWithText(str(Res.string.language_name_uk)).performClick() + rule.onNodeWithText(str(Res.string.cancel)).performClick() + + waitFor { countText(str(Res.string.language_dialog_title)) == 0 } + assertEquals(AppLanguage.SYSTEM, app.locales.current()) + } + + @Test + fun appearanceOffersTheDefaultTextSizeAndLineSpacingOfNotes() { + app.settings.fontSize.value = 22 + showAppearance() + + scrollToText(str(Res.string.appearance_text_section), ignoreCase = true) + scrollToText(str(Res.string.font_size_title)) + scrollToText(str(Res.string.note_line_spacing_title)) + scrollToText(str(Res.string.font_size_value, 22)) + scrollToText(str(Res.string.appearance_text_desc)) + } + + @Test + fun appearanceShowsEveryThemeModeAndFont() { + showAppearance() + + listOf( + Res.string.palette_paper, + Res.string.palette_forest, + Res.string.palette_ocean, + Res.string.palette_graphite, + Res.string.palette_midnight, + Res.string.theme_type_system, + Res.string.theme_type_light, + Res.string.theme_type_dark, + ).forEach { rule.onNodeWithText(str(it)).assertExists() } + scrollToText(str(Res.string.font_style_tech)) + assertBodyNotEmpty(minTexts = 10) + } + + @Test + fun choosingAThemeRecolorsTheApp() { + showAppearance() + assertEquals(colorSchemeFor(ThemePalette.PAPER, false).surface, surface) + + rule.onNodeWithText(str(Res.string.palette_ocean)).performClick() + + waitFor { app.settings.theme.value.palette == ThemePalette.OCEAN } + assertEquals(colorSchemeFor(ThemePalette.OCEAN, false).surface, surface) + } + + @Test + fun darkModeRecolorsTheApp() { + showAppearance() + + rule.onNodeWithText(str(Res.string.theme_type_dark)).performClick() + + waitFor { app.settings.theme.value.type == ThemeType.DARK } + assertEquals(colorSchemeFor(ThemePalette.PAPER, true).surface, surface) + rule.onNodeWithText(str(Res.string.appearance_mode_note_dark)).assertIsDisplayed() + } + + @Test + fun midnightIsAlwaysDarkAndLocksTheMode() { + showAppearance() + + rule.onNodeWithText(str(Res.string.palette_midnight)).performClick() + + waitFor { app.settings.theme.value.palette == ThemePalette.MIDNIGHT } + assertEquals(colorSchemeFor(ThemePalette.MIDNIGHT, true).surface, surface) + rule.onNodeWithText(str(Res.string.appearance_mode_note_midnight)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.theme_type_light)).assertIsNotEnabled() + } + + @Test + fun theFontChoiceIsSaved() { + showAppearance() + scrollToText(str(Res.string.font_style_tech)) + + rule.onNodeWithText(str(Res.string.font_style_tech)).performClick() + + waitFor { app.settings.theme.value.fontStyle == FontStyleType.TECH_MINIMAL } + assertFalse( + rule.onNodeWithText( + str(Res.string.font_style_tech), + ).fetchSemanticsNode().config.contains(SemanticsProperties.Disabled), + ) + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/SwipeableNoteTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SwipeableNoteTest.kt similarity index 86% rename from app/src/test/java/com/pasich/encly/ui/screens/SwipeableNoteTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SwipeableNoteTest.kt index b4cce7ea..0a66138a 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/SwipeableNoteTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SwipeableNoteTest.kt @@ -17,7 +17,13 @@ import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeRight import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.dialog_confirm +import com.pasich.encly.core.resources.dialog_dismiss +import com.pasich.encly.core.resources.note_archive +import com.pasich.encly.core.resources.note_unarchive +import com.pasich.encly.core.resources.note_unpin +import com.pasich.encly.core.resources.swipe_delete_title import com.pasich.encly.data.model.Note import com.pasich.encly.domain.model.NoteColor import com.pasich.encly.domain.model.SwipeAction @@ -138,9 +144,9 @@ class SwipeableNoteTest : ComposeScreenTest() { swipeLeftOnCard() - waitForText(str(R.string.swipe_delete_title)) + waitForText(str(Res.string.swipe_delete_title)) assertTrue(events.isEmpty()) - rule.onNodeWithText(str(R.string.dialog_confirm)).performClick() + rule.onNodeWithText(str(Res.string.dialog_confirm)).performClick() waitFor { events.isNotEmpty() } assertEquals(listOf(NoteListEvent.NoteToTrash(note)), events) } @@ -150,10 +156,10 @@ class SwipeableNoteTest : ComposeScreenTest() { show(settings = SwipeSettings(left = SwipeAction.DELETE)) swipeLeftOnCard() - waitForText(str(R.string.swipe_delete_title)) - rule.onNodeWithText(str(R.string.dialog_dismiss)).performClick() + waitForText(str(Res.string.swipe_delete_title)) + rule.onNodeWithText(str(Res.string.dialog_dismiss)).performClick() - waitFor { countText(str(R.string.swipe_delete_title)) == 0 } + waitFor { countText(str(Res.string.swipe_delete_title)) == 0 } assertTrue(events.isEmpty()) } @@ -175,14 +181,14 @@ class SwipeableNoteTest : ComposeScreenTest() { swipeRightOnCard() assertTrue(events.isEmpty()) - assertEquals(listOf(str(R.string.note_archive)), customActionLabels()) + assertEquals(listOf(str(Res.string.note_archive)), customActionLabels()) } @Test fun theConfiguredActionsAreOfferedToTalkBack() { show(Note(id = 1, isPinned = true)) - assertEquals(listOf(str(R.string.note_unpin), str(R.string.note_archive)), customActionLabels()) + assertEquals(listOf(str(Res.string.note_unpin), str(Res.string.note_archive)), customActionLabels()) } @Test @@ -205,7 +211,7 @@ class SwipeableNoteTest : ComposeScreenTest() { swipeLeftOnCard() assertEquals(listOf(NoteListEvent.ArchiveNote(archived, false)), events) - assertEquals(listOf(str(R.string.note_unarchive)), customActionLabels()) + assertEquals(listOf(str(Res.string.note_unarchive)), customActionLabels()) } private companion object { diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TagsAndTrashTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TagsAndTrashTest.kt new file mode 100644 index 00000000..091651a8 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TagsAndTrashTest.kt @@ -0,0 +1,194 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.delete_forever +import com.pasich.encly.core.resources.delete_tag +import com.pasich.encly.core.resources.empty_trash_desc +import com.pasich.encly.core.resources.empty_trash_title +import com.pasich.encly.core.resources.restore +import com.pasich.encly.core.resources.tag_add +import com.pasich.encly.core.resources.tag_edit +import com.pasich.encly.core.resources.tag_hidden +import com.pasich.encly.core.resources.tag_move_down +import com.pasich.encly.core.resources.tag_name_taken +import com.pasich.encly.core.resources.tag_show_in_list +import com.pasich.encly.core.resources.tags_empty_desc +import com.pasich.encly.core.resources.tags_empty_title +import com.pasich.encly.core.resources.tags_hint +import com.pasich.encly.core.resources.trash_auto_delete_hint +import com.pasich.encly.core.resources.trash_hint +import com.pasich.encly.core.resources.trash_selected +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.EditTagScreen +import com.pasich.encly.presentation.screen.trash.TrashScreen +import org.junit.Assert.assertEquals +import org.junit.Test + +class TagsAndTrashTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private fun showTags() { + setNavScreen(viewModels(app.tagList()), route = NavRoutes.EditTagRoute.name) { nav -> EditTagScreen(nav) } + } + + private fun showTrash() { + setNavScreen(viewModels(app.trash()), route = NavRoutes.TrashRoute.name) { nav -> TrashScreen(nav) } + } + + private fun trashed(vararg titles: String) { + app.notes.trashNotes.value = titles.mapIndexed { index, title -> + Note(id = index + 1L, title = title, value = "[]", isTrash = true, date = 1_700_000_000_000) + } + } + + @Test + fun noTagsShowsTheFieldAndTheEmptyState() { + showTags() + + waitForText(str(Res.string.tags_empty_title)) + rule.onNodeWithText(str(Res.string.tags_empty_desc)).assertIsDisplayed() + rule.onNode(hasSetTextAction()).assertExists() + assertBodyNotEmpty(minTexts = 3) + } + + @Test + fun tagsAreListedWithTheirNoteCounts() { + val work = Tag(id = 1, nameTag = "Work") + app.withTags(work, Tag(id = 2, nameTag = "Home", isVisible = false, position = 1)) + app.notes.allNotesWithTags.value = listOf( + NoteWithTags(Note(id = 1, title = "a", tagIds = setOf(1)), listOf(work)), + NoteWithTags(Note(id = 2, title = "b", tagIds = setOf(1)), listOf(work)), + ) + showTags() + + waitForText("Work") + rule.onNodeWithText("Home").assertIsDisplayed() + rule.onNodeWithText(str(Res.string.tag_hidden)).assertIsDisplayed() + waitForText("2") + rule.onNodeWithText(str(Res.string.tags_hint)).assertIsDisplayed() + } + + @Test + fun aNewTagIsCreatedFromTheField() { + showTags() + waitForText(str(Res.string.tags_empty_title)) + + rule.onNode(hasSetTextAction()).performTextInput("Travel") + rule.onNodeWithText(str(Res.string.tag_add)).assertIsEnabled().performClick() + + waitFor { app.tags.tags.value.any { it.nameTag == "Travel" } } + waitForText("Travel") + } + + @Test + fun aTakenNameIsFlaggedAsYouType() { + app.withTags(Tag(id = 1, nameTag = "Work")) + showTags() + waitForText("Work") + + rule.onNode(hasSetTextAction()).performTextInput("work") + + waitForText(str(Res.string.tag_name_taken)) + rule.onNodeWithText(str(Res.string.tag_add)).assertIsNotEnabled() + } + + @Test + fun tappingATagOpensItsSheet() { + app.withTags(Tag(id = 1, nameTag = "Work")) + showTags() + waitForText("Work") + + rule.onNode(hasText("Work") and hasClickAction()).performClick() + + waitForText(str(Res.string.tag_edit)) + rule.onNodeWithText(str(Res.string.tag_show_in_list)).assertExists() + rule.onNodeWithText(str(Res.string.delete_tag)).assertExists() + } + + @Test + fun aTagMovesDownWithTheAccessibilityAction() { + app.withTags(Tag(id = 1, nameTag = "Work"), Tag(id = 2, nameTag = "Home", position = 1)) + showTags() + waitForText("Home") + + val actions = rule.onNode(hasText("Work") and hasClickAction()).fetchSemanticsNode() + .config[SemanticsActions.CustomActions] + rule.runOnUiThread { actions.single { it.label == str(Res.string.tag_move_down) }.action() } + + waitFor { app.tags.tags.value.map { it.nameTag } == listOf("Home", "Work") } + } + + @Test + fun anEmptyTrashSaysSo() { + showTrash() + + waitForText(str(Res.string.empty_trash_title)) + rule.onNodeWithText(str(Res.string.empty_trash_desc)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 2) + } + + @Test + fun trashedNotesAreListedWithTheHint() { + trashed("Old plan", "Draft") + showTrash() + + waitForText("Old plan") + rule.onNodeWithText("Draft").assertIsDisplayed() + rule.onNodeWithText(str(Res.string.trash_hint)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.trash_auto_delete_hint)).assertIsDisplayed() + } + + @Test + fun longPressSelectsAndOffersRestoreAndDeleteForever() { + trashed("Old plan", "Draft") + showTrash() + waitForText("Old plan") + + rule.onNode(hasText("Old plan") and hasClickAction()).performTouchInput { longClick() } + + waitForText(plural(Res.plurals.trash_selected, 1, 1)) + rule.onNodeWithContentDescription(str(Res.string.restore)).assertIsDisplayed() + rule.onNodeWithContentDescription(str(Res.string.delete_forever)).assertIsDisplayed() + } + + @Test + fun restoringTheSelectionTakesTheNoteOutOfTheTrash() { + trashed("Old plan") + showTrash() + waitForText("Old plan") + rule.onNode(hasText("Old plan") and hasClickAction()).performTouchInput { longClick() } + waitForText(plural(Res.plurals.trash_selected, 1, 1)) + + rule.onNodeWithContentDescription(str(Res.string.restore)).performClick() + + waitFor { app.notes.updatedNotes.any { it.id == 1L && !it.isTrash } } + } + + @Test + fun tappingATrashedNoteOpensItReadOnly() { + trashed("Old plan") + showTrash() + waitForText("Old plan") + + rule.onNode(hasText("Old plan") and hasClickAction()).performClick() + + waitFor { currentRoute() == TestRoutes.EDIT_NOTE } + assertEquals(true, rule.runOnIdle { nav.currentBackStackEntry?.arguments?.getBoolean("isReadTrashOnly") }) + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt similarity index 85% rename from app/src/test/java/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt index 36d89448..cc680298 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt @@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.lifecycle.Lifecycle import androidx.navigation.compose.NavHost @@ -15,10 +16,12 @@ import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import com.pasich.encly.data.model.Task import com.pasich.encly.domain.model.ThemeSettings +import com.pasich.encly.platform.LocalAppWindow import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.screen.TasksScreen import com.pasich.encly.presentation.viewmodel.InlineSubtaskTarget import com.pasich.encly.presentation.viewmodel.TasksViewModel +import com.pasich.encly.testutil.ActivityTestWindow import com.pasich.encly.ui.theme.EnclyTheme import org.junit.After import org.junit.Assert.assertEquals @@ -42,7 +45,7 @@ class TasksScreenRecreationTest { val rule = createEmptyComposeRule() private val context: Context get() = ApplicationProvider.getApplicationContext() - private val app by lazy { TestApp(context) } + private val app by lazy { TestApp() } private lateinit var tasks: TasksViewModel private var scenario: ActivityScenario? = null @@ -99,10 +102,12 @@ class TasksHostActivity : ComponentActivity() { super.onCreate(savedInstanceState) val owner = checkNotNull(viewModels) setContent { - EnclyTheme(settings = ThemeSettings(), dark = false) { - val nav = rememberNavController() - NavHost(navController = nav, startDestination = NavRoutes.TasksRoute.name) { - composable(NavRoutes.TasksRoute.name) { ProvideViewModels(owner) { TasksScreen(nav) } } + CompositionLocalProvider(LocalAppWindow provides ActivityTestWindow(this)) { + EnclyTheme(settings = ThemeSettings(), dark = false) { + val nav = rememberNavController() + NavHost(navController = nav, startDestination = NavRoutes.TasksRoute.name) { + composable(NavRoutes.TasksRoute.name) { ProvideViewModels(owner) { TasksScreen(nav) } } + } } } } diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TasksScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TasksScreenTest.kt new file mode 100644 index 00000000..b50ed9ac --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TasksScreenTest.kt @@ -0,0 +1,392 @@ +package com.pasich.encly.ui.screens + +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextInput +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.add +import com.pasich.encly.core.resources.priority_high +import com.pasich.encly.core.resources.priority_select_title +import com.pasich.encly.core.resources.save +import com.pasich.encly.core.resources.subtask_add +import com.pasich.encly.core.resources.subtask_first_step +import com.pasich.encly.core.resources.subtask_next_step +import com.pasich.encly.core.resources.subtask_placeholder +import com.pasich.encly.core.resources.subtasks_all_done +import com.pasich.encly.core.resources.subtasks_done_summary +import com.pasich.encly.core.resources.subtasks_hide +import com.pasich.encly.core.resources.subtasks_show +import com.pasich.encly.core.resources.subtasks_state_shown +import com.pasich.encly.core.resources.tag_drag_handle +import com.pasich.encly.core.resources.tag_move_down +import com.pasich.encly.core.resources.tag_move_up +import com.pasich.encly.core.resources.task_add +import com.pasich.encly.core.resources.task_all_done_title +import com.pasich.encly.core.resources.task_delete +import com.pasich.encly.core.resources.task_deleted +import com.pasich.encly.core.resources.task_done_section +import com.pasich.encly.core.resources.task_edit_placeholder +import com.pasich.encly.core.resources.task_empty_desc +import com.pasich.encly.core.resources.task_empty_title +import com.pasich.encly.core.resources.undo +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Task +import com.pasich.encly.presentation.screen.TasksScreen +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TasksScreenTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private lateinit var back: OnBackPressedDispatcher + + private fun show(openNewTask: Boolean = false) { + setNavScreen(viewModels(app.tasksVm()), route = TestRoutes.TASKS) { nav -> + back = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher + TasksScreen(nav, openNewTask = openNewTask) + } + } + + private fun pressBack() { + rule.runOnIdle { back.onBackPressed() } + rule.waitForIdle() + } + + @Test + fun noTasksShowsTheEmptyStateWithAnAction() { + show() + + waitForText(str(Res.string.task_empty_title)) + rule.onNodeWithText(str(Res.string.task_empty_desc)).assertIsDisplayed() + assertBodyNotEmpty(minTexts = 3) + } + + @Test + fun oneOpenTaskIsListedWithItsPriority() { + app.withTasks(Task(id = 1, title = "Buy milk", priority = 2)) + show() + + waitForText("Buy milk") + assertTrue(countText(str(Res.string.priority_high)) >= 1) + assertBodyNotEmpty() + } + + @Test + fun manyTasksShowOpenOnesAndTheDoneSection() { + app.withTasks( + *Array(6) { Task(id = it + 1L, title = "Open $it") }, + Task(id = 20, title = "Finished", isCompleted = true, completedDate = 1_700_000_000_000), + ) + show() + + waitForText("Open 0") + scrollToText(str(Res.string.task_done_section, 1), ignoreCase = true) + rule.onNodeWithText("Finished").assertIsDisplayed() + assertBodyNotEmpty(minTexts = 6) + } + + @Test + fun allOpenTasksDoneSaysSo() { + app.withTasks(Task(id = 1, title = "Finished", isCompleted = true, completedDate = 1L)) + show() + + waitForText(str(Res.string.task_all_done_title)) + rule.onNodeWithText("Finished").assertIsDisplayed() + } + + @Test + fun aNewTaskIsAddedFromTheSheet() { + show() + waitForText(str(Res.string.task_empty_title)) + + rule.onAllNodesWithText(str(Res.string.task_add)).onFirst().performClick() + rule.waitForIdle() + rule.onNodeWithText(str(Res.string.add)).assertIsNotEnabled() + rule.onAllNodes(hasSetTextAction())[0].performTextInput("Water the plants") + rule.onNodeWithText(str(Res.string.add)).performClick() + + waitFor { app.tasks.tasks.value.any { it.title == "Water the plants" } } + waitForText("Water the plants") + assertTrue(rule.onAllNodesWithText(str(Res.string.add)).fetchSemanticsNodes().isEmpty()) + } + + @Test + fun theHomeCardsNewTaskLinkOpensTheSheetOnArrival() { + show(openNewTask = true) + + waitForText(str(Res.string.priority_select_title), ignoreCase = true) + assertTrue(rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty()) + } + + @Test + fun tickingATaskCompletesIt() { + app.withTasks(Task(id = 1, title = "Call mum")) + show() + waitForText("Call mum") + + rule.onNode(isTaskCheckbox).performClick() + + waitFor { app.tasks.tasks.value.single().isCompleted } + waitForText(str(Res.string.task_done_section, 1), ignoreCase = true) + assertEquals( + ToggleableState.On, + rule.onNode(isTaskCheckbox).fetchSemanticsNode().config[SemanticsProperties.ToggleableState], + ) + } + + @Test + fun aDeletedTaskCanBeBroughtBackFromTheSnackbar() { + app.withTasks(Task(id = 1, title = "Call mum")) + show() + waitForText("Call mum") + + rule.onNodeWithContentDescription(str(Res.string.task_edit_placeholder)).performClick() + waitForText(str(Res.string.task_delete)) + rule.onNodeWithText(str(Res.string.task_delete)).performClick() + + waitForText(str(Res.string.task_deleted)) + assertTrue(app.tasks.tasks.value.isEmpty()) + rule.onNodeWithText(str(Res.string.undo)).performClick() + waitFor { app.tasks.tasks.value.singleOrNull()?.title == "Call mum" } + } + + @Test + fun foldedATaskShowsOnlyItsNextStepAndTickingItMovesOn() { + withShoppingList(Subtask(id = 12, taskId = 1, title = "Bread", position = 2)) + show() + + waitForText("Shop") + // The first open one, not the done Milk or the later Bread; and no "1/3" text anywhere. + waitForText("Eggs") + assertEquals(0, countText("Milk")) + assertEquals(0, countText("Bread")) + assertEquals(0, countText("/3", substring = true)) + assertEquals(0, countText(str(Res.string.subtask_placeholder))) + + rule.onNodeWithContentDescription(str(Res.string.subtask_next_step, "Eggs")).performClick() + + waitFor { app.tasks.subtasks.value.single { it.id == 11L }.isCompleted } + waitForText("Bread") + waitFor { countText("Eggs") == 0 } + // Saved in place: no sheet opened, and no completion offer while one is still open. + assertTrue(rule.onAllNodesWithText(str(Res.string.save)).fetchSemanticsNodes().isEmpty()) + assertEquals(0, countText(str(Res.string.subtasks_all_done))) + } + + @Test + fun tickingTheLastOpenStepKeepsItStruckAndOffersToComplete() { + withShoppingList() + show() + waitForText("Eggs") + + rule.onNodeWithContentDescription(str(Res.string.subtask_next_step, "Eggs")).performClick() + + waitFor { app.tasks.subtasks.value.all { it.isCompleted } } + waitForText(str(Res.string.subtasks_all_done)) + rule.onNodeWithText("Eggs").assertIsDisplayed() + assertEquals(0, countText("Milk")) + assertTrue(!app.tasks.tasks.value.single().isCompleted) + } + + @Test + fun aTaskWithoutSubtasksHasNoLeafAndOpensToFirstStep() { + app.withTasks(Task(id = 1, title = "Call mum")) + show() + waitForText("Call mum") + assertEquals(0, countText(str(Res.string.subtask_first_step))) + assertEquals(1, rule.onAllNodes(isTaskCheckbox).fetchSemanticsNodes().size) + + rule.onNodeWithText("Call mum").performClick() + + waitForText(str(Res.string.subtask_first_step)) + // The task row and the add leaf: no sub-task checkboxes. + assertEquals(1, rule.onAllNodes(isTaskCheckbox).fetchSemanticsNodes().size) + } + + @Test + fun aTapOnTheRowShowsTheWholeTreeAndBackFoldsIt() { + withShoppingList(Subtask(id = 12, taskId = 1, title = "Bread", position = 2)) + show() + waitForText("Eggs") + + rule.onNodeWithText("Shop").performClick() + + waitForText("Milk") + rule.onNodeWithText("Bread").assertIsDisplayed() + rule.onNodeWithText(str(Res.string.subtask_placeholder)).assertIsDisplayed() + + pressBack() + + waitFor { countText("Milk") == 0 } + rule.onNodeWithText("Eggs").assertIsDisplayed() + // The screen is still there. + rule.onNodeWithText("Shop").assertIsDisplayed() + } + + @Test + fun theLeafsChevronOpensTheTree() { + withShoppingList() + show() + waitForText("Eggs") + + rule.onNodeWithContentDescription(str(Res.string.subtasks_show)).performClick() + + waitForText("Milk") + rule.onNodeWithContentDescription(str(Res.string.subtasks_hide)).performClick() + waitFor { countText("Milk") == 0 } + } + + @Test + fun theRowReadsItsProgressAndOffersActions() { + withShoppingList() + show() + waitForText("Shop") + val row = rule.onNodeWithText("Shop") + val config = row.fetchSemanticsNode().config + + val summary = plural(Res.plurals.subtasks_done_summary, 2, 1, 2) + assertTrue(config[SemanticsProperties.StateDescription].startsWith(summary)) + assertEquals(str(Res.string.subtasks_show), config[SemanticsActions.OnClick].label) + assertEquals( + listOf(str(Res.string.subtasks_show), str(Res.string.task_edit_placeholder), str(Res.string.subtask_add)), + config[SemanticsActions.CustomActions].map { it.label }, + ) + row.performClick() + waitFor { + row.fetchSemanticsNode().config.getOrNull(SemanticsProperties.StateDescription) + ?.endsWith(str(Res.string.subtasks_state_shown)) == true + } + assertEquals(str(Res.string.subtasks_hide), row.fetchSemanticsNode().config[SemanticsActions.OnClick].label) + } + + @Test + fun theFirstStepTurnsIntoAFieldThatAddsOnDone() { + app.withTasks(Task(id = 1, title = "Call mum")) + show() + waitForText("Call mum") + rule.onNodeWithText("Call mum").performClick() + waitForText(str(Res.string.subtask_first_step)) + + rule.onNodeWithText(str(Res.string.subtask_first_step)).performClick() + rule.waitForIdle() + val field = rule.onNode(hasSetTextAction()) + field.assertIsFocused() + field.performTextInput("Birthday") + field.performImeAction() + + waitFor { app.tasks.subtasks.value.singleOrNull()?.title == "Birthday" } + waitForText("Birthday") + // Still open, empty and focused, for the next one; the FAB is out of the way meanwhile. + rule.onNode(hasSetTextAction()).assertIsFocused() + assertEquals(0, countText(str(Res.string.task_add))) + + // Back closes the field first, and only then folds the tree. + pressBack() + waitFor { rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isEmpty() } + rule.onNodeWithText(str(Res.string.subtask_placeholder)).assertIsDisplayed() + pressBack() + waitFor { countText(str(Res.string.subtask_placeholder)) == 0 } + // Folded, it now shows its next step like any task with sub-tasks. + rule.onNodeWithText("Birthday").assertIsDisplayed() + } + + @Test + fun aDoneTaskShowsNoNextStepUntilOpened() { + app.withTasks(Task(id = 1, title = "Shop", isCompleted = true, completedDate = 1L)) + app.tasks.subtasks.value = listOf(Subtask(id = 11, taskId = 1, title = "Eggs", position = 0)) + show() + waitForText("Shop") + assertEquals(0, countText("Eggs")) + + rule.onNodeWithText("Shop").performClick() + + waitForText("Eggs") + rule.onNodeWithContentDescription("Eggs").performClick() + waitFor { app.tasks.subtasks.value.single().isCompleted } + } + + @Test + fun theEditButtonOpensTheSheet() { + withShoppingList() + show() + waitForText("Shop") + + rule.onNodeWithContentDescription(str(Res.string.task_edit_placeholder)).performClick() + + waitForText(str(Res.string.task_delete)) + // The sheet with the task's checklist. + waitForText("Milk") + } + + @Test + fun moveUpAndDownAreOnTheTitleTalkBackFocusesInTheTree() { + withShoppingList() + show() + waitForText("Shop") + rule.onNodeWithText("Shop").performClick() + waitForText("Milk") + + val milk = rule.onNode(hasText("Milk") and hasClickAction()) + val eggs = rule.onNode(hasText("Eggs") and hasClickAction()) + assertEquals(listOf(str(Res.string.tag_move_down)), customActions(milk)) + assertEquals(listOf(str(Res.string.tag_move_up)), customActions(eggs)) + + // And they move the row. + rule.runOnIdle { + milk.fetchSemanticsNode().config[SemanticsActions.CustomActions].single().action() + } + waitFor { app.tasks.subtasks.value.sortedBy { it.position }.map { it.title } == listOf("Eggs", "Milk") } + } + + @Test + fun moveUpAndDownAreOnTheDragHandlesInTheSheet() { + withShoppingList() + show() + waitForText("Shop") + rule.onNodeWithContentDescription(str(Res.string.task_edit_placeholder)).performClick() + waitForText(str(Res.string.task_delete)) + waitForText("Milk") + + val handles = rule.onAllNodesWithContentDescription(str(Res.string.tag_drag_handle)) + assertEquals(listOf(str(Res.string.tag_move_down)), customActions(handles[0])) + assertEquals(listOf(str(Res.string.tag_move_up)), customActions(handles[1])) + } + + private fun customActions(node: SemanticsNodeInteraction): List = + node.fetchSemanticsNode().config.getOrNull(SemanticsActions.CustomActions).orEmpty().map { it.label } + + private fun withShoppingList(vararg more: Subtask) { + app.withTasks(Task(id = 1, title = "Shop")) + app.tasks.subtasks.value = listOf( + Subtask(id = 10, taskId = 1, title = "Milk", isCompleted = true, position = 0), + Subtask(id = 11, taskId = 1, title = "Eggs", position = 1), + ) + more + } + + private companion object { + /** A task's own checkbox; the filter chips are checkboxes too, but with a label. */ + val isTaskCheckbox = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) and + SemanticsMatcher.keyNotDefined(SemanticsProperties.Text) + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt similarity index 79% rename from app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index 49ab236f..aa3654f4 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -1,13 +1,12 @@ package com.pasich.encly.ui.screens -import android.content.Context -import android.net.Uri import androidx.lifecycle.SavedStateHandle -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode import com.pasich.encly.core.backup.BackupCipher import com.pasich.encly.core.backup.BackupPayloadCodec import com.pasich.encly.core.backup.BackupSecret -import com.pasich.encly.core.common.AppStrings +import com.pasich.encly.core.common.ComposeAppStrings +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.untitled import com.pasich.encly.core.security.AuthSettings import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock @@ -15,23 +14,30 @@ 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.RecoveryStage +import com.pasich.encly.core.security.ScreenshotAllowance 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.core.security.bip39.Bip39 import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupMapper import com.pasich.encly.data.backup.BackupPhraseSetup +import com.pasich.encly.data.backup.NoBackupPreferences import com.pasich.encly.data.backup.PendingRestore import com.pasich.encly.data.export.MarkdownDocuments import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task +import com.pasich.encly.domain.model.EditorToolbar +import com.pasich.encly.domain.model.NotePreviews import com.pasich.encly.domain.repository.NotesRepository import com.pasich.encly.domain.usecase.OnboardingUseCase import com.pasich.encly.domain.usecase.note.CleanTrashNotesUseCase import com.pasich.encly.domain.usecase.note.ObserveNotesUseCase +import com.pasich.encly.domain.usecase.note.PurgeExpiredTrashUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteColorUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteDescriptionUseCase @@ -40,64 +46,76 @@ import com.pasich.encly.domain.usecase.note.UpdateNoteTagUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase import com.pasich.encly.domain.usecase.tag.ReorderTagsUseCase import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.DocumentRef import com.pasich.encly.presentation.viewmodel.AppearanceViewModel import com.pasich.encly.presentation.viewmodel.AuthSetupViewModel import com.pasich.encly.presentation.viewmodel.BackupViewModel import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.EditorToolbarViewModel import com.pasich.encly.presentation.viewmodel.LockNowViewModel import com.pasich.encly.presentation.viewmodel.LockViewModel import com.pasich.encly.presentation.viewmodel.MainListStateViewModel import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel import com.pasich.encly.presentation.viewmodel.NoteListViewModel +import com.pasich.encly.presentation.viewmodel.NotePreviewsViewModel import com.pasich.encly.presentation.viewmodel.NoteSearchViewModel import com.pasich.encly.presentation.viewmodel.OnboardingViewModel import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel import com.pasich.encly.presentation.viewmodel.SelectedTagHolder import com.pasich.encly.presentation.viewmodel.SettingsViewModel +import com.pasich.encly.presentation.viewmodel.StartupViewModel import com.pasich.encly.presentation.viewmodel.StatisticViewModel import com.pasich.encly.presentation.viewmodel.TagListViewModel import com.pasich.encly.presentation.viewmodel.TasksViewModel import com.pasich.encly.presentation.viewmodel.TrashViewModel -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.FakeAppLocales +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import com.pasich.encly.testutil.TestNotesRepository import com.pasich.encly.testutil.anyCharArray import com.pasich.encly.utils.DeviceCapabilities +import com.pasich.encly.whatsnew.WhatsNewTracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import org.jetbrains.compose.resources.getString import org.mockito.ArgumentMatchers import org.mockito.Mockito.mock import org.mockito.Mockito.`when` /** - * The app's data layer as fakes, and every ViewModel built on it the way Hilt would. The + * The app's data layer as fakes, and every ViewModel built on it the way the graph would. The * vault's key handling is a Mockito [SecurityManager]; tests stub what they need. */ -internal class TestApp(context: Context) { +internal class TestApp { val notes = TestNotesRepository() val tags = FakeTagsRepository() val tasks = FakeTasksRepository() val settings = FakeSettingsRepository() + val locales = FakeAppLocales() val security: SecurityManager = mock(SecurityManager::class.java) val documents: BackupDocuments = mock(BackupDocuments::class.java) val markdownDocuments: MarkdownDocuments = mock(MarkdownDocuments::class.java) val store = InMemoryVaultDataStore() - val backupManager = BackupManager(security, store, InMemorySharedPreferences()) - val sessionLock = SessionLockManager(security) + val backupManager = BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences) + val screenshots = ScreenshotAllowance() + val sessionLock = SessionLockManager(security, screenshots = screenshots) val selectedTag = SelectedTagHolder() init { // 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) + // A session that owes nothing after the recovery phrase. + `when`(security.recoveryStage()).thenReturn(RecoveryStage.NONE) } private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private val appContext = context fun withNotes(vararg notes: Note, tag: Tag? = null) = apply { - this.notes.allNotesWithTags.value = notes.map { NoteWithTag(note = it, tag = tag) } + this.notes.allNotesWithTags.value = + notes.map { NoteWithTags(it.copy(tagIds = setOfNotNull(tag?.id)), listOfNotNull(tag)) } } fun withTags(vararg tags: Tag) = apply { this.tags.tags.value = tags.toList() } @@ -115,7 +133,7 @@ internal class TestApp(context: Context) { `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 + Bip39().isValid(it.getArgument(0)) } } @@ -152,8 +170,8 @@ internal class TestApp(context: Context) { } /** A picked document named [name] holding [bytes]. */ - fun document(name: String, bytes: ByteArray): Uri { - val uri = Uri.parse("content://test/$name") + fun document(name: String, bytes: ByteArray): DocumentRef { + val uri = DocumentRef("content://test/$name") `when`(documents.read(uri)).thenReturn(bytes) `when`(documents.displayName(uri)).thenReturn(name) return uri @@ -183,10 +201,29 @@ internal class TestApp(context: Context) { fun statistics() = StatisticViewModel(tags, tasks) - fun settingsVm() = SettingsViewModel(settings) + fun settingsVm() = SettingsViewModel(settings, locales) fun mainListState() = MainListStateViewModel(settings) + val notePreviews = NotePreviews(InMemoryKeyValueStore()) + + fun notePreviewsVm() = NotePreviewsViewModel(notePreviews) + + val editorToolbar = EditorToolbar(InMemoryKeyValueStore()) + + fun toolbarVm() = EditorToolbarViewModel(editorToolbar) + + /** The start-up work of the home screen, with the trash and the version notes the tests control. */ + fun startup(tracker: WhatsNewTracker = freshInstall()) = StartupViewModel( + tracker, + PurgeExpiredTrashUseCase(notes), + ) + + /** A tracker that has already recorded the version, as after the install: no "What's new" is due. */ + private fun freshInstall() = WhatsNewTracker(InMemoryKeyValueStore(), AppInfo.Default).also { + it.recordFreshInstall() + } + fun lockNow() = LockNowViewModel(sessionLock) fun trash() = TrashViewModel(notes, UpdateNoteTrashStatusUseCase(notes), CleanTrashNotesUseCase(notes)) @@ -202,8 +239,9 @@ internal class TestApp(context: Context) { fun securitySettings() = SecuritySettingsViewModel( security, - KeyboardPrivacy(InMemorySharedPreferences()), - AutoLock(InMemorySharedPreferences()), + KeyboardPrivacy(InMemoryKeyValueStore()), + AutoLock(InMemoryKeyValueStore()), + screenshots, ) fun backup() = BackupViewModel( @@ -212,6 +250,7 @@ internal class TestApp(context: Context) { securityManager = security, sessionLockManager = sessionLock, documents = documents, + screenshots = screenshots, ) fun markdownExport() = MarkdownExportViewModel( @@ -219,7 +258,7 @@ internal class TestApp(context: Context) { sessionLockManager = sessionLock, store = store, documents = markdownDocuments, - strings = AppStrings(appContext), + strings = ComposeAppStrings(), ) private val pendingRestore by lazy { PendingRestore(backupManager) } @@ -240,12 +279,15 @@ internal class TestApp(context: Context) { // Main, not a worker: Compose tests run effects on an unconfined dispatcher, so an exit // (save, then pop) would otherwise resume on the worker and navigate from it. ioDispatcher = Dispatchers.Main, - copyTitle = { appContext.getString(com.pasich.encly.R.string.untitled) + " (Copy)" }, + copyTitle = { getString(Res.string.untitled) + " (Copy)" }, ) /** The ViewModels the home screen and its drawer, chips and task card ask for. */ fun home(noteList: NoteListViewModel = noteList()): Array = arrayOf( mainListState(), + notePreviewsVm(), + toolbarVm(), + startup(), settingsVm(), noteList, search(), diff --git a/app/src/test/java/com/pasich/encly/ui/screens/ThemedScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ThemedScreensTest.kt similarity index 92% rename from app/src/test/java/com/pasich/encly/ui/screens/ThemedScreensTest.kt rename to shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ThemedScreensTest.kt index 02ccc904..441acf2c 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/ThemedScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ThemedScreensTest.kt @@ -3,15 +3,16 @@ package com.pasich.encly.ui.screens import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color -import com.pasich.encly.R -import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_title import com.pasich.encly.data.model.Note import com.pasich.encly.data.model.Task -import com.pasich.encly.domain.model.ItemListBlock import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.screen.LockScreen import com.pasich.encly.presentation.screen.MainRootScreen @@ -27,7 +28,7 @@ import org.robolectric.ParameterizedRobolectricTestRunner /** The busiest screens in each of the five themes, light and dark: they render in that theme. */ @RunWith(ParameterizedRobolectricTestRunner::class) class ThemedScreensTest(private val palette: ThemePalette, private val dark: Boolean) : ComposeScreenTest() { - private val app by lazy { TestApp(context) } + private val app by lazy { TestApp() } private val settings get() = ThemeSettings(palette = palette) private var surface: Color = Color.Unspecified @@ -117,7 +118,7 @@ class ThemedScreensTest(private val palette: ThemePalette, private val dark: Boo probe() LockScreen(nav) } - waitForText(str(R.string.lock_title)) + waitForText(str(Res.string.lock_title)) assertThemed() } diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/WhatsNewScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/WhatsNewScreenTest.kt new file mode 100644 index 00000000..e09317e0 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/WhatsNewScreenTest.kt @@ -0,0 +1,82 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.lifecycle.ViewModel +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.whats_new_dismiss +import com.pasich.encly.core.resources.whats_new_row_desc +import com.pasich.encly.core.resources.whats_new_title +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.presentation.navigation.LocalVaultLocked +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.AboutScreen +import com.pasich.encly.presentation.screen.MainRootScreen +import com.pasich.encly.presentation.viewmodel.StartupViewModel +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.whatsnew.Changelogs +import com.pasich.encly.whatsnew.WhatsNewTracker +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The "What's new" sheet: after an update on the home screen, and from Settings → About. */ +class WhatsNewScreenTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + /** The notes of this build, in the language of the test. */ + private val heading get() = checkNotNull(Changelogs.current("en")).heading + + /** The home screen of an install that last ran an older version. */ + private fun showHomeAfterAnUpdate(locked: Boolean = false) { + val tracker = WhatsNewTracker(InMemoryKeyValueStore(), AppInfo.Default) + val others: List = app.home().filterNot { it is StartupViewModel } + val owner = viewModels(app.startup(tracker), *others.toTypedArray()) + setNavScreen(owner, route = NavRoutes.HomeRoute.name) { nav -> + CompositionLocalProvider(LocalVaultLocked provides locked) { MainRootScreen(nav) } + } + } + + @Test + fun theSheetOpensOnTheHomeScreenAfterAnUpdateAndClosesOnTheButton() { + showHomeAfterAnUpdate() + + waitForText(heading) + rule.onNodeWithText(str(Res.string.whats_new_title)).assertIsDisplayed() + + rule.onNodeWithText(str(Res.string.whats_new_dismiss)).performClick() + + waitFor { countText(heading) == 0 } + } + + @Test + fun theSheetIsNeverDrawnWhileTheVaultIsLocked() { + showHomeAfterAnUpdate(locked = true) + + rule.waitForIdle() + assertEquals(0, countText(heading)) + assertEquals(0, countText(str(Res.string.whats_new_dismiss))) + } + + @Test + fun theSheetDoesNotOpenOnTheHomeScreenOfAnInstallThatHasSeenThisVersion() { + setNavScreen(viewModels(*app.home()), route = NavRoutes.HomeRoute.name) { nav -> MainRootScreen(nav) } + + rule.waitForIdle() + assertEquals(0, countText(str(Res.string.whats_new_dismiss))) + } + + @Test + fun aboutOffersTheNotesOfThisVersion() { + setNavScreen(viewModels(), route = NavRoutes.AboutRoute.name) { nav -> AboutScreen(nav) } + waitForText(str(Res.string.whats_new_row_desc)) + + rule.onNodeWithText(str(Res.string.whats_new_row_desc)).performScrollTo().performClick() + + waitForText(heading) + rule.onNodeWithText(str(Res.string.whats_new_dismiss)).performClick() + waitFor { countText(heading) == 0 } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/utils/LocalizedDateTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/utils/LocalizedDateTest.kt new file mode 100644 index 00000000..3764926a --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/utils/LocalizedDateTest.kt @@ -0,0 +1,100 @@ +package com.pasich.encly.utils + +import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.asTimeZone +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.time.Instant + +/** + * The platform date formats give exactly what the java.text expressions they replaced gave: the + * locale picks the month names and the clock, the zone picks the day. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [32]) +class LocalizedDateTest { + private val savedZone = java.util.TimeZone.getDefault() + private val kyiv = TimeZone.of("Europe/Kyiv") + private val instant = Instant.parse("2026-10-14T19:32:53Z") + private val tags = listOf("en-US", "uk", "de", "fr", "ja") + + @Before + fun useKyiv() = java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Europe/Kyiv")) + + @After + fun restoreZone() = java.util.TimeZone.setDefault(savedZone) + + private fun reference(style: LocalizedDate, locale: Locale): String { + val format = when (style) { + LocalizedDate.TIME -> DateFormat.getTimeInstance(DateFormat.SHORT, locale) + + LocalizedDate.DATE_TIME_SHORT -> DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, locale) + + LocalizedDate.DATE_TIME_MEDIUM -> + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale) + + LocalizedDate.DAY_MONTH -> + SimpleDateFormat(android.text.format.DateFormat.getBestDateTimePattern(locale, "dMMM"), locale) + + LocalizedDate.DAY_MONTH_YEAR -> + SimpleDateFormat(android.text.format.DateFormat.getBestDateTimePattern(locale, "dMMMy"), locale) + } + return format.format(Date(instant.toEpochMilliseconds())) + } + + @Test + fun everyStyleReadsAsItDidBeforeInEveryLanguage() { + for (style in LocalizedDate.entries) { + for (tag in tags) { + assertEquals( + "$style in $tag", + reference(style, Locale.forLanguageTag(tag)), + formatLocalized(instant, style, tag, kyiv), + ) + } + } + } + + @Test + fun theTimeFollowsTheZoneAndTheLocaleClock() { + assertEquals("22:32", formatLocalized(instant, LocalizedDate.TIME, "uk", kyiv)) + assertEquals("19:32", formatLocalized(instant, LocalizedDate.TIME, "uk", TimeZone.UTC)) + assertEquals("22:32", formatLocalized(instant, LocalizedDate.TIME, "uk", kyiv)) + } + + @Test + fun arabicKeepsWesternDigitsUnlessTheTagAsksOtherwise() { + val western = formatLocalized(instant, LocalizedDate.DATE_TIME_SHORT, "ar", kyiv) + assertEquals(true, western.any { it in '0'..'9' } && western.none { it in '\u0660'..'\u0669' }) + val arabicIndic = formatLocalized(instant, LocalizedDate.TIME, "ar-u-nu-arab", kyiv) + assertEquals(true, arabicIndic.any { it in '\u0660'..'\u0669' }) + } + + @Test + fun aFixedOffsetZoneIsHonouredNotReadAsGmt() { + val plusThree = UtcOffset(hours = 3).asTimeZone() + assertEquals("22:32", formatLocalized(instant, LocalizedDate.TIME, "de", plusThree)) + assertNotEquals( + formatLocalized(instant, LocalizedDate.TIME, "de", TimeZone.UTC), + formatLocalized(instant, LocalizedDate.TIME, "de", plusThree), + ) + } + + @Test + fun theYearIsLeftOutOfTheDayAndMonthOnly() { + val dayMonth = formatLocalized(instant, LocalizedDate.DAY_MONTH, "en-US", kyiv) + assertEquals(false, dayMonth.contains("2026")) + assertEquals(true, formatLocalized(instant, LocalizedDate.DAY_MONTH_YEAR, "en-US", kyiv).contains("2026")) + } +} diff --git a/shared/src/androidMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.android.kt b/shared/src/androidMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.android.kt new file mode 100644 index 00000000..60907ac6 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.android.kt @@ -0,0 +1,4 @@ +package com.pasich.encly.presentation.designsystem + +// A hardware keyboard on Android (or ChromeOS) uses Ctrl. +internal actual fun platformUsesCommandKey(): Boolean = false diff --git a/shared/src/androidMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.android.kt b/shared/src/androidMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.android.kt new file mode 100644 index 00000000..bf0925c4 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.android.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +// Touch scrolling is the way on Android: no scrollbar is drawn. + +@Composable +actual fun BoxScope.EnclyScrollbar(state: LazyListState, modifier: Modifier) = Unit + +@Composable +actual fun BoxScope.EnclyScrollbar(state: ScrollState, modifier: Modifier) = Unit + +@Composable +actual fun BoxScope.EnclyScrollbar(state: LazyStaggeredGridState, modifier: Modifier) = Unit + +@Composable +actual fun BoxScope.EnclyHorizontalScrollbar(state: ScrollState, modifier: Modifier) = Unit diff --git a/shared/src/androidMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.android.kt b/shared/src/androidMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.android.kt new file mode 100644 index 00000000..4dadec51 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.android.kt @@ -0,0 +1,15 @@ +package com.pasich.encly.presentation.effects + +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +internal actual fun rememberAnimationsOff(): Boolean { + val resolver = LocalContext.current.contentResolver + return remember(resolver) { + runCatching { Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f } + .getOrDefault(false) + } +} diff --git a/shared/src/androidMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.android.kt b/shared/src/androidMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.android.kt new file mode 100644 index 00000000..daf99e06 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.android.kt @@ -0,0 +1,18 @@ +package com.pasich.encly.presentation.effects + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal actual fun rememberImeVisible(): Boolean = WindowInsets.isImeVisible + +@SuppressLint("ConfigurationScreenWidthHeight") +@Composable +internal actual fun windowWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp diff --git a/shared/src/androidMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.android.kt b/shared/src/androidMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.android.kt new file mode 100644 index 00000000..e6b6c85d --- /dev/null +++ b/shared/src/androidMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.android.kt @@ -0,0 +1,4 @@ +package com.pasich.encly.ui.theme + +/** Android devices are touch first, tablets included. */ +internal actual fun isPointerFirstDevice(): Boolean = false diff --git a/shared/src/androidMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.android.kt b/shared/src/androidMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.android.kt new file mode 100644 index 00000000..3f0f96a6 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.android.kt @@ -0,0 +1,15 @@ +package com.pasich.encly.ui.theme + +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +internal actual fun wallpaperColorScheme(dark: Boolean): ColorScheme? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + val context = LocalContext.current + return if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) +} diff --git a/shared/src/androidMain/kotlin/com/pasich/encly/utils/LocalizedDate.android.kt b/shared/src/androidMain/kotlin/com/pasich/encly/utils/LocalizedDate.android.kt new file mode 100644 index 00000000..ff78544f --- /dev/null +++ b/shared/src/androidMain/kotlin/com/pasich/encly/utils/LocalizedDate.android.kt @@ -0,0 +1,20 @@ +package com.pasich.encly.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalResources +import kotlinx.datetime.TimeZone +import java.util.Locale +import kotlin.time.Instant + +@Composable +internal actual fun currentLanguageTag(): String = + (LocalResources.current.configuration.locales[0] ?: Locale.ENGLISH).toLanguageTag() + +internal actual fun formatLocalized( + instant: Instant, + style: LocalizedDate, + languageTag: String, + zone: TimeZone, +): String = formatLocalizedJvm(instant, style, languageTag, zone) { locale, skeleton -> + android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton) +} diff --git a/shared/src/commonMain/composeResources/drawable/ic_launcher_foreground.xml b/shared/src/commonMain/composeResources/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..ea4683e8 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/font/ibm_plex_sans_variable.ttf b/shared/src/commonMain/composeResources/font/ibm_plex_sans_variable.ttf similarity index 100% rename from app/src/main/res/font/ibm_plex_sans_variable.ttf rename to shared/src/commonMain/composeResources/font/ibm_plex_sans_variable.ttf diff --git a/app/src/main/res/font/playfair_display_variable.ttf b/shared/src/commonMain/composeResources/font/playfair_display_variable.ttf similarity index 100% rename from app/src/main/res/font/playfair_display_variable.ttf rename to shared/src/commonMain/composeResources/font/playfair_display_variable.ttf diff --git a/app/src/main/res/font/poppins_bold.ttf b/shared/src/commonMain/composeResources/font/poppins_bold.ttf similarity index 100% rename from app/src/main/res/font/poppins_bold.ttf rename to shared/src/commonMain/composeResources/font/poppins_bold.ttf diff --git a/app/src/main/res/font/poppins_medium.ttf b/shared/src/commonMain/composeResources/font/poppins_medium.ttf similarity index 100% rename from app/src/main/res/font/poppins_medium.ttf rename to shared/src/commonMain/composeResources/font/poppins_medium.ttf diff --git a/app/src/main/res/font/poppins_regular.ttf b/shared/src/commonMain/composeResources/font/poppins_regular.ttf similarity index 100% rename from app/src/main/res/font/poppins_regular.ttf rename to shared/src/commonMain/composeResources/font/poppins_regular.ttf diff --git a/app/src/main/res/font/source_sans3_variable.ttf b/shared/src/commonMain/composeResources/font/source_sans3_variable.ttf similarity index 100% rename from app/src/main/res/font/source_sans3_variable.ttf rename to shared/src/commonMain/composeResources/font/source_sans3_variable.ttf diff --git a/app/src/main/java/com/pasich/encly/core/Constant.kt b/shared/src/commonMain/kotlin/com/pasich/encly/core/Constant.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/core/Constant.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/core/Constant.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/core/common/UiTextResolve.kt b/shared/src/commonMain/kotlin/com/pasich/encly/core/common/UiTextResolve.kt new file mode 100644 index 00000000..c857397e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/common/UiTextResolve.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.core.common + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource + +/** The text in the composition's locale, so it follows the in-app language. */ +@Composable +fun UiText.asString(): String = when (this) { + is UiText.Resource -> stringResource(id) +} + +/** The text in the process's current locale, which is the in-app language (see AppLocales). */ +suspend fun UiText.resolve(): String = when (this) { + is UiText.Resource -> getString(id) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/core/di/AppViewModelFactory.kt b/shared/src/commonMain/kotlin/com/pasich/encly/core/di/AppViewModelFactory.kt new file mode 100644 index 00000000..cf7ad228 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/di/AppViewModelFactory.kt @@ -0,0 +1,40 @@ +package com.pasich.encly.core.di + +import androidx.lifecycle.ViewModel +import com.pasich.encly.presentation.viewmodel.SelectedTagHolder +import com.pasich.encly.presentation.viewmodel.WindowSelections +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import kotlin.reflect.KClass + +/** + * Creates the app's ViewModels from the graph's multibindings (see `MetroViewModelFactory`). The same factory + * serves the Android and the desktop graph, which both pick it up from `AppScope`. + */ +@Inject +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class AppViewModelFactory( + override val viewModelProviders: Map, () -> ViewModel>, + override val assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, + override val manualAssistedFactoryProviders: + Map, () -> ManualViewModelAssistedFactory>, +) : MetroViewModelFactory() { + /** + * The same ViewModels for one window of a host with several: every ViewModel it creates gets [selectedTag], the + * tag filter of that window, instead of the one the single-window hosts share. + */ + fun forWindow(selectedTag: SelectedTagHolder): MetroViewModelFactory = object : MetroViewModelFactory() { + override val viewModelProviders: Map, () -> ViewModel> = + this@AppViewModelFactory.viewModelProviders.mapValues { (_, provider) -> + { WindowSelections.creatingFor(selectedTag) { provider() } } + } + override val assistedFactoryProviders = this@AppViewModelFactory.assistedFactoryProviders + override val manualAssistedFactoryProviders = this@AppViewModelFactory.manualAssistedFactoryProviders + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/core/di/CoroutineModule.kt b/shared/src/commonMain/kotlin/com/pasich/encly/core/di/CoroutineModule.kt new file mode 100644 index 00000000..9ead78d7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/di/CoroutineModule.kt @@ -0,0 +1,53 @@ +package com.pasich.encly.core.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.Qualifier +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob + +/** + * Qualifier for the application-lifetime [CoroutineScope]. + * + * Use it for fire-and-forget work that MUST outlive the caller's own scope — + * e.g. persisting a note during ViewModel.onCleared(), where [viewModelScope] is + * already cancelled and any coroutine launched in it would be dropped. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope + +/** + * Qualifier for the dispatcher that blocking I/O (Room/SQLCipher) runs on. + * + * Injected instead of referencing [platformIoDispatcher] directly so unit tests can run that work on + * their test dispatcher: a real I/O thread outlives the test and resumes onto a + * Dispatchers.Main that was already reset. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +/** Provides coroutine infrastructure that lives for the whole application process. */ +@ContributesTo(AppScope::class) +@BindingContainer +object CoroutineModule { + + /** + * A [SupervisorJob]-backed scope on [platformIoDispatcher]. A failure in one child does + * not cancel the others, and the scope is never cancelled (it is tied to the + * application process), so work launched here survives ViewModel teardown. + */ + @Provides + @SingleIn(AppScope::class) + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + platformIoDispatcher) + + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher = platformIoDispatcher +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt b/shared/src/commonMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt new file mode 100644 index 00000000..7cdb4760 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt @@ -0,0 +1,6 @@ +package com.pasich.encly.core.di + +import kotlinx.coroutines.CoroutineDispatcher + +/** The dispatcher for blocking work (disk, crypto, the database): `Dispatchers.IO`, which common code cannot name. */ +internal expect val platformIoDispatcher: CoroutineDispatcher diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/core/locale/AppLanguage.kt b/shared/src/commonMain/kotlin/com/pasich/encly/core/locale/AppLanguage.kt new file mode 100644 index 00000000..5abd0933 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/locale/AppLanguage.kt @@ -0,0 +1,51 @@ +package com.pasich.encly.core.locale + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.language_name_ar +import com.pasich.encly.core.resources.language_name_de +import com.pasich.encly.core.resources.language_name_en +import com.pasich.encly.core.resources.language_name_es +import com.pasich.encly.core.resources.language_name_fr +import com.pasich.encly.core.resources.language_name_it +import com.pasich.encly.core.resources.language_name_nl +import com.pasich.encly.core.resources.language_name_pl +import com.pasich.encly.core.resources.language_name_pt +import com.pasich.encly.core.resources.language_name_uk +import com.pasich.encly.core.resources.language_system_default +import org.jetbrains.compose.resources.StringResource + +/** + * Languages the UI is translated into. [tag] matches the `values-` resource folder and + * res/xml/locales_config.xml; [nativeName] is the language's own name, which is what the + * picker shows so a user can find their language whatever the current UI language is. + * + * [rightToLeft] languages are laid out right to left (see `appLayoutDirection`). + * + * [SYSTEM] clears the per-app override and follows the device language. + */ +enum class AppLanguage(val tag: String?, val nativeName: StringResource, val rightToLeft: Boolean = false) { + SYSTEM(null, Res.string.language_system_default), + ENGLISH("en", Res.string.language_name_en), + UKRAINIAN("uk", Res.string.language_name_uk), + GERMAN("de", Res.string.language_name_de), + FRENCH("fr", Res.string.language_name_fr), + SPANISH("es", Res.string.language_name_es), + ITALIAN("it", Res.string.language_name_it), + POLISH("pl", Res.string.language_name_pl), + PORTUGUESE("pt", Res.string.language_name_pt), + DUTCH("nl", Res.string.language_name_nl), + ARABIC("ar", Res.string.language_name_ar, rightToLeft = true), + ; + + companion object { + /** + * Maps a BCP 47 tag (e.g. "de", "pt-BR", "uk-UA") to a supported language by its + * primary subtag. A null/blank or unsupported tag means no supported override: SYSTEM. + */ + fun fromTag(tag: String?): AppLanguage { + val language = tag?.substringBefore('-')?.substringBefore('_')?.lowercase() + if (language.isNullOrBlank()) return SYSTEM + return entries.firstOrNull { it.tag == language } ?: SYSTEM + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/core/security/SecureDatabaseVault.kt b/shared/src/commonMain/kotlin/com/pasich/encly/core/security/SecureDatabaseVault.kt new file mode 100644 index 00000000..09b03e51 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/security/SecureDatabaseVault.kt @@ -0,0 +1,37 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.data.database.VaultFile +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject + +/** + * [VaultDatabase] on the app's [SecureDatabaseManager]: the security core opens, closes and deletes the + * encrypted database through this port and never sees Room or the cipher. Shared by the Android and desktop + * apps, which both get the [VaultDatabase] binding from it. + */ +@Inject +@ContributesBinding(AppScope::class) +class SecureDatabaseVault(private val manager: SecureDatabaseManager) : VaultDatabase { + override fun isDatabaseUnlocked(): Boolean = manager.isDatabaseUnlocked() + + override fun hasEncryptedDatabase(vault: VaultKind): Boolean = manager.hasEncryptedDatabase(vault.file()) + + override fun unlockDatabase(dek: ByteArray, allowCreate: Boolean): Boolean = + manager.unlockDatabase(dek, allowCreate) + + override fun unlockDatabase(dek: ByteArray, allowCreate: Boolean, vault: VaultKind): Boolean = + manager.unlockDatabase(dek, allowCreate, vault.file()) + + override fun reset() = manager.reset() + + override fun wipe() = manager.wipe() + + override fun wipeIfClosed(only: VaultKind?): Boolean = manager.wipeIfClosed(only?.file()) + + private fun VaultKind.file(): VaultFile = when (this) { + VaultKind.PRIMARY -> VaultFile.PRIMARY + VaultKind.DECOY -> VaultFile.DECOY + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupDocuments.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupDocuments.kt new file mode 100644 index 00000000..3d675485 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupDocuments.kt @@ -0,0 +1,118 @@ +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.platform.DocumentRef +import com.pasich.encly.platform.PlatformDocuments +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.buffered +import kotlinx.io.readTo + +/** + * Reads and writes backup files at a [DocumentRef] the user picked (the system's document dialog). Only the + * already encrypted container passes through here: no plaintext and no temp file is ever + * written, and no storage permission is needed. + */ +@SingleIn(AppScope::class) +@Inject +class BackupDocuments(private val documents: PlatformDocuments) { + fun read(doc: DocumentRef): ByteArray = io { + documents.openRead(doc).orFail().use { readCapped(it) } + } + + /** The document's display name, for showing which file was picked; null when unknown. */ + fun displayName(doc: DocumentRef): String? = runCatching { documents.displayName(doc) }.getOrNull() + + /** + * Writes [file] to a document the picker just created, then reads it back to check it. + * + * An existing, non-empty document is refused with [BackupError.TARGET_EXISTS] instead of + * being overwritten: truncating it first would destroy the previous backup whenever the new + * write then fails (storage full, provider error, revoked grant). On any failure the + * incomplete new document is deleted where the provider allows it. + */ + fun write(doc: DocumentRef, file: ByteArray) = io { + if (hasContent(doc)) throw BackupException(BackupError.TARGET_EXISTS) + var complete = false + try { + documents.openWrite(doc).orFail().buffered().use { it.write(file) } + val written = documents.openRead(doc).orFail().use { readCapped(it) } + if (!written.contentEquals(file)) throw BackupException(BackupError.IO) + complete = true + } finally { + if (!complete) discard(doc) + } + } + + private fun hasContent(doc: DocumentRef): Boolean = + documents.openRead(doc)?.use { it.readAtMostTo(Buffer(), 1) >= 0 } ?: false + + /** Best effort: a provider may not support deleting, and the write error is what matters. */ + private fun discard(doc: DocumentRef) { + runCatching { documents.delete(doc) } + } + + private fun T?.orFail(): T = this ?: throw BackupException(BackupError.IO) + + /** + * Maps every provider failure to [BackupError.IO]. Third-party DocumentsProviders also throw + * RuntimeExceptions across Binder (IllegalArgumentException "Unknown URI", unsupported mode, + * IllegalStateException, SecurityException when the grant was revoked...). Their messages are + * dropped: they can quote the document path. + */ + private inline fun io(block: () -> T): T = try { + block() + } catch (_: IOException) { + throw BackupException(BackupError.IO) + } catch (_: RuntimeException) { + throw BackupException(BackupError.IO) + } + + internal companion object { + private const val BUFFER_BYTES = 64 * 1024 + + /** + * Reads one backup file of at most [limit] bytes. The buffer is sized from the header's + * ciphertext length, so the file is held in memory exactly once. Anything that is not a + * backup, declares more than [limit] bytes, or runs past its declared end is rejected + * without reading the rest; a file shorter than declared is returned for + * [BackupFormat.parse] to report as truncated. [input] is read unbuffered, so nothing + * past what was needed is taken from the provider. + */ + fun readCapped(input: RawSource, limit: Int = BackupFormat.MAX_FILE_BYTES): ByteArray { + val head = ByteArray(BackupFormat.HEADER_LENGTH) + val headRead = fill(input, head, 0) + val file = if (headRead < head.size) { + head.copyOf(headRead) + } else { + val header = BackupFormat.parseHeader(head, limit.toLong()) + head.copyOf(BackupFormat.HEADER_LENGTH + header.ciphertextLength) + } + val filled = fill(input, file, headRead) + return when { + filled < file.size -> file.copyOf(filled) + input.readAtMostTo(Buffer(), 1) >= 0 -> throw BackupException(BackupError.CORRUPTED) + else -> file + } + } + + /** Reads into [target] from [from] until it is full or the stream ends; returns the end. */ + private fun fill(input: RawSource, target: ByteArray, from: Int): Int { + val chunk = Buffer() + var position = from + while (position < target.size) { + val read = input.readAtMostTo(chunk, minOf(BUFFER_BYTES, target.size - position).toLong()).toInt() + if (read < 0) break + chunk.readTo(target, position, position + read) + position += read + } + return position + } + } +} diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt similarity index 92% rename from app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt index 9a059dd7..f256cc0c 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt @@ -7,10 +7,10 @@ import com.pasich.encly.core.backup.BackupSubtask import com.pasich.encly.core.backup.BackupTag import com.pasich.encly.core.backup.BackupTask import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task -import java.util.Locale enum class ImportMode { /** @@ -47,7 +47,11 @@ data class ImportSummary( /** Vault rows <-> [BackupPayload]. Links travel as uids, never as local autoincrement ids. */ object BackupMapper { - fun toPayload(snapshot: VaultSnapshot, exportedAt: Long): BackupPayload { + fun toPayload( + snapshot: VaultSnapshot, + exportedAt: Long, + settings: Map = emptyMap(), + ): BackupPayload { val tagUidById = snapshot.tags.associate { it.id to it.uid } val subtasksByTask = snapshot.subtasks.groupBy { it.taskId } return BackupPayload( @@ -61,7 +65,7 @@ object BackupMapper { description = note.description, date = note.date, dateCreate = note.dateCreate, - tagUid = note.tagId?.let(tagUidById::get), + tagUids = snapshot.tagsOf(note.id).map { it.uid }, isTrash = note.isTrash, isPinned = note.isPinned, isArchived = note.isArchived, @@ -86,6 +90,7 @@ object BackupMapper { .map { BackupSubtask(it.uid, it.title, it.isCompleted, it.position) }, ) }, + settings = settings, ).also(BackupPayloadCodec::validate) } } @@ -115,7 +120,11 @@ object BackupImporter { val noteUids = existing.notes.mapTo(HashSet()) { it.uid } val newNotes = payload.notes.filter { it.uid !in noteUids } - newNotes.forEach { store.insertNote(it.toEntity(tagIds)) } + newNotes.forEach { note -> + val noteId = store.insertNote(note.toEntity()) + // Two backup tags can be one tag here (matched by name): each link is written once. + note.tagUids.mapNotNull(tagIds::get).distinct().forEach { store.insertNoteTag(NoteTag(noteId, it)) } + } val taskIds = existing.tasks.associate { it.uid to it.id } val newTasks = payload.tasks.filter { it.uid !in taskIds } @@ -158,7 +167,7 @@ object BackupImporter { // Name -> local id; the first tag with a name wins, also for tags added below. val idsByName = HashMap() if (target.match == TagMatch.UID_OR_NAME) { - existing.tags.forEach { idsByName.putIfAbsent(tagNameKey(it.nameTag), it.id) } + existing.tags.forEach { idsByName.getOrPut(tagNameKey(it.nameTag)) { it.id } } } var added = 0 tags.sortedBy { it.position }.forEach { tag -> @@ -173,7 +182,7 @@ object BackupImporter { Tag(nameTag = tag.name, isVisible = tag.visible, position = position, uid = tag.uid), ) target.tagIds[tag.uid] = id - if (target.match == TagMatch.UID_OR_NAME) idsByName.putIfAbsent(tagNameKey(tag.name), id) + if (target.match == TagMatch.UID_OR_NAME) idsByName.getOrPut(tagNameKey(tag.name)) { id } added++ } return added @@ -216,15 +225,14 @@ object BackupImporter { ) /** Two tag names are the same tag for [TagMatch.UID_OR_NAME] when their keys are equal. */ - fun tagNameKey(name: String): String = name.trim().lowercase(Locale.ROOT) + fun tagNameKey(name: String): String = name.trim().lowercase() - private fun BackupNote.toEntity(tagIds: Map) = Note( + private fun BackupNote.toEntity() = Note( title = title, value = value, description = description, date = date, dateCreate = dateCreate, - tagId = tagUid?.let(tagIds::get), isTrash = isTrash, uid = uid, // An archived note is never pinned, whatever the file says. diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupManager.kt similarity index 76% rename from app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupManager.kt index 69fdc484..8ea06ede 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupManager.kt @@ -1,48 +1,56 @@ package com.pasich.encly.data.backup -import android.content.SharedPreferences -import androidx.core.content.edit import com.pasich.encly.core.AppLogger import com.pasich.encly.core.backup.BackupCipher import com.pasich.encly.core.backup.BackupError import com.pasich.encly.core.backup.BackupException +import com.pasich.encly.core.backup.BackupFlags import com.pasich.encly.core.backup.BackupFormat import com.pasich.encly.core.backup.BackupHeader import com.pasich.encly.core.backup.BackupKeys import com.pasich.encly.core.backup.BackupPayload import com.pasich.encly.core.backup.BackupPayloadCodec import com.pasich.encly.core.backup.BackupSecret +import com.pasich.encly.core.backup.BackupSettings +import com.pasich.encly.core.common.currentTimeMillis +import com.pasich.encly.core.platform.KeyValueStore import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.CancellationException -import javax.inject.Inject -import javax.inject.Singleton /** * Encrypted export/import of the whole vault. Files are built and read fully in memory and * handed to/from the Storage Access Framework by the UI: no temp files, no permissions, no * network. Plaintext payload buffers and derived keys are zeroized after use. */ -@Singleton -class BackupManager @Inject constructor( +@SingleIn(AppScope::class) +@Inject +class BackupManager( private val securityManager: SecurityManager, private val store: VaultDataStore, - private val secureStoragePrefs: SharedPreferences, + private val secureStoragePrefs: KeyValueStore, + private val preferences: BackupPreferences, ) { - internal var clock: () -> Long = System::currentTimeMillis + internal var clock: () -> Long = ::currentTimeMillis /** The import limit, which export enforces too; replaceable in tests. */ internal var maxFileBytes: Int = BackupFormat.MAX_FILE_BYTES /** - * Seals every note (trash included), tag and task of the unlocked vault. Throws + * Seals every note (trash included), tag and task of the unlocked vault, with the user's + * preferences ([BackupSettings.included] only; never a PIN, lock or install state). Throws * [BackupError.TOO_LARGE] instead of producing a file larger than import accepts, which * would look like a good backup but could never be restored. */ suspend fun createBackup(): ByteArray { realVaultOnly() val boundStore = guarded { store.bindToCurrentSession() } - val payload = guarded { BackupMapper.toPayload(boundStore.snapshot(), clock()) } + val payload = guarded { + BackupMapper.toPayload(boundStore.snapshot(), clock(), preferences.snapshotOrEmpty()) + } val root = securityManager.copyBackupRootKey() ?: throw BackupException(BackupError.VAULT_UNAVAILABLE) return try { @@ -88,10 +96,17 @@ class BackupManager @Inject constructor( /** * Imports into the unlocked vault in one transaction; nothing is written on failure. Refused * in the decoy vault. + * + * Only [ImportMode.REPLACE] also restores the backup's preferences ("make this install like + * the backup": a fresh install, or an explicit replace). A merge keeps the preferences of + * the install it lands in. They are applied after the notes were imported, so a failed import + * changes none; a preference that cannot be applied is skipped and never fails the restore. */ suspend fun import(payload: BackupPayload, mode: ImportMode, tagMatch: TagMatch = TagMatch.UID): ImportSummary { realVaultOnly() - return guarded { BackupImporter.import(payload, mode, store.bindToCurrentSession(), tagMatch) } + val summary = guarded { BackupImporter.import(payload, mode, store.bindToCurrentSession(), tagMatch) } + if (mode == ImportMode.REPLACE && payload.settings.isNotEmpty()) preferences.applyQuietly(payload.settings) + return summary } /** @@ -123,6 +138,6 @@ class BackupManager @Inject constructor( private const val TAG = "BackupManager" /** In the app flags; a wipe-PIN erase removes it with the vault it described. */ - internal const val LAST_EXPORT_KEY = "backup_last_export_at" + internal const val LAST_EXPORT_KEY = BackupFlags.LAST_EXPORT_KEY } } diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupPhraseSetup.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPhraseSetup.kt similarity index 86% rename from app/src/main/java/com/pasich/encly/data/backup/BackupPhraseSetup.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPhraseSetup.kt index 5af1a6be..4f664cc0 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupPhraseSetup.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPhraseSetup.kt @@ -1,16 +1,18 @@ package com.pasich.encly.data.backup import com.pasich.encly.core.security.SecurityManager -import javax.inject.Inject -import javax.inject.Singleton +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn /** * The recovery phrase an export is sealed to. Backups have no separate passphrase (see * SECURITY.md), so a vault must have a recovery phrase, and its backup key, before it can * export. Every call needs an unlocked session. */ -@Singleton -class BackupPhraseSetup @Inject constructor(private val securityManager: SecurityManager) { +@SingleIn(AppScope::class) +@Inject +class BackupPhraseSetup(private val securityManager: SecurityManager) { fun hasRecoveryPhrase(): Boolean = securityManager.hasRecoverySeed() /** A recovery-seed vault created before backup keys existed must confirm its words once. */ diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPreferences.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPreferences.kt new file mode 100644 index 00000000..035bcab7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPreferences.kt @@ -0,0 +1,193 @@ +package com.pasich.encly.data.backup + +import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.backup.BackupSettings +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.security.AutoLock +import com.pasich.encly.core.security.AutoLockDelay +import com.pasich.encly.core.security.KeyboardPrivacy +import com.pasich.encly.domain.enums.NoteSortOption +import com.pasich.encly.domain.model.EditorToolbar +import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.NoteLineSpacing +import com.pasich.encly.domain.model.NotePreviews +import com.pasich.encly.domain.model.SwipeAction +import com.pasich.encly.domain.model.SwipeSide +import com.pasich.encly.domain.model.ThemePalette +import com.pasich.encly.domain.model.ThemeType +import com.pasich.encly.domain.model.ToolbarLayout +import com.pasich.encly.domain.repository.SettingsRepository +import com.pasich.encly.platform.AppLocaleController +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first + +/** + * The user's preferences as a backup carries them: [BackupSettings.included] keys, string values. + * Kept apart from [BackupManager] so the vault logic does not know where each preference lives. + */ +interface BackupPreferences { + /** The current value of every backed-up preference. Never holds security state. */ + suspend fun snapshot(): Map + + /** + * Applies the entries of [raw] this version understands ([BackupSettings.accepted]). One + * that cannot be applied is skipped; this never throws for a bad value. + */ + suspend fun apply(raw: Map) +} + +/** The preferences to export; none, rather than a failed export, if they cannot be read. Allow-listed keys only. */ +internal suspend fun BackupPreferences.snapshotOrEmpty(): Map = try { + snapshot().filterKeys { it in BackupSettings.included } +} catch (e: CancellationException) { + throw e +} catch (_: RuntimeException) { + AppLogger.w("BackupPreferences", "Backup without settings: they could not be read") + emptyMap() +} + +/** Restores [raw]; a failure is logged and never fails the restore that called it. */ +internal suspend fun BackupPreferences.applyQuietly(raw: Map) { + try { + apply(raw) + } catch (e: CancellationException) { + throw e + } catch (_: RuntimeException) { + AppLogger.w("BackupPreferences", "Settings were not restored") + } +} + +/** No preferences: tests, and a host that has none. */ +object NoBackupPreferences : BackupPreferences { + override suspend fun snapshot(): Map = emptyMap() + + override suspend fun apply(raw: Map) = Unit +} + +/** + * The app's preference stores behind [BackupPreferences]: the settings repository (DataStore), the + * auto-lock delay, strict keyboard privacy, note previews and the editor toolbar (flag store, held in memory + * by their own classes, so they are set through those), and the in-app language. + */ +@Inject +@ContributesBinding(AppScope::class) +@Suppress("LongParameterList") // One collaborator per place a preference lives. +class SettingsBackup( + private val settings: SettingsRepository, + private val autoLock: AutoLock, + private val keyboardPrivacy: KeyboardPrivacy, + private val notePreviews: NotePreviews, + private val locales: AppLocaleController, + private val toolbar: EditorToolbar, +) : BackupPreferences { + + override suspend fun snapshot(): Map { + val theme = settings.themeSettingsFlow.first() + val swipe = settings.swipeSettingsFlow.first() + val all = linkedMapOf( + BackupSettings.IS_DYNAMIC_THEME to theme.dynamic.toString(), + BackupSettings.THEME_TYPE to theme.type.name, + BackupSettings.THEME_PALETTE to theme.palette.name, + BackupSettings.FONT_STYLE to theme.fontStyle.name, + BackupSettings.FONT_SIZE to settings.fontSizeFlow.first().toString(), + BackupSettings.NOTE_LINE_SPACING to settings.noteLineSpacingFlow.first().name, + BackupSettings.IS_GRID_NOTE_LIST to settings.isGridNoteList.first().toString(), + BackupSettings.SORT to settings.getSortNotes.first().name, + BackupSettings.SHOW_TASKS to settings.showTasksFlow.first().toString(), + BackupSettings.SIMPLE_EDIT to settings.simpleEditFlow.first().toString(), + BackupSettings.SWIPE_ENABLED to swipe.enabled.toString(), + BackupSettings.SWIPE_RIGHT to swipe.right.name, + BackupSettings.SWIPE_LEFT to swipe.left.name, + BackupSettings.AUTO_LOCK_DELAY to autoLock.delay.value.name, + BackupSettings.KEYBOARD_STRICT_PRIVACY to keyboardPrivacy.strict.value.toString(), + BackupSettings.SHOW_NOTE_PREVIEWS to notePreviews.show.value.toString(), + BackupSettings.APP_LANGUAGE to locales.current().name, + BackupSettings.EDITOR_TOOLBAR to toolbar.layout.value.encode(), + ) + // Only what the allow-list names, whatever else is added above by mistake. + return all.filterKeys { it in BackupSettings.included } + } + + override suspend fun apply(raw: Map) { + val values = BackupSettings.accepted(raw) + applyEach(values) + // Last: on Android a language change recreates the running screen. + values[BackupSettings.APP_LANGUAGE]?.let(::languageOf)?.let { language -> + skippingFailure(BackupSettings.APP_LANGUAGE) { if (language != locales.current()) locales.apply(language) } + } + } + + private suspend fun applyEach(values: Map) { + values.forEach { (key, value) -> + skippingFailure(key) { applyOne(key, value) } + } + } + + @Suppress("CyclomaticComplexMethod") // One branch per backed-up preference. + private suspend fun applyOne(key: String, value: String) { + when (key) { + BackupSettings.IS_DYNAMIC_THEME -> settings.setDynamicTheme(value.toBoolean()) + + BackupSettings.THEME_TYPE -> named(value)?.let { settings.setThemeType(it) } + + BackupSettings.THEME_PALETTE -> named(value)?.let { settings.setThemePalette(it) } + + BackupSettings.FONT_STYLE -> named(value)?.let { settings.setFontStyle(it) } + + BackupSettings.FONT_SIZE -> value.toIntOrNull()?.let { settings.setFontSize(it) } + + BackupSettings.NOTE_LINE_SPACING -> named(value)?.let { settings.setNoteLineSpacing(it) } + + BackupSettings.IS_GRID_NOTE_LIST -> settings.setGridNoteList(value.toBoolean()) + + BackupSettings.SORT -> named(value)?.let { settings.setSortNotes(it) } + + BackupSettings.SHOW_TASKS -> settings.setShowTasks(value.toBoolean()) + + BackupSettings.SIMPLE_EDIT -> settings.setSimpleEdit(value.toBoolean()) + + BackupSettings.SWIPE_ENABLED -> settings.setSwipeEnabled(value.toBoolean()) + + BackupSettings.SWIPE_RIGHT -> swipeAction(SwipeSide.RIGHT, value) + + BackupSettings.SWIPE_LEFT -> swipeAction(SwipeSide.LEFT, value) + + BackupSettings.AUTO_LOCK_DELAY -> named(value)?.let(autoLock::setDelay) + + BackupSettings.KEYBOARD_STRICT_PRIVACY -> keyboardPrivacy.setStrict(value.toBoolean()) + + BackupSettings.SHOW_NOTE_PREVIEWS -> notePreviews.setShow(value.toBoolean()) + + BackupSettings.EDITOR_TOOLBAR -> toolbar.set(ToolbarLayout.decode(value)) + + // The language is applied after the others (see [apply]); any other key is not ours. + else -> Unit + } + } + + private suspend fun swipeAction(side: SwipeSide, value: String) { + named(value)?.let { settings.setSwipeAction(side, it) } + } + + private fun languageOf(value: String): AppLanguage? = named(value) + + private inline fun > named(value: String): T? = enumValues().firstOrNull { it.name == value } + + private suspend fun skippingFailure(key: String, block: suspend () -> Unit) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (_: RuntimeException) { + // A preference that cannot be written is only a preference; the restore goes on. + AppLogger.w(TAG, "Skipped restoring setting $key") + } + } + + private companion object { + const val TAG = "SettingsBackup" + } +} diff --git a/app/src/main/java/com/pasich/encly/data/backup/PendingRestore.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/PendingRestore.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/data/backup/PendingRestore.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/backup/PendingRestore.kt index 825a74fc..a2d8adcd 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/PendingRestore.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/PendingRestore.kt @@ -2,8 +2,10 @@ package com.pasich.encly.data.backup import com.pasich.encly.core.backup.BackupException import com.pasich.encly.core.backup.BackupPayload -import javax.inject.Inject -import javax.inject.Singleton +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlin.concurrent.Volatile /** * A backup decrypted by onboarding's "Restore from backup", held in memory until first-run @@ -11,8 +13,9 @@ import javax.inject.Singleton * committed, so a process killed meanwhile leaves no committed (empty) vault: onboarding simply * starts over. It is never written anywhere else. */ -@Singleton -class PendingRestore @Inject constructor(private val backupManager: BackupManager) { +@SingleIn(AppScope::class) +@Inject +class PendingRestore(private val backupManager: BackupManager) { @Volatile private var payload: BackupPayload? = null diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/RoomVaultDataStore.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/RoomVaultDataStore.kt new file mode 100644 index 00000000..4a04e536 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/RoomVaultDataStore.kt @@ -0,0 +1,85 @@ +package com.pasich.encly.data.backup + +import androidx.room.deferredTransaction +import androidx.room.immediateTransaction +import androidx.room.useReaderConnection +import androidx.room.useWriterConnection +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.NoteTag +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Tag +import com.pasich.encly.data.model.Task +import dev.zacsweers.metro.Inject + +/** [VaultDataStore] over a single SQLCipher/Room opening. */ +@Suppress("TooManyFunctions") // One function per vault table operation of the contract. +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")) + + // One read transaction: every table is read from the same state of the database. + override suspend fun snapshot(): VaultSnapshot = database().let { db -> + db.useReaderConnection { + it.deferredTransaction { + VaultSnapshot( + notes = db.backupDao().allNotes(), + tags = db.backupDao().allTags(), + tasks = db.backupDao().allTasks(), + subtasks = db.backupDao().allSubtasks(), + noteTags = db.backupDao().allNoteTags(), + ) + } + } + } + + override suspend fun snapshotOfNote(noteId: Long): VaultSnapshot = database().let { db -> + db.useReaderConnection { + it.deferredTransaction { + val note = db.notesDao().getNoteById(noteId) + VaultSnapshot( + notes = listOfNotNull(note), + tags = if (note == null) emptyList() else db.backupDao().tagsOfNote(noteId), + tasks = emptyList(), + noteTags = if (note == null) emptyList() else db.backupDao().noteTagsOf(noteId), + ) + } + } + } + + override suspend fun inTransaction(block: suspend () -> R): R = + database().useWriterConnection { it.immediateTransaction { block() } } + + override suspend fun deleteAll() { + val dao = database().backupDao() + // The foreign keys would cascade the links with their notes; deleted explicitly all the same. + dao.deleteAllNoteTags() + dao.deleteAllNotes() + // The foreign key would cascade them with their tasks; deleted explicitly all the same. + dao.deleteAllSubtasks() + dao.deleteAllTasks() + dao.deleteAllTags() + } + + override suspend fun insertTag(tag: Tag): Long = database().backupDao().insertTag(tag) + + override suspend fun insertNote(note: Note): Long = database().backupDao().insertNote(note) + + override suspend fun insertNoteTag(link: NoteTag) = database().backupDao().insertNoteTag(link) + + override suspend fun insertTask(task: Task): Long = database().backupDao().insertTask(task) + + override suspend fun insertSubtask(subtask: Subtask): Long = database().backupDao().insertSubtask(subtask) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/VaultDataStore.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/VaultDataStore.kt new file mode 100644 index 00000000..d73444aa --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/VaultDataStore.kt @@ -0,0 +1,68 @@ +package com.pasich.encly.data.backup + +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Tag +import com.pasich.encly.data.model.Task + +/** + * Every note (trash included), tag, task and sub-task in the vault, as stored, and which note has which tag + * ([noteTags]; the notes' own [Note.tagIds] are not filled here). + */ +data class VaultSnapshot( + val notes: List, + val tags: List, + val tasks: List, + val subtasks: List = emptyList(), + val noteTags: List = emptyList(), +) { + private val tagsByNote: Map> by lazy { + val byId = tags.associateBy { it.id } + noteTags.groupBy({ it.noteId }, { it.tagId }).mapValues { (_, ids) -> + ids.distinct().mapNotNull(byId::get).sortedWith(compareBy({ it.position }, { it.id })) + } + } + + /** The tags of note [noteId] that are in this snapshot, in their display order (position, then id). */ + fun tagsOf(noteId: Long): List = tagsByNote[noteId].orEmpty() +} + +/** + * The narrow storage contract encrypted export/import needs. The Room implementation is + * [RoomVaultDataStore]; tests use an in-memory one. + */ +@Suppress("TooManyFunctions") // One function per vault table operation of the contract. +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 + + /** Only note [noteId] and its tags: for exporting one note without reading the whole vault. */ + suspend fun snapshotOfNote(noteId: Long): VaultSnapshot = snapshot().let { all -> + val note = all.notes.firstOrNull { it.id == noteId } + val links = all.noteTags.filter { note != null && it.noteId == note.id } + val tagIds = links.mapTo(HashSet()) { it.tagId } + VaultSnapshot(listOfNotNull(note), all.tags.filter { it.id in tagIds }, emptyList(), noteTags = links) + } + + /** Runs [block] atomically: if it throws, none of its writes are kept. */ + suspend fun inTransaction(block: suspend () -> R): R + + suspend fun deleteAll() + + suspend fun insertTag(tag: Tag): Long + + suspend fun insertNote(note: Note): Long + + /** Puts the tag on the note; both are in the vault already. */ + suspend fun insertNoteTag(link: NoteTag) + + suspend fun insertTask(task: Task): Long + + suspend fun insertSubtask(subtask: Subtask): Long +} diff --git a/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt index 8b44dc00..1e130208 100644 --- a/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt @@ -14,13 +14,15 @@ import com.pasich.encly.domain.model.SwipeSide import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.domain.model.ThemeType +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import javax.inject.Inject -import javax.inject.Singleton +import kotlin.concurrent.Volatile const val IS_DYNAMIC_THEME = "is_dynamic_theme" const val THEME_TYPE = "theme_type" @@ -39,9 +41,10 @@ const val SWIPE_RIGHT = "swipe_right" const val SWIPE_LEFT = "swipe_left" const val APPEARANCE_MIGRATED = "appearance_migrated_v1" -@Singleton +@SingleIn(AppScope::class) @Suppress("TooManyFunctions") // One small getter/setter pair per persisted editor or display setting. -class SettingsLocalDataSource @Inject constructor(private val dataStore: DataStore) { +@Inject +class SettingsLocalDataSource(private val dataStore: DataStore) { private companion object { val isDynamicTheme = booleanPreferencesKey(IS_DYNAMIC_THEME) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/di/RepositoryModule.kt new file mode 100644 index 00000000..9d0ab771 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/di/RepositoryModule.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.data.di + +import com.pasich.encly.data.repository.NotesRepositoryImpl +import com.pasich.encly.data.repository.SettingsRepositoryImpl +import com.pasich.encly.data.repository.TagsRepositoryImpl +import com.pasich.encly.data.repository.TasksRepositoryImpl +import com.pasich.encly.domain.repository.NotesRepository +import com.pasich.encly.domain.repository.SettingsRepository +import com.pasich.encly.domain.repository.TagsRepository +import com.pasich.encly.domain.repository.TasksRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo + +@ContributesTo(AppScope::class) +@BindingContainer +interface RepositoryModule { + @Binds + fun bindNotesRepository(impl: NotesRepositoryImpl): NotesRepository + + @Binds + fun bindTagsRepository(impl: TagsRepositoryImpl): TagsRepository + + @Binds + fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository + + @Binds + fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt new file mode 100644 index 00000000..f988226c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt @@ -0,0 +1,142 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.MAX_LIST_LEVEL +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.model.hasContent +import com.pasich.encly.editor.model.kindIn +import com.pasich.encly.editor.model.normalizedLevels +import com.pasich.encly.editor.model.numbers + +/** + * A note's blocks as Markdown (CommonMark with GFM task lists), for the plaintext export. + * + * - Blocks are separated by a blank line, so a separator `---` can never turn the line above it + * into a heading. + * - Text is escaped so that what the user typed reads as typed: inline marks (`*`, `` ` ``, `[`, + * `<`, `\`, `~`, a word-edge `_`, an entity such as `&`) and whatever would start a block at + * the start of a line (`#`, `>`, `-`, `+`, `1.`, `===`, an indent of four spaces). + * - A line break inside a block is kept as a hard break (a trailing `\`). + * - Code is a fenced block and a table a GFM pipe table. + * - A link is `[title](url)`. Only `http`, `https`, `mailto` and `tel` addresses become links; any + * other scheme (`javascript:`, `data:`) is written as inline code, so the file never carries a + * link that runs something when opened in a viewer. A link's preview image is never written: + * `![](...)` would make a viewer fetch it from the network. + */ +@Suppress("TooManyFunctions") // One function per kind of block. +object MarkdownBlocks { + private const val NO_BREAK_SPACE = '\u00A0' + + /** The width of `- `, the marker of a bullet and of a task item. */ + private const val BULLET_WIDTH = 2 + private const val MIN_FENCE = 3 + private val BACKTICK_RUN = Regex("`+") + private val LINK_SCHEMES = setOf("http", "https", "mailto", "tel") + private val ENCODED_IN_LINK = mapOf( + ' ' to "%20", + '(' to "%28", + ')' to "%29", + '<' to "%3C", + '>' to "%3E", + '\\' to "%5C", + ) + + /** The Markdown of [blocks] (those without content are left out); empty when nothing is left. */ + fun render(blocks: List): String = + blocks.filter { it.hasContent() }.map(::renderBlock).filter { it.isNotEmpty() }.joinToString("\n\n") + + private fun renderBlock(block: Block): String = when (block) { + is Block.TextBlock -> paragraphs(block.text.value) + is Block.HBlock -> heading(block.blockType, block.text.value) + is Block.QuoteBlock -> quote(block.text.value) + is Block.LinkBlock -> link(block.block.value) + is Block.SeparatorBlock -> "---" + is Block.ListBlock -> list(block.blockType, block.items.value) + is Block.CodeBlock -> code(block.text.value) + is Block.TableBlock -> table(block.table.value) + } + + /** A fenced code block; the fence is longer than any run of backticks inside, so the code cannot end it. */ + private fun code(text: String): String { + val longestRun = BACKTICK_RUN.findAll(text).maxOfOrNull { it.value.length } ?: 0 + val fence = "`".repeat(maxOf(MIN_FENCE, longestRun + 1)) + return "$fence\n${text.trimEnd('\n', '\r')}\n$fence" + } + + /** + * A GFM pipe table. GFM has no table without a header row, so one is written as a header of empty cells + * (which the importer reads back as no header). A bar in a cell is `\|`, a line break a space; the + * columns are not aligned. + */ + private fun table(data: TableData): String { + val rows = data.rows.map { row -> row.cells.map { cell(it.value) } } + val header = if (data.header) rows.first() else List(data.columnCount) { "" } + val body = if (data.header) rows.drop(1) else rows + return (listOf(header, List(data.columnCount) { "---" }) + body).joinToString("\n") { row -> + row.joinToString(" | ", prefix = "| ", postfix = " |") + } + } + + private fun cell(text: String): String = MarkdownText.escapeInline(MarkdownText.oneLine(text)) + + private fun heading(type: BlockType, text: String): String { + // H1 to H4 are consecutive. + val level = type.ordinal - BlockType.H1.ordinal + 1 + // A heading is one line; a trailing "#" would be read as the closing sequence. + val escaped = MarkdownText.escapeInline(MarkdownText.oneLine(text)).trimEnd() + val line = if (escaped.endsWith("#")) escaped.dropLast(1) + "\\#" else escaped + return "${"#".repeat(level)} $line".trimEnd() + } + + private fun quote(text: String): String = MarkdownText.hardBreakLines(text).joinToString("\n") { line -> + if (line.isEmpty()) ">" else "> $line" + } + + private fun link(data: LinkDataBlock): String { + val url = data.url.trim() + val label = MarkdownText.oneLine(data.title).ifBlank { url } + val target = safeTarget(url) + if (target == null) return "`${MarkdownText.oneLine(url).replace('`', '\'')}`" + return "[${MarkdownText.escapeInline(label)}]($target)" + } + + /** [url] ready for `( … )`, or null for an address that must not become a link. */ + private fun safeTarget(url: String): String? { + val scheme = url.substringBefore(':', "").lowercase() + val clean = url.filterNot { it.isISOControl() || it == NO_BREAK_SPACE } + return if (clean.isEmpty() || ':' !in clean || scheme !in LINK_SCHEMES) { + null + } else { + // The characters that would end or break the destination are percent-encoded. + clean.map { ENCODED_IN_LINK[it] ?: it.toString() }.joinToString("") + } + } + + private fun list(type: BlockType, items: List): String { + val shown = items.filter { it.value.isNotBlank() }.normalizedLevels(type) + val numbers = shown.numbers(type) + // How far each open level is indented: its parent's indent plus the width of the parent's list marker, which + // is where the parent's text starts, as CommonMark wants a nested item to line up. (The box of a task item + // belongs to its text, not to the marker.) + val indents = IntArray(MAX_LIST_LEVEL + 2) + return shown.withIndex().joinToString("\n") { (index, item) -> + val kind = item.kindIn(type) + val marker = when (kind) { + BlockType.LIST_NUMBER -> "${numbers[index]}. " + BlockType.LIST_CHECK -> if (item.isCheck) "- [x] " else "- [ ] " + else -> "- " + } + val indent = indents[item.level] + indents[item.level + 1] = indent + if (kind == BlockType.LIST_NUMBER) marker.length else BULLET_WIDTH + val lines = MarkdownText.hardBreakLines(item.value) + val continuation = " ".repeat(indent + marker.length) + val first = " ".repeat(indent) + marker + lines.first() + (listOf(first) + lines.drop(1).map { if (it.isEmpty()) "" else continuation + it }).joinToString("\n") + } + } + + private fun paragraphs(text: String): String = MarkdownText.hardBreakLines(text).joinToString("\n") +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownDocuments.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownDocuments.kt new file mode 100644 index 00000000..83f9db28 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownDocuments.kt @@ -0,0 +1,65 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.core.backup.BackupError +import com.pasich.encly.core.backup.BackupException +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.PlatformDocuments +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.Sink +import kotlinx.io.buffered + +/** + * Writes the plaintext (Markdown) export to a document the user just created in the system save + * dialog. The content is streamed straight into that document: no temp file, no cache or files + * directory, no share intent, and no storage permission. + */ +@SingleIn(AppScope::class) +@Inject +class MarkdownDocuments(private val documents: PlatformDocuments) { + + /** + * Streams what [block] writes into the document at [doc]. A document that already holds data + * is refused with [BackupError.TARGET_EXISTS] instead of being overwritten. On any failure the + * incomplete document is deleted where the provider allows it. Provider failures become + * [BackupError.IO] without their message, which can quote the document's path. + */ + fun write(doc: DocumentRef, block: (Sink) -> Unit) = io { + if (hasContent(doc)) throw BackupException(BackupError.TARGET_EXISTS) + var complete = false + try { + val sink = documents.openWrite(doc) ?: throw BackupException(BackupError.IO) + sink.buffered().use(block) + complete = true + } finally { + if (!complete) discard(doc) + } + } + + /** + * Best effort: removes the empty document the picker created when nothing could be written to + * it. A document that holds data is never deleted: a provider may have handed back an existing + * file of the user's, and an unreadable one is left alone too. + */ + fun discard(doc: DocumentRef) { + runCatching { if (!hasContent(doc)) documents.delete(doc) } + } + + private fun hasContent(doc: DocumentRef): Boolean = + documents.openRead(doc)?.use { it.readAtMostTo(Buffer(), 1) >= 0 } ?: false + + private inline fun io(block: () -> T): T = try { + block() + } catch (e: BackupException) { + throw e + } catch (_: IOException) { + throw BackupException(BackupError.IO) + } catch (_: RuntimeException) { + // Third-party providers throw RuntimeExceptions across Binder (SecurityException when the grant + // was revoked among them). + throw BackupException(BackupError.IO) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownExport.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownExport.kt new file mode 100644 index 00000000..6c60e452 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownExport.kt @@ -0,0 +1,52 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.data.backup.VaultSnapshot +import com.pasich.encly.data.zip.ZipEntrySink +import com.pasich.encly.data.zip.zipWriter +import kotlinx.io.Sink + +/** + * The Markdown export of what a vault holds, written straight to a stream (a document the user + * picked): nothing is buffered into a file, and nothing is added to the vault's data. Notes that + * are in the trash are left out; archived notes go to `archive/`. + * + * It exports the vault that is open, whichever it is: the decoy vault exports what it contains. + * Notes under a hidden tag are included, as they are in the vault. + */ +object MarkdownExport { + const val TASKS_FILE = "tasks.md" + const val ARCHIVE_FOLDER = "archive/" + const val MIME_NOTE = "text/markdown" + const val MIME_ZIP = "application/zip" + + /** Earliest time a zip entry can carry (the DOS epoch, 1980-01-01). */ + private const val ZIP_EPOCH_MILLIS = 315_532_800_000L + + /** Every note that is not in the trash and the tasks, as a zip of `.md` files. Closing [out] is the caller's. */ + fun writeAll(snapshot: VaultSnapshot, labels: MarkdownLabels, out: Sink) { + writeAll(snapshot, labels, zipWriter(out)) + } + + internal fun writeAll(snapshot: VaultSnapshot, labels: MarkdownLabels, zip: ZipEntrySink) { + val names = MarkdownFileNames(reserved = listOf(TASKS_FILE)) + zip.putText(TASKS_FILE, null, MarkdownTasks.render(snapshot.tasks, snapshot.subtasks, snapshot.tags, labels)) + snapshot.notes.filterNot { it.isTrash }.forEach { note -> + val folder = if (note.isArchived) ARCHIVE_FOLDER else "" + val name = names.next(note.title, labels.untitled, folder) + zip.putText(name, note.date, MarkdownNotes.render(note, snapshot.tagNamesOf(note.id), labels)) + } + zip.finish() + } + + /** The note [noteId] as one `.md` document; false when the vault has no such note (or it is in the trash). */ + fun writeNote(snapshot: VaultSnapshot, noteId: Long, labels: MarkdownLabels, out: Sink): Boolean { + val note = snapshot.notes.firstOrNull { it.id == noteId && !it.isTrash } ?: return false + out.write(MarkdownNotes.render(note, snapshot.tagNamesOf(note.id), labels).encodeToByteArray()) + return true + } + + private fun VaultSnapshot.tagNamesOf(noteId: Long) = tagsOf(noteId).map { it.nameTag } + + private fun ZipEntrySink.putText(name: String, time: Long?, text: String) = + put(name, time?.takeIf { it >= ZIP_EPOCH_MILLIS }, text.encodeToByteArray()) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownFileNames.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownFileNames.kt new file mode 100644 index 00000000..f71c1315 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownFileNames.kt @@ -0,0 +1,74 @@ +package com.pasich.encly.data.export + +/** + * File names for the exported notes: safe on every file system and in a zip, and unique. + * + * A name is built from the note's title, so it is treated as untrusted text: it is normalized to + * NFC, loses control, format and separator characters (a right-to-left override can fake an + * extension), the characters Windows and the zip format reject (`/ \ : * ? " < > |`), leading + * dots (a hidden file, `..`) and trailing dots and spaces, avoids the device names Windows + * reserves, and is cut to a byte budget at a character boundary. Two names that differ only in + * case count as the same one, because macOS and Windows would overwrite one with the other. + */ +class MarkdownFileNames(reserved: Collection = emptyList(), private val maxBytes: Int = MAX_NAME_BYTES) { + private val taken = HashSet() + + init { + reserved.forEach { taken += key(it) } + } + + /** + * A unique `.md` for [title] inside [folder] (`""` or e.g. `"archive/"`); [fallback] if + * the title has no usable text. + */ + fun next(title: String, fallback: String, folder: String = ""): String { + val base = sanitize(title).ifEmpty { sanitize(fallback).ifEmpty { "note" } } + var candidate = fit(base, "") + var n = 1 + while (!taken.add(key(folder + candidate + EXTENSION))) { + n++ + candidate = fit(base, " ($n)") + } + return folder + candidate + EXTENSION + } + + /** [base] cut so that it, [suffix] and the extension fit the byte budget. */ + private fun fit(base: String, suffix: String): String { + val room = maxBytes - EXTENSION.length - suffix.encodeToByteArray().size + return cutBytes(base, room.coerceAtLeast(1)).trimEnd('.', ' ') + suffix + } + + private fun key(path: String) = normalizeNfc(path).lowercase() + + companion object { + const val EXTENSION = ".md" + const val MAX_NAME_BYTES = 100 + private val RESERVED = Regex("""^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$""", RegexOption.IGNORE_CASE) + + /** [title] reduced to characters that are safe in a file name; may be empty. */ + fun sanitize(title: String): String { + val kept = keepFileNameCharacters(normalizeNfc(title)) + val spaced = kept.replace(Regex(" {2,}"), " ").trim().trimStart('.', ' ').trimEnd('.', ' ') + return if (RESERVED.matches(spaced.substringBefore('.').trim())) "_$spaced" else spaced + } + + /** The longest prefix of [text] that is at most [bytes] bytes of UTF-8, ending on a character. */ + fun cutBytes(text: String, bytes: Int): String = utf8Prefix(text, bytes) + } +} + +// The platform side of the rules above (JvmUnicode.kt): Unicode normalization and character categories +// have no Kotlin common equivalent. + +/** [text] in Unicode normalization form C. */ +internal expect fun normalizeNfc(text: String): String + +/** + * [text] without the control, format, separator, private-use, unassigned and lone-surrogate + * characters and without the characters file systems reject; every kind of space becomes one + * plain space. (Runs of spaces, dots at the ends and reserved names are the caller's.) + */ +internal expect fun keepFileNameCharacters(text: String): String + +/** The longest prefix of [text] that is at most [bytes] bytes of UTF-8, ending on a character. */ +internal expect fun utf8Prefix(text: String, bytes: Int): String diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownNotes.kt similarity index 85% rename from app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownNotes.kt index 2af0ed71..eded6bd5 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownNotes.kt @@ -1,12 +1,12 @@ package com.pasich.encly.data.export -import com.pasich.encly.core.serialization.BlockConverter import com.pasich.encly.data.model.Note -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.json.JsonPrimitive -import java.time.Instant +import kotlin.time.Instant /** * The words the exported files contain besides the user's own text, in the in-app language @@ -28,15 +28,16 @@ class MarkdownLabels(val untitled: String, val unreadable: String, val tasks: St * a title with a colon, a quote or a line break cannot break out of its line. */ object MarkdownNotes { - /** [tagName] is the note's tag, if any. */ - fun render(note: Note, tagName: String?, labels: MarkdownLabels): String { + /** [tagNames] are the note's tags, in their order. */ + fun render(note: Note, tagNames: List, labels: MarkdownLabels): String { val title = note.title.trim() val blocks = BlockConverter.jsonToBlocksOrNull(note.value) val body = blocks?.let(MarkdownBlocks::render) ?: MarkdownText.escapeInline(labels.unreadable) return buildString { append("---\n") append("title: ").append(quoted(title.ifEmpty { labels.untitled })).append('\n') - if (!tagName.isNullOrBlank()) append("tags: [").append(quoted(tagName.trim())).append("]\n") + val tags = tagNames.map { it.trim() }.filter { it.isNotEmpty() } + if (tags.isNotEmpty()) append("tags: [").append(tags.joinToString(", ") { quoted(it) }).append("]\n") if (note.dateCreate > 0L) append("created: ").append(iso(note.dateCreate)).append('\n') if (note.date > 0L) append("updated: ").append(iso(note.date)).append('\n') if (note.description.isNotBlank()) { @@ -74,5 +75,5 @@ object MarkdownNotes { }, ).toString() - private fun iso(millis: Long): String = Instant.ofEpochMilli(millis).toString() + private fun iso(millis: Long): String = Instant.fromEpochMilliseconds(millis).toString() } diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownTasks.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownTasks.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/data/export/MarkdownTasks.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownTasks.kt diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownText.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownText.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/data/export/MarkdownText.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownText.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/NoteDocument.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/NoteDocument.kt new file mode 100644 index 00000000..5369f995 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/NoteDocument.kt @@ -0,0 +1,92 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.data.model.Note +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.hasContent + +/** What kind of list a [DocumentBlock.Items] is. */ +enum class ListKind { BULLET, NUMBERED, CHECKLIST } + +/** One row of a list; [checked] only means something in a checklist. */ +data class DocumentItem(val text: String, val checked: Boolean = false) + +/** + * A block of a [NoteDocument]: what a page of the note shows, with no editor state in it. A renderer (the PDF + * writer, the printer) draws these and nothing else, so it needs no knowledge of the editor. + */ +sealed interface DocumentBlock { + /** A heading; [level] is 1 (largest) to 4. */ + data class Heading(val level: Int, val text: String) : DocumentBlock + + data class Paragraph(val text: String) : DocumentBlock + + data class Quote(val text: String) : DocumentBlock + + /** A link, shown as its text: [title] (when it has one) and the address. Never an active link. */ + data class Link(val title: String, val url: String) : DocumentBlock + + data object Separator : DocumentBlock + + data class Items(val kind: ListKind, val items: List) : DocumentBlock +} + +/** + * One note as a document for paper or PDF: [title], the [tags] it carries and the [blocks] that are not blank. + * It holds plaintext and is built from the stored note only inside an authenticated export (see + * `MarkdownExportViewModel`); nothing keeps it afterwards. + */ +class NoteDocument(val title: String, val tags: List, val blocks: List) { + companion object { + private const val H1_TO_H4 = 4 + + /** + * The document of [note]. [tagNames] are its tags, in their order; a note whose stored content cannot be read + * gets [unreadable] as its only paragraph, and a note without a title [untitled]. + */ + fun of(note: Note, tagNames: List, untitled: String, unreadable: String): NoteDocument { + val parsed = BlockConverter.jsonToBlocksOrNull(note.value) + val blocks = parsed?.let(::fromBlocks) ?: listOf(DocumentBlock.Paragraph(unreadable)) + return NoteDocument( + title = note.title.trim().ifEmpty { untitled }, + tags = tagNames.map { it.trim() }.filter { it.isNotEmpty() }, + blocks = blocks, + ) + } + + /** The blocks of the editor as document blocks; blocks without content are left out. */ + fun fromBlocks(blocks: List): List = blocks.filter { it.hasContent() }.map { block -> + when (block) { + is Block.TextBlock -> DocumentBlock.Paragraph(block.text.value) + + is Block.HBlock -> DocumentBlock.Heading(headingLevel(block.blockType), block.text.value) + + is Block.QuoteBlock -> DocumentBlock.Quote(block.text.value) + + is Block.LinkBlock -> block.block.value.let { DocumentBlock.Link(it.title.trim(), it.url.trim()) } + + is Block.SeparatorBlock -> DocumentBlock.Separator + + // Code and tables are drawn as paragraphs of their text (a table as one row of cells per line). + is Block.CodeBlock -> DocumentBlock.Paragraph(block.text.value) + + is Block.TableBlock -> DocumentBlock.Paragraph( + block.table.value.rows.joinToString("\n") { row -> row.cells.joinToString(" | ") { it.value } }, + ) + + is Block.ListBlock -> DocumentBlock.Items( + kind = when (block.blockType) { + BlockType.LIST_NUMBER -> ListKind.NUMBERED + BlockType.LIST_CHECK -> ListKind.CHECKLIST + else -> ListKind.BULLET + }, + items = block.items.value.filter { it.value.isNotBlank() } + .map { DocumentItem(it.value, it.isCheck) }, + ) + } + } + + private fun headingLevel(type: BlockType): Int = (type.ordinal - BlockType.H1.ordinal + 1).coerceIn(1, H1_TO_H4) + } +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/EditorJson.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/EditorJson.kt index 8f58a781..fae321e1 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/EditorJson.kt @@ -1,8 +1,9 @@ package com.pasich.encly.data.handoff -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.codec.InlineHtml +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/HandoffStaging.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/HandoffStaging.kt new file mode 100644 index 00000000..988391fe --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/HandoffStaging.kt @@ -0,0 +1,127 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.data.zip.openZipFile +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem + +/** + * The one place a hand-off touches the disk: My Notes' ZIP is copied into a file of its own in + * [dir] (the app-private cache) because the URI grant ends with the activity and the reader needs + * random access, then parsed and mapped, then deleted in a `finally`. The plaintext lives on disk + * only for the parse. What a killed process left behind is deleted by [sweep], at the next start + * of the app and when a wipe-PIN erase finishes; a file a load in this process is still reading + * is never touched, so two hand-offs at once do not delete each other's. + * + * Deleting is all that can be done: on flash storage overwriting a file does not reliably + * erase the old blocks, so it is not attempted. + */ +class HandoffStaging(private val dir: Path, private val limits: HandoffLimits = HandoffLimits()) { + + /** + * Copies what [open] returns, reads and maps it. Throws [HandoffException]; an [IOException] + * from [open] or the copy (the platform adapter also reports a refused grant as one) means + * the URI could not be read. + */ + fun load(open: () -> RawSource?): HandoffImport { + val zip = createStagingFile(dir) + try { + val input = open() ?: throw HandoffException(HandoffError.INVALID_PAYLOAD) + input.use { source -> + SystemFileSystem.sink(zip).buffered().use { + MyNotesHandoffReader.copyLimited(source, it, limits) + } + } + return MyNotesHandoffMapper.map(read(zip, limits)) + } finally { + release(dir, zip) + } + } + + companion object { + const val DIR_NAME = "mynotes-handoff" + private const val ZIP_PREFIX = "handoff-" + private const val ZIP_SUFFIX = ".zip" + private const val NAME_RANDOM_BYTES = 8 + private const val BYTE_MASK = 0xFF + private const val HEX_RADIX = 16 + + /** The hand-off in the ZIP file [zip], which is opened with the platform's ZIP support. */ + internal fun read(zip: Path, limits: HandoffLimits = HandoffLimits()): MyNotesHandoff = + MyNotesHandoffReader.read(SystemFileSystem.metadataOrNull(zip)?.size ?: 0L, limits) { openZipFile(zip) } + + /** Paths of the files loads in this process are using; also the lock of the directory. */ + private val inUse = mutableSetOf() + + private val lock = SynchronizedObject() + + /** + * Deletes everything in [dir] that no load in this process is using (what killed + * processes left behind), and [dir] itself once it is empty. + */ + fun sweep(dir: Path) { + synchronized(lock) { + if (SystemFileSystem.exists(dir)) { + try { + SystemFileSystem.list(dir).filterNot { it.toString() in inUse }.forEach(::deleteRecursively) + } catch (_: IOException) { + // Not a readable directory: nothing to sweep. + } + } + if (inUse.isEmpty()) deleteIfEmpty(dir) + } + } + + private fun createStagingFile(dir: Path): Path = synchronized(lock) { + try { + SystemFileSystem.createDirectories(dir) + } catch (_: IOException) { + throw IOException("no staging directory") + } + var zip: Path + do { + zip = Path(dir, ZIP_PREFIX + hex(Crypto.randomBytes(NAME_RANDOM_BYTES)) + ZIP_SUFFIX) + } while (SystemFileSystem.exists(zip)) + SystemFileSystem.sink(zip).close() + zip.also { inUse += it.toString() } + } + + private fun release(dir: Path, zip: Path) { + synchronized(lock) { + deleteRecursively(zip) + inUse -= zip.toString() + // Only when no other load is using it (a directory that is not empty stays). + if (inUse.isEmpty()) deleteIfEmpty(dir) + } + } + + /** Deletes [path] and, for a directory, what is in it; what cannot be deleted is left. */ + private fun deleteRecursively(path: Path) { + try { + if (SystemFileSystem.metadataOrNull(path)?.isDirectory == true) { + SystemFileSystem.list(path).forEach(::deleteRecursively) + } + SystemFileSystem.delete(path, mustExist = false) + } catch (_: IOException) { + // Left for the next sweep. + } + } + + /** Deletes the directory [dir] unless something is in it (or it is gone already). */ + private fun deleteIfEmpty(dir: Path) { + try { + SystemFileSystem.delete(dir, mustExist = false) + } catch (_: IOException) { + // Not empty. + } + } + + private fun hex(bytes: ByteArray): String = + bytes.joinToString("") { (it.toInt() and BYTE_MASK).toString(HEX_RADIX).padStart(2, '0') } + } +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt similarity index 94% rename from app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt index e2edc4cf..11ea3d9c 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt @@ -1,8 +1,9 @@ package com.pasich.encly.data.handoff -import com.pasich.encly.core.serialization.BlockConverter -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.codec.InlineHtml +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoff.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoff.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoff.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoff.kt diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt index 012e7f60..4022e867 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt @@ -5,8 +5,8 @@ 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.backup.BackupTask +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.data.backup.BackupImporter -import java.security.MessageDigest /** What the user confirms before the import: counts only, never content. */ data class HandoffPreview( @@ -51,7 +51,7 @@ object MyNotesHandoffMapper { description = "", date = note.date, dateCreate = note.date, - tagUid = note.tag?.let(tags::uidForName), + tagUids = listOfNotNull(note.tag?.let(tags::uidForName)), isTrash = note.isTrash, ) } @@ -99,7 +99,7 @@ object MyNotesHandoffMapper { if (id.isNotBlank() && id.length <= BackupPayloadCodec.MAX_UID_LENGTH) id else sha256Hex("$kind:$id") private fun sha256Hex(text: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8)) + val digest = Crypto.sha256(text.encodeToByteArray()) return buildString(digest.size * 2) { digest.forEach { byte -> val b = byte.toInt() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt new file mode 100644 index 00000000..8fe54cd7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt @@ -0,0 +1,168 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.data.zip.ZipEntrySource +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.buffered +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.io.decodeFromSource + +/** + * Size limits of a hand-off. Every one is enforced on the bytes actually read, never on a + * size a header claims, so a ZIP bomb or an endless stream stops at the limit. + */ +data class HandoffLimits( + /** The ZIP as copied from My Notes. */ + val maxZipBytes: Long = MAX_BYTES, + /** `handoff.json` uncompressed. */ + val maxJsonBytes: Long = MAX_BYTES, + /** Records per list (notes, tasks, tags, task categories). */ + val maxRecords: Int = 100_000, + /** A note's title, text or Editor.js JSON, a task's text: characters each. */ + val maxTextChars: Int = MIB, + /** A tag or category name, and a record id. */ + val maxNameChars: Int = 1_000, +) { + private companion object { + const val MIB = 1024 * 1024 + const val MAX_BYTES = 32L * MIB + } +} + +/** + * Reads a My Notes hand-off ZIP strictly: exactly one entry, `handoff.json`, in the v1 shape, + * within [HandoffLimits]. Throws [HandoffException] with [HandoffError.TOO_LARGE], + * [HandoffError.UNSUPPORTED_SCHEMA] or [HandoffError.INVALID_PAYLOAD]; nothing else escapes. + */ +@OptIn(ExperimentalSerializationApi::class) +object MyNotesHandoffReader { + const val ENTRY_NAME = "handoff.json" + + private val json = Json { + ignoreUnknownKeys = true + isLenient = false + coerceInputValues = false + } + + /** Reads `format` and `schema` only, before the full decode. */ + @Serializable + private class Probe(val format: String? = null, val schema: Int? = null) + + /** + * Copies [input] (the content URI's stream) to [target], stopping with [HandoffError.TOO_LARGE] + * past [HandoffLimits.maxZipBytes]. The caller deletes [target] whatever the outcome. + */ + fun copyLimited(input: RawSource, target: Sink, limits: HandoffLimits = HandoffLimits()) { + try { + LimitedSource(input, limits.maxZipBytes).buffered().transferTo(target) + } catch (_: LimitExceededException) { + throw HandoffException(HandoffError.TOO_LARGE) + } + } + + /** + * Reads the archive [openArchive] returns, which is [zipBytes] long (the length of the file it + * was opened from, checked before anything is opened). + */ + internal fun read( + zipBytes: Long, + limits: HandoffLimits = HandoffLimits(), + openArchive: () -> ZipEntrySource, + ): MyNotesHandoff { + ensure(zipBytes <= limits.maxZipBytes, HandoffError.TOO_LARGE) + val handoff = guarded { + openArchive().use { archive -> + ensure(archive.entryCount == 1, HandoffError.INVALID_PAYLOAD) + val entry = archive.find(ENTRY_NAME)?.takeUnless { it.isDirectory } + ?: throw HandoffException(HandoffError.INVALID_PAYLOAD) + // A declared size is only a hint (it can lie); the stream limit below is the check. + ensure(entry.declaredSize <= limits.maxJsonBytes, HandoffError.TOO_LARGE) + val open = { LimitedSource(entry.open(), limits.maxJsonBytes).buffered() } + val probe = open().use { decode(Probe.serializer(), it) } + val schema = probe.schema?.takeIf { probe.format == MyNotesHandoff.FORMAT } + ensure(schema != null && schema >= MyNotesHandoff.SCHEMA, HandoffError.INVALID_PAYLOAD) + ensure(schema == MyNotesHandoff.SCHEMA, HandoffError.UNSUPPORTED_SCHEMA) + open().use { decode(MyNotesHandoff.serializer(), it) } + } + } + validate(handoff, limits) + return handoff + } + + private fun decode(deserializer: DeserializationStrategy, input: Source): T = + json.decodeFromSource(deserializer, input) + + /** Maps every parse failure to a [HandoffError]; a [HandoffException] passes through. */ + private inline fun guarded(block: () -> T): T = try { + block() + } catch (e: IOException) { + rejected(e) + } catch (e: SerializationException) { + rejected(e) + } catch (e: IllegalArgumentException) { + rejected(e) + } + + /** The cause is dropped on purpose: a parser message can quote note content. */ + private fun rejected(e: Exception): Nothing = throw HandoffException( + if (generateSequence(e) { it.cause }.any { it is LimitExceededException }) { + HandoffError.TOO_LARGE + } else { + HandoffError.INVALID_PAYLOAD + }, + ) + + private fun validate(handoff: MyNotesHandoff, limits: HandoffLimits) { + val lists = listOf(handoff.tags, handoff.notes, handoff.taskCategories, handoff.tasks) + ensure(lists.all { it.size <= limits.maxRecords }, HandoffError.TOO_LARGE) + val texts = handoff.notes.flatMap { listOf(it.title, it.value, it.valueJson.orEmpty()) } + + handoff.tasks.map { it.description } + ensure(texts.all { it.length <= limits.maxTextChars }, HandoffError.TOO_LARGE) + val names = handoff.tags.flatMap { listOf(it.id, it.name) } + + handoff.taskCategories.flatMap { listOf(it.id, it.name) } + + handoff.notes.flatMap { listOf(it.id, it.tag.orEmpty()) } + + handoff.tasks.flatMap { listOf(it.id, it.categoryId.orEmpty()) } + ensure(names.all { it.length <= limits.maxNameChars }, HandoffError.TOO_LARGE) + + val valid = uniqueIds(handoff.tags.map { it.id }) && + uniqueIds(handoff.notes.map { it.id }) && + uniqueIds(handoff.taskCategories.map { it.id }) && + uniqueIds(handoff.tasks.map { it.id }) && + handoff.notes.all { it.attachments >= 0 } + ensure(valid, HandoffError.INVALID_PAYLOAD) + val categoryIds = handoff.taskCategories.mapTo(HashSet()) { it.id } + val knownCategories = handoff.tasks.all { it.categoryId == null || it.categoryId in categoryIds } + ensure(knownCategories, HandoffError.INVALID_PAYLOAD) + } + + private fun uniqueIds(ids: List): Boolean = ids.all { it.isNotBlank() } && ids.toSet().size == ids.size + + private fun ensure(condition: Boolean, error: HandoffError) { + if (!condition) throw HandoffException(error) + } + + private class LimitExceededException : IOException("limit exceeded") + + /** Fails with [LimitExceededException] once more than [limit] bytes were read. */ + private class LimitedSource(private val upstream: RawSource, private val limit: Long) : RawSource { + private var count = 0L + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + val n = upstream.readAtMostTo(sink, byteCount) + if (n > 0) { + count += n + if (count > limit) throw LimitExceededException() + } + return n + } + + override fun close() = upstream.close() + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/DocumentTree.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/DocumentTree.kt index 0c1e84dd..86ff1d27 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/DocumentTree.kt @@ -1,6 +1,6 @@ package com.pasich.encly.data.importer -import java.io.InputStream +import kotlinx.io.RawSource /** 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) @@ -14,7 +14,7 @@ interface DocumentTree { fun children(parentId: String): List - fun open(id: String): InputStream? + fun open(id: String): RawSource? } /** diff --git a/app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/FormatDetector.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/FormatDetector.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/Frontmatter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/Frontmatter.kt new file mode 100644 index 00000000..2f37e53a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/Frontmatter.kt @@ -0,0 +1,143 @@ +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. [listKeys] are the keys written + * as a list (`[a, b]` or `- item` lines), whose items are taken whole. + */ +class MarkdownDocument( + private val meta: Map>, + val body: String, + private val listKeys: Set = emptySet(), +) { + 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, an item of a real list stays whole. */ + fun list(key: String): List = if (key in listKeys) { + meta[key].orEmpty() + } else { + meta[key].orEmpty().flatMap { it.split(',') } + } + + fun flag(key: String): Boolean = value(key)?.lowercase() in TRUE_VALUES + + private companion object { + val TRUE_VALUES = setOf("true", "yes", "1") + } +} + +/** + * Front matter read by hand, with no YAML library and nothing executed: `key: value` lines, a + * `[a, b]` inline list or `- item` lines under a key, quoted values unquoted. Anything else + * (nested maps, block scalars, anchors, tags) is ignored. Without a closing `---` within + * [MAX_LINES] lines the file has no front matter and is all body; nor has a block with no + * `key:` line in it (a horizontal rule, some text, another rule). + */ +object Frontmatter { + private const val MAX_LINES = 200 + private val KEY_VALUE = Regex("""^([A-Za-z0-9_][A-Za-z0-9_ .-]*):(?:[ \t]+(.*))?$""") + private val LIST_ITEM = Regex("""^[ \t]*-[ \t]+(.*)$""") + private val json = Json { isLenient = false } + + fun split(text: String): MarkdownDocument { + val lines = text.lines() + val end = if (lines.firstOrNull()?.trimEnd() == "---") { + (1 until minOf(lines.size, MAX_LINES)).firstOrNull { + lines[it].trimEnd() == "---" || lines[it].trimEnd() == "..." + } + } else { + null + } + return if (end == null || !isFrontmatter(lines.subList(1, end))) { + MarkdownDocument(emptyMap(), text) + } else { + val listKeys = HashSet() + MarkdownDocument(parse(lines.subList(1, end), listKeys), lines.drop(end + 1).joinToString("\n"), listKeys) + } + } + + /** 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() } + + /** The values by key; the keys written as a list go to [listKeys]. */ + private fun parse(lines: List, listKeys: MutableSet): 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() + val raw = pair.groupValues[2].trim() + if (raw.isInlineList()) listKeys += current + meta.getOrPut(current) { ArrayList() } += scalars(raw) + } else { + val item = LIST_ITEM.matchEntire(line) + if (item != null && current != null) { + listKeys += current + 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.isInlineList() -> items(raw.removeSurrounding("[", "]")).map { unquote(it.trim()) } + raw.isNotEmpty() && raw != "|" && raw != ">" -> listOf(unquote(raw)) + else -> emptyList() + } + + private fun String.isInlineList() = startsWith("[") && endsWith("]") + + /** The items of an inline list split at its commas, but not at a comma inside a double-quoted item. */ + private fun items(raw: String): List { + val items = ArrayList() + val item = StringBuilder() + var quoted = false + var escaped = false + for (c in raw) { + when { + escaped -> escaped = false + + quoted && c == '\\' -> escaped = true + + c == '"' -> quoted = !quoted + + c == ',' && !quoted -> { + items += item.toString() + item.clear() + continue + } + } + item.append(c) + } + items += item.toString() + return items + } + + /** 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/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportDocuments.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportDocuments.kt new file mode 100644 index 00000000..a981de3f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportDocuments.kt @@ -0,0 +1,35 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.PlatformDocuments +import dev.zacsweers.metro.Inject + +/** + * The platform side of an import: the user's picked files or folder as an [ImportFileSource]. Only + * [PlatformDocuments.openRead] and the folder listing are used (no file paths, seeking or reliance on a + * provider's size), and a folder is walked up to the depth and entry limits. The user's files are only + * ever read. + */ +@Inject +class ImportDocuments(private val documents: PlatformDocuments) { + + fun files(docs: List): ImportFileSource = ImportFileSource { + docs.asSequence().map { doc -> + SourceFile(displayName(doc) ?: FALLBACK_NAME) { documents.openRead(doc) } + } + } + + fun tree(root: DocumentRef, limits: ImportLimits = ImportLimits()): ImportFileSource = + TreeWalker(documents.openTree(root, limits.maxEntries), limits).source() + + /** A provider that will not tell the name (or refuses access) leaves the file unnamed. */ + private fun displayName(doc: DocumentRef): String? = try { + documents.displayName(doc) + } catch (_: RuntimeException) { + null + } + + private companion object { + const val FALLBACK_NAME = "file" + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt index 622d1456..97ec0a3f 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt @@ -4,11 +4,12 @@ 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.core.common.currentTimeMillis +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.data.backup.BackupImporter -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import java.security.MessageDigest +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType /** What the user confirms before anything is written: counts only, never content. */ data class ImportPreview( @@ -49,8 +50,8 @@ class ImportPlan(val payload: BackupPayload, val preview: ImportPreview, val row * 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 note gets every one of its tags (two names that are one tag here, "Work" and "work", give it + * once). 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 { @@ -77,7 +78,7 @@ object ImportMapper { duplicates++ continue } - val tagUid = note.tags.firstNotNullOfOrNull { tag(it, tags, tagNames) } + val tagUids = note.tags.mapNotNull { tag(it, tags, tagNames) }.distinct() val edited = note.edited ?: note.created ?: now notes += BackupNote( uid = uid, @@ -86,15 +87,15 @@ object ImportMapper { description = "", date = edited, dateCreate = note.created ?: edited, - tagUid = tagUid, + tagUids = tagUids, isTrash = note.trashed, ) - rows += ImportReview.row(uid, note, tagUid?.let(tagNames::get), edited) + rows += ImportReview.row(uid, note, tagUids.mapNotNull(tagNames::get), edited) losses += note.losses if (note.blocks.any { it is Block.ListBlock && it.blockType == BlockType.LIST_CHECK }) checklists++ if (note.trashed) trashed++ } - val usedTagUids = notes.mapNotNullTo(HashSet()) { it.tagUid } + val usedTagUids = notes.flatMapTo(HashSet()) { it.tagUids } val payload = BackupPayload( exportedAt = now, tags = tags.values.filter { it.uid in usedTagUids }.mapIndexed { index, tag -> tag.copy(position = index) }, @@ -149,15 +150,24 @@ object ImportMapper { private fun textOf(blocks: List): String = blocks.joinToString("\n") { block -> when (block) { is Block.TextBlock -> block.text.value + is Block.HBlock -> block.text.value + is Block.QuoteBlock -> block.text.value + + is Block.CodeBlock -> block.text.value + + is Block.TableBlock -> + block.table.value.rows.joinToString("\n") { row -> row.cells.joinToString(" ") { it.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)) + val digest = Crypto.sha256(text.encodeToByteArray()) return buildString(digest.size * 2) { digest.forEach { byte -> val b = byte.toInt() @@ -187,7 +197,7 @@ object ImportAnalyzer { read: ReadResult, forced: ImportSource? = null, limits: ImportLimits = ImportLimits(), - now: Long = System.currentTimeMillis(), + now: Long = currentTimeMillis(), ): Analysis { val kinds = FormatDetector.classify(read.entries, limits) val detection = if (forced == null) FormatDetector.detect(kinds) else FormatDetector.validate(forced, kinds) diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReader.kt similarity index 82% rename from app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReader.kt index ceea0094..13307619 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReader.kt @@ -1,16 +1,17 @@ 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 +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.Source +import kotlinx.io.buffered +import kotlinx.io.readByteArray /** * 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?) +class SourceFile(val path: String, val unsafe: Boolean = false, val open: () -> RawSource?) /** Where the files of a selection come from; the Storage Access Framework implementation is [SafImportFiles]. */ fun interface ImportFileSource { @@ -51,7 +52,7 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { private var seen = 0 private var attachments = 0 private var skippedBytes = 0L - private val scratch = ByteArray(BUFFER) + private val scratch = Buffer() private var inputBytes = 0L private var totalBytes = 0L private var unsafe = 0 @@ -82,15 +83,13 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { file.open() } catch (_: IOException) { null - } catch (_: SecurityException) { - null } if (stream == null) { unreadable++ return } try { - stream.use { process(file.path, Counting(it)) } + Counting(stream).buffered().use { process(file.path, it) } } catch (_: IOException) { unreadable++ } catch (_: IllegalArgumentException) { @@ -99,17 +98,16 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { } } - private fun process(path: String, input: InputStream) { + private fun process(path: String, input: Source) { val head = readHead(input) - val whole = SequenceInputStream(ByteArrayInputStream(head), input) when { - isZip(head) -> readZip(whole) + isZip(head) -> readZip(input) - TarReader.looksLikeTar(head) -> readTar(whole) + TarReader.looksLikeTar(head) -> readTar(input) else -> { countEntry() - accept(path, whole) + accept(path, input) } } } @@ -128,18 +126,20 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { } /** Up to [TarReader.BLOCK] bytes of [input]: enough to tell a ZIP or TAR from a plain file. */ - private fun readHead(input: InputStream): ByteArray { + private fun readHead(input: Source): ByteArray { val head = ByteArray(TarReader.BLOCK) + // Peeked, not consumed: the archive readers below start at the first byte. + val peek = input.peek() var read = 0 while (read < head.size) { - val n = input.read(head, read, head.size - read) + val n = peek.readAtMostTo(head, read, head.size) if (n < 0) break read += n } return head.copyOf(read) } - private fun readZip(input: InputStream) { + private fun readZip(input: Source) { val zip = ZipStreamReader(input) while (true) { checkCancelled() @@ -158,7 +158,7 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { } } - private fun readTar(input: InputStream) { + private fun readTar(input: Source) { val tar = TarReader(input) while (true) { checkCancelled() @@ -180,7 +180,7 @@ 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) { + private fun accept(rawPath: String, input: RawSource) { val path = safePath(rawPath, limits.maxPathDepth) val cap = if (path != null && isContainerName(path)) limits.maxContainerBytes else limits.maxEntryBytes val bytes = readBounded(input, cap) @@ -196,16 +196,17 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { * 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) { + private fun skipDirectory(input: RawSource) { // 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) { + private fun drain(input: RawSource) { while (true) { - val n = input.read(scratch) + val n = input.readAtMostTo(scratch, BUFFER.toLong()) if (n < 0) return + scratch.clear() skippedBytes += n if (skippedBytes > limits.maxSkippedBytes) throw ImportException(ImportError.TOO_LARGE) checkCancelled() @@ -213,42 +214,36 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { } /** 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) + private fun readBounded(input: RawSource, cap: Int): ByteArray? { + val out = Buffer() + val chunk = Buffer() var over = false while (true) { - val n = input.read(buffer) + val n = input.readAtMostTo(chunk, BUFFER.toLong()) 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) + if (out.size + n > cap) over = true else chunk.transferTo(out) } + chunk.clear() checkCancelled() } - return if (over) null else out.toByteArray() + return if (over) null else out.readByteArray() } - 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()) + /** Counts the bytes taken from the file itself against [ImportLimits.maxInputBytes]. */ + private inner class Counting(private val upstream: RawSource) : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + val n = upstream.readAtMostTo(sink, byteCount) + if (n > 0) { + inputBytes += n + if (inputBytes > limits.maxInputBytes) throw ImportException(ImportError.TOO_LARGE) + } return n } - override fun markSupported(): Boolean = false - - private fun count(n: Long) { - inputBytes += n - if (inputBytes > limits.maxInputBytes) throw ImportException(ImportError.TOO_LARGE) - } + override fun close() = upstream.close() } } diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt index e67442a3..1784213d 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt @@ -3,9 +3,9 @@ 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 +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType /** * One note of the review list. Held in memory only, for the open vault; it is dropped on cancel, @@ -17,7 +17,8 @@ data class ReviewNote( /** About one line of the first text, no markup. */ val snippet: String, val hasChecklist: Boolean, - val tag: String?, + /** The note's tags by name, in order. */ + val tags: List, val trashed: Boolean, val date: Long, /** A note with this uid is already in the vault, so importing it would add nothing. */ @@ -30,12 +31,12 @@ object ImportReview { private const val BODY_CHARS = 4000 private val WHITESPACE = Regex("""\s+""") - internal fun row(uid: String, note: ImportedNote, tag: String?, date: Long) = ReviewNote( + internal fun row(uid: String, note: ImportedNote, tags: List, 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, + tags = tags, trashed = note.trashed, date = date, ) @@ -49,6 +50,7 @@ object ImportReview { is Block.TextBlock -> block.text.value is Block.HBlock -> block.text.value is Block.QuoteBlock -> block.text.value + is Block.CodeBlock -> block.text.value is Block.ListBlock -> block.items.value.firstOrNull()?.value.orEmpty() else -> "" } @@ -71,6 +73,12 @@ object ImportReview { is Block.LinkBlock -> block.block.value.url + is Block.CodeBlock -> block.text.value + + is Block.TableBlock -> block.table.value.rows.joinToString("\n") { row -> + row.cells.joinToString(" | ") { it.value } + } + is Block.ListBlock -> block.items.value.withIndex().joinToString("\n") { (index, item) -> when (block.blockType) { BlockType.LIST_CHECK -> (if (item.isCheck) "☑ " else "☐ ") + item.value @@ -86,7 +94,7 @@ object ImportReview { */ fun selection(payload: BackupPayload, selected: Set): BackupPayload { val notes = payload.notes.filter { it.uid in selected } - val used = notes.mapNotNullTo(HashSet()) { it.tagUid } + val used = notes.flatMapTo(HashSet()) { it.tagUids } 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/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportText.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/data/importer/ImportText.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportText.kt index 3d4ee431..40ea6b31 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportText.kt @@ -1,15 +1,12 @@ package com.pasich.encly.data.importer -import com.pasich.encly.dynamicBlocks.hasContent +import com.pasich.encly.editor.model.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 { @@ -19,10 +16,8 @@ internal object ImportText { /** 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") + bytes.decodeToString(throwOnInvalidSequence = true).removePrefix(Char(BOM_CODE).toString()) + .replace("\r\n", "\n") .replace('\r', '\n') } catch (_: CharacterCodingException) { null diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTime.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTime.kt new file mode 100644 index 00000000..5c688b7f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTime.kt @@ -0,0 +1,144 @@ +package com.pasich.encly.data.importer + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlin.time.Instant + +/** + * 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 END_OF_DAY = Regex("""^(\d{4}-\d{2}-\d{2})T24:00(?::00(?:\.0*)?)?Z$""") + private val LEAP_SECOND = Regex("""T23:59:60((?:\.\d*)?Z)$""") + private val MISSING_SECONDS = Regex("""(T\d{2}:\d{2})(?=Z|[+-]\d)""") + private val EMPTY_FRACTION = Regex("""(:\d{2})\.(?=Z|[+-]\d|$)""") + + private val ISO_FORMS: List<(String) -> Long> = listOf( + { Instant.parse(it).toEpochMilliseconds() }, + { LocalDateTime.parse(it).toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() }, + { LocalDate.parse(it).atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() }, + ) + + /** 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 } + } + + /** + * The forms java.time took that kotlinx-datetime does not, rewritten to ones it does: the + * letters T and Z in either case, no seconds before an offset (`19:32Z`), an empty fraction + * (`19:32:53.Z`), and with `Z` the end of the day as `24:00:00` and a leap second `23:59:60`. + */ + private fun lenient(text: String): String { + val upper = text.uppercase() + val endOfDay = END_OF_DAY.matchEntire(upper) + return if (endOfDay != null) { + LocalDate.parse(endOfDay.groupValues[1]).plus(1, DateTimeUnit.DAY).toString() + "T00:00:00Z" + } else { + upper.replace(LEAP_SECOND, "T23:59:59$1") + .replace(MISSING_SECONDS, "$1:00") + .replace(EMPTY_FRACTION, "$1") + } + } + + private fun iso(text: String): Long? { + val prepared = try { + lenient(text) + } catch (_: IllegalArgumentException) { + return null + } + return ISO_FORMS.firstNotNullOfOrNull { form -> + try { + form(prepared) + } catch (_: IllegalArgumentException) { + 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(year, month, day, hour24, clock[MINUTE].toIntOrNull() ?: 0, clock[SECOND].toIntOrNull() ?: 0) + .toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() + } catch (_: IllegalArgumentException) { + 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/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTypes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTypes.kt new file mode 100644 index 00000000..248c0cd2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTypes.kt @@ -0,0 +1,191 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.import_joplin_formats +import com.pasich.encly.core.resources.import_joplin_howto +import com.pasich.encly.core.resources.import_joplin_losses +import com.pasich.encly.core.resources.import_joplin_name +import com.pasich.encly.core.resources.import_keep_formats +import com.pasich.encly.core.resources.import_keep_howto +import com.pasich.encly.core.resources.import_keep_losses +import com.pasich.encly.core.resources.import_keep_name +import com.pasich.encly.core.resources.import_md_formats +import com.pasich.encly.core.resources.import_md_howto +import com.pasich.encly.core.resources.import_md_losses +import com.pasich.encly.core.resources.import_md_name +import com.pasich.encly.core.resources.import_nn_formats +import com.pasich.encly.core.resources.import_nn_howto +import com.pasich.encly.core.resources.import_nn_losses +import com.pasich.encly.core.resources.import_nn_name +import com.pasich.encly.core.resources.import_sn_formats +import com.pasich.encly.core.resources.import_sn_howto +import com.pasich.encly.core.resources.import_sn_losses +import com.pasich.encly.core.resources.import_sn_name +import com.pasich.encly.editor.model.Block +import org.jetbrains.compose.resources.StringResource + +/** + * 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( + val title: StringResource, + val formats: StringResource, + val howTo: StringResource, + val losses: StringResource, +) { + GOOGLE_KEEP( + Res.string.import_keep_name, + Res.string.import_keep_formats, + Res.string.import_keep_howto, + Res.string.import_keep_losses, + ), + STANDARD_NOTES( + Res.string.import_sn_name, + Res.string.import_sn_formats, + Res.string.import_sn_howto, + Res.string.import_sn_losses, + ), + JOPLIN( + Res.string.import_joplin_name, + Res.string.import_joplin_formats, + Res.string.import_joplin_howto, + Res.string.import_joplin_losses, + ), + NOTESNOOK( + Res.string.import_nn_name, + Res.string.import_nn_formats, + Res.string.import_nn_howto, + Res.string.import_nn_losses, + ), + MARKDOWN( + Res.string.import_md_name, + Res.string.import_md_formats, + Res.string.import_md_howto, + Res.string.import_md_losses, + ), +} + +/** Why an import stops before anything is written. */ +enum class ImportError { + /** A limit of [ImportLimits] was exceeded. */ + TOO_LARGE, + + /** Files were read, but none is a supported export. */ + UNSUPPORTED, + + /** Nothing readable was selected. */ + NOTHING_FOUND, + + /** The selection could not be opened or read. */ + UNREADABLE, +} + +/** Carries an [ImportError]; the message never quotes content. */ +class ImportException(val error: ImportError) : Exception(error.name) + +/** + * Every limit is enforced on the bytes actually read, never on a size an archive header claims, + * so a ZIP bomb stops at the limit. + */ +data class ImportLimits( + /** Bytes read from the selected files in total (compressed bytes count as read); room for a Takeout of photos. */ + val maxInputBytes: Long = MAX_INPUT_BYTES, + /** Bytes after decompression, over every entry that is kept or looked at (oversized ones included). */ + val maxTotalBytes: Long = MAX_TOTAL_BYTES, + /** Bytes inflated and thrown away: media and attachments in a ZIP, which are counted, never kept. */ + val maxSkippedBytes: Long = MAX_SKIPPED_BYTES, + /** One note file. */ + val maxEntryBytes: Int = MAX_ENTRY_BYTES, + /** A JSON export that holds many notes at once (Standard Notes). */ + val maxContainerBytes: Int = MAX_CONTAINER_BYTES, + val maxEntries: Int = MAX_ENTRIES, + val maxNotes: Int = MAX_NOTES, + val maxPathDepth: Int = MAX_PATH_DEPTH, + /** JSON nesting; the tree parser recurses per level. */ + val maxJsonDepth: Int = MAX_JSON_DEPTH, +) { + companion object { + private const val MIB = 1024 * 1024 + const val MAX_TOTAL_BYTES = 64L * MIB + const val MAX_INPUT_BYTES = 2048L * MIB + const val MAX_SKIPPED_BYTES = 2048L * MIB + const val MAX_ENTRY_BYTES = 4 * MIB + const val MAX_CONTAINER_BYTES = 16 * MIB + const val MAX_ENTRIES = 20_000 + const val MAX_NOTES = 20_000 + const val MAX_PATH_DEPTH = 16 + const val MAX_JSON_DEPTH = 64 + } +} + +/** One file of the selection (or one archive member), fully in memory and never on disk. */ +class ImportEntry(val path: String, val bytes: ByteArray) { + val name: String get() = path.substringAfterLast('/') + val extension: String get() = name.substringAfterLast('.', "").lowercase() +} + +/** Files the reading layer left out, by reason. */ +data class ReadSkips( + val unsafePaths: Int = 0, + val nestedArchives: Int = 0, + val tooLarge: Int = 0, + val unreadable: Int = 0, +) { + val total: Int get() = unsafePaths + nestedArchives + tooLarge + unreadable +} + +/** What the parsers could not bring over, counted over the whole selection. */ +data class ImportLosses( + val attachments: Int = 0, + val pinned: Int = 0, + val archived: Int = 0, + val colors: Int = 0, + /** 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, + 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; the note gets every one of them. */ + 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(), +) + +/** Whether this is the platform's out-of-memory error, which reading an enormous import can raise. */ +internal expect fun Throwable.isOutOfMemory(): Boolean diff --git a/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/JoplinParser.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/JoplinParser.kt index f0fd8982..37f3b958 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/JoplinParser.kt @@ -1,12 +1,14 @@ package com.pasich.encly.data.importer +import com.pasich.encly.editor.codec.MarkdownBlockMapper + /** * 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 + * A note gets its explicit Joplin tags (alphabetical), else its notebook's * name. To-do notes arrive as ordinary notes and are counted. Resources (attachments) stay behind. */ internal object JoplinParser { @@ -66,7 +68,7 @@ internal object JoplinParser { 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)) + context.add(note(note, explicit.ifEmpty { notebook })) } return context.result() } @@ -79,7 +81,7 @@ internal object JoplinParser { .groupBy({ it.first }, { it.second }) } - private fun note(note: Item, tags: List, explicitCount: Int) = ImportedNote( + private fun note(note: Item, tags: List) = ImportedNote( sourceKey = note.id, title = note.title, blocks = MarkdownBlockMapper.toBlocks(note.body), @@ -87,10 +89,7 @@ internal object JoplinParser { 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, - ), + losses = ImportLosses(todos = if (note.props["is_todo"] == "1") 1 else 0), ) private fun time(item: Item, vararg keys: String): Long? = diff --git a/app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/KeepParser.kt similarity index 90% rename from app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/KeepParser.kt index 5765993d..928c102e 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/KeepParser.kt @@ -1,8 +1,9 @@ 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 com.pasich.encly.editor.codec.ImportBlocks +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -13,8 +14,8 @@ import kotlinx.serialization.json.JsonObject * 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 + * the labels as the tags, trash state, timestamps (microseconds). Counted as losses: + * attachments, pinned, archived, colors. HTML duplicates and media files are * ignored. */ internal object KeepParser { @@ -60,7 +61,6 @@ internal object KeepParser { 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), ), ) } diff --git a/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/LexicalBlocks.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/LexicalBlocks.kt index 8498c8db..7d658f2d 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/LexicalBlocks.kt @@ -1,8 +1,9 @@ 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 com.pasich.encly.editor.codec.ImportBlocks +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject diff --git a/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/MarkdownNotesParser.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/MarkdownNotesParser.kt index fb02fa68..0a621e47 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/MarkdownNotesParser.kt @@ -1,5 +1,7 @@ package com.pasich.encly.data.importer +import com.pasich.encly.editor.codec.MarkdownBlockMapper + /** * Markdown / TXT files: generic (Obsidian, Markor, Joplin's Markdown export, ...) and the * Notesnook export, which differs only in its extras ([notesnook]). @@ -75,7 +77,7 @@ internal object MarkdownNotesParser { 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), + losses = losses(doc, notesnook, archivedFolder = ARCHIVE_DIR in dirs), ) } @@ -104,12 +106,10 @@ internal object MarkdownNotesParser { 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), - ) + /** Notesnook's extras that Encly has no counterpart for. */ + private fun losses(doc: MarkdownDocument, 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, + ) } diff --git a/app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/StandardNotesParser.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/StandardNotesParser.kt index df411ac1..78fedef4 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/StandardNotesParser.kt @@ -1,7 +1,9 @@ package com.pasich.encly.data.importer -import com.pasich.encly.data.handoff.InlineHtml -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.codec.ImportBlocks +import com.pasich.encly.editor.codec.InlineHtml +import com.pasich.encly.editor.codec.MarkdownBlockMapper +import com.pasich.encly.editor.model.Block import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -68,7 +70,7 @@ internal object StandardNotesParser { 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. + // Alphabetical, so the tags' order 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)) } @@ -111,7 +113,6 @@ internal object StandardNotesParser { 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), ), ) } diff --git a/app/src/main/java/com/pasich/encly/data/importer/TarReader.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/TarReader.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/data/importer/TarReader.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/TarReader.kt index 03ee8807..b36b3424 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/TarReader.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/TarReader.kt @@ -1,7 +1,9 @@ package com.pasich.encly.data.importer -import java.io.IOException -import java.io.InputStream +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.Source /** * A minimal sequential reader for POSIX ustar / GNU tar (Joplin's JEX export). It never seeks and @@ -14,7 +16,7 @@ import java.io.InputStream */ // One small format (header fields, padding, long names); a malformed header exits early. @Suppress("TooManyFunctions", "ReturnCount") -internal class TarReader(private val input: InputStream) { +internal class TarReader(private val input: Source) { class Header(val path: String, val type: Char) private var remaining = 0L @@ -52,19 +54,17 @@ internal class TarReader(private val input: InputStream) { } /** 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 { + fun data(): RawSource = object : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { if (remaining <= 0) return -1 - val n = input.read(b, off, minOf(len.toLong(), remaining).toInt()) + if (byteCount == 0L) return 0 + val n = input.readAtMostTo(sink, minOf(byteCount, remaining)) if (n < 0) throw IOException("truncated tar") remaining -= n return n } + + override fun close() = Unit } private fun end(): Header? { @@ -76,7 +76,7 @@ internal class TarReader(private val input: InputStream) { val block = ByteArray(BLOCK) var read = 0 while (read < BLOCK) { - val n = input.read(block, read, BLOCK - read) + val n = input.readAtMostTo(block, read, BLOCK) if (n < 0) { if (read == 0) return null throw IOException("truncated tar") @@ -91,7 +91,7 @@ internal class TarReader(private val input: InputStream) { val data = ByteArray(size.toInt()) var read = 0 while (read < data.size) { - val n = input.read(data, read, data.size - read) + val n = input.readAtMostTo(data, read, data.size) if (n < 0) throw IOException("truncated tar") read += n } @@ -103,7 +103,7 @@ internal class TarReader(private val input: InputStream) { var left = count val sink = ByteArray(BLOCK) while (left > 0) { - val n = input.read(sink, 0, minOf(left, sink.size.toLong()).toInt()) + val n = input.readAtMostTo(sink, 0, minOf(left, sink.size.toLong()).toInt()) if (n < 0) throw IOException("truncated tar") left -= n } @@ -113,7 +113,7 @@ internal class TarReader(private val input: InputStream) { 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 ustar = block.decodeToString(MAGIC_OFFSET, MAGIC_OFFSET + USTAR.length) == USTAR val prefix = if (ustar) cString(block, PREFIX_OFFSET, PREFIX_LENGTH) else "" return if (prefix.isEmpty()) base else "$prefix/$base" } @@ -121,7 +121,7 @@ internal class TarReader(private val input: InputStream) { 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) + return bytes.decodeToString(offset, end) } private fun octal(block: ByteArray, offset: Int, length: Int): Long { @@ -158,9 +158,9 @@ internal class TarReader(private val input: InputStream) { 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 + val length = data.decodeToString(pos, space).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) + val record = data.decodeToString(space + 1, pos + length - 1) if (record.startsWith("path=")) return record.removePrefix("path=") pos += length } @@ -200,8 +200,8 @@ internal class TarReader(private val input: InputStream) { /** 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) + if (head.decodeToString(MAGIC_OFFSET, MAGIC_OFFSET + USTAR.length) != USTAR) return false + return runCatching { TarReader(Buffer()).checksumOk(head.copyOf(BLOCK)) }.getOrDefault(false) } } } diff --git a/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ZipStreamReader.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ZipStreamReader.kt index c46adc58..62b05bdf 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ZipStreamReader.kt @@ -1,17 +1,16 @@ 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 +import com.pasich.encly.data.zip.RawInflater +import com.pasich.encly.data.zip.decodeCp437 +import com.pasich.encly.data.zip.newCrc32 +import com.pasich.encly.data.zip.newRawInflater +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.Source /** - * A sequential ZIP reader, because [java.util.zip.ZipInputStream] cannot read what streaming + * A sequential ZIP reader, because the platform's ZIP stream 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. @@ -24,20 +23,21 @@ import java.util.zip.Inflater */ // 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) { +internal class ZipStreamReader(private val input: Source) { 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 + val crc = newCrc32() + val inflater = if (method == DEFLATED) newRawInflater() else null var fed = 0 var storedLeft = size var done = false } private val buf = ByteArray(BUFFER) + private val chunk = ByteArray(BUFFER) private var pos = 0 private var lim = 0 private var state: State? = null @@ -60,13 +60,16 @@ internal class ZipStreamReader(private val input: InputStream) { } /** 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 + fun data(): RawSource = object : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + if (byteCount == 0L) return 0 + val length = minOf(byteCount, chunk.size.toLong()).toInt() + val n = state?.let { readData(it, chunk, 0, length) } ?: -1 + if (n > 0) sink.write(chunk, 0, n) + return n.toLong() } - override fun read(b: ByteArray, off: Int, len: Int): Int = state?.let { readData(it, b, off, len) } ?: -1 + override fun close() = Unit } private fun open(header: ByteArray): Entry { @@ -96,14 +99,11 @@ internal class ZipStreamReader(private val input: InputStream) { */ private fun decodeName(bytes: ByteArray): String { val utf8 = try { - Charsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)).toString() + bytes.decodeToString(throwOnInvalidSequence = true) } catch (_: CharacterCodingException) { null } - return utf8 ?: String(bytes, cp437) + return utf8 ?: decodeCp437(bytes) } /** The (size, compressed size) of the ZIP64 extra field, if present. */ @@ -141,13 +141,13 @@ internal class ZipStreamReader(private val input: InputStream) { 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) + buf.copyInto(b, off, pos, pos + n) pos += n entry.storedLeft -= n return n } - private fun inflate(entry: State, inflater: Inflater, b: ByteArray, off: Int, len: Int): Int { + private fun inflate(entry: State, inflater: RawInflater, b: ByteArray, off: Int, len: Int): Int { while (true) { if (inflater.needsInput()) { sync(entry, inflater) @@ -155,11 +155,7 @@ internal class ZipStreamReader(private val input: InputStream) { 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") - } + val n = inflater.inflate(b, off, len) when { n > 0 -> return n @@ -176,7 +172,7 @@ internal class ZipStreamReader(private val input: InputStream) { } /** Moves the read position past the input the inflater has used. */ - private fun sync(entry: State, inflater: Inflater) { + private fun sync(entry: State, inflater: RawInflater) { pos += entry.fed - inflater.remaining entry.fed = 0 } @@ -220,7 +216,7 @@ internal class ZipStreamReader(private val input: InputStream) { private fun fill(): Boolean { if (pos >= lim) { - lim = input.read(buf, 0, buf.size).coerceAtLeast(0) + lim = input.readAtMostTo(buf, 0, buf.size).coerceAtLeast(0) pos = 0 } return pos < lim @@ -229,11 +225,11 @@ internal class ZipStreamReader(private val input: InputStream) { /** 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) + buf.copyInto(buf, 0, pos, lim) lim -= pos pos = 0 while (lim < count) { - val n = input.read(buf, lim, buf.size - lim) + val n = input.readAtMostTo(buf, lim, buf.size) if (n < 0) break lim += n } @@ -247,7 +243,7 @@ internal class ZipStreamReader(private val input: InputStream) { while (done < count) { if (!fill()) throw IOException("truncated zip") val n = minOf(count - done, lim - pos) - System.arraycopy(buf, pos, out, done, n) + buf.copyInto(out, done, pos, pos + n) pos += n done += n } @@ -269,11 +265,6 @@ 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 diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt new file mode 100644 index 00000000..219c46cf --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt @@ -0,0 +1,127 @@ +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.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.domain.repository.NotesRepository +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private const val TAG = "NotesRepository" + +/** Keeps the opening that created this repository; delayed calls cannot cross a re-lock. */ +@Suppress("TooManyFunctions") // One function per read and per single-column write. +@Inject +class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : NotesRepository { + private val lease: DatabaseLease? = databaseProvider.captureLease() + + private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).notesDao() + + override fun getAllNotesWithTag(): Flow> = + daoFlow { dao().getAllNotesWithTags() }.map { it.map(NoteWithTags::normalized) } + + override fun getArchivedNotesWithTag(): Flow> = + daoFlow { dao().getArchivedNotesWithTags() }.map { it.map(NoteWithTags::normalized) } + + override fun getNotesOfTagWithArchived(tagId: Long): Flow> = + daoFlow { dao().getNotesOfTagWithArchived(tagId) }.map { it.map(NoteWithTags::normalized) } + + override fun observeNote(noteId: Long): Flow = + daoFlow { dao().observeNoteWithTags(noteId) }.map { it?.normalized() } + + override fun tagNoteCounts(): Flow> = + daoFlow { dao().observeTagNoteCounts() }.map { counts -> counts.associate { it.tagId to it.count } } + + override suspend fun setColor(noteId: Long, color: String): Result = storageWrite(TAG, "setColor") { + dao().updateColor(noteId, color).requireRows() + } + + override suspend fun setTagged(noteId: Long, tagId: Long, tagged: Boolean): Result = + storageWrite(TAG, "setTagged") { + // A tag that is already on (or off) is not a failure: the note is as asked. + if (tagged) { + dao().changeNoteTags(noteId, added = listOf(tagId), removed = emptyList()) + } else { + dao().changeNoteTags(noteId, added = emptyList(), removed = listOf(tagId)) + } + } + + override suspend fun clearTags(noteId: Long): Result = storageWrite(TAG, "clearTags") { + dao().clearNoteTags(noteId) + Unit + } + + override suspend fun setDescription(noteId: Long, description: String): Result = + storageWrite(TAG, "setDescription") { dao().updateDescription(noteId, description).requireRows() } + + override suspend fun setPinned(noteId: Long, pinned: Boolean): Result = storageWrite(TAG, "setPinned") { + dao().updatePinned(noteId, pinned) + // An archived note refuses a pin without touching a row; that is not a failure. + Unit + } + + override suspend fun setArchived(noteId: Long, archived: Boolean): Result = + storageWrite(TAG, "setArchived") { dao().updateArchived(noteId, archived).requireRows() } + + override fun getTrashNotes(): Flow> = daoFlow { dao().getTrashNotes() } + + override suspend fun getNoteById(noteId: Long): Note? = dao().getNoteWithTags(noteId)?.normalized()?.note + + override suspend fun insertNote(note: Note): Result = storageWrite(TAG, "insertNote") { + dao().insertNoteWithTags(note) + } + + override suspend fun updateEdited(note: Note, base: Note?): Result = storageWrite(TAG, "updateEdited") { + if (base == null) { + // Nothing to diff against: the note gets exactly the editor's tags, in the transaction of the row. + return@storageWrite dao().updateEditedWithAllTags(note).requireRows() + } + dao().saveEdited( + noteId = note.id, + title = note.title, + value = note.value, + stamp = note.date != base.date || note.description != base.description, + description = note.description, + date = note.date, + setPinned = note.isPinned != base.isPinned, + pinned = note.isPinned, + setColor = note.color != base.color, + color = note.color, + setTextSize = note.textSize != base.textSize, + textSize = note.textSize, + setLineSpacing = note.lineSpacing != base.lineSpacing, + lineSpacing = note.lineSpacing, + addTags = note.tagIds - base.tagIds, + removeTags = base.tagIds - note.tagIds, + ).requireRows() + } + + override suspend fun setTrashed(noteId: Long, trashed: Boolean, at: Long?): Result = + storageWrite(TAG, "setTrashed") { dao().updateTrashed(noteId, trashed, at.takeIf { trashed }).requireRows() } + + override suspend fun deleteNoteById(id: Long): Result = storageWrite(TAG, "deleteNoteById") { + dao().deleteNoteById(id).requireRows() + } + + override suspend fun stampUntimedTrash(at: Long): Result = storageWrite(TAG, "stampUntimedTrash") { + // Any number of rows, none included: an empty trash is not a failed write. + dao().stampTrashedAt(at) + Unit + } + + override suspend fun deleteTrashedBefore(cutoff: Long): Result = storageWrite(TAG, "deleteTrashedBefore") { + // Any number of rows, none included: nothing expired is not a failed write. + dao().deleteTrashedBefore(cutoff) + } +} + +/** The tags in their display order (position, then id), and their ids on the note as well. */ +internal fun NoteWithTags.normalized(): NoteWithTags { + val sorted = tags.sortedWith(TAG_ORDER) + return NoteWithTags(note.copy(tagIds = sorted.mapTo(LinkedHashSet()) { it.id }), sorted) +} + +internal val TAG_ORDER: Comparator = compareBy({ it.position }, { it.id }) diff --git a/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt similarity index 92% rename from app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt index 9d769f7d..04db559e 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt @@ -11,15 +11,16 @@ import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.domain.model.ThemeType import com.pasich.encly.domain.repository.SettingsRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first -import javax.inject.Inject -import javax.inject.Singleton -@Singleton +@SingleIn(AppScope::class) @Suppress("TooManyFunctions") // Implements the settings interface with one delegate per setting. -class SettingsRepositoryImpl @Inject constructor(private val settingsLocalDataSource: SettingsLocalDataSource) : - SettingsRepository { +@Inject +class SettingsRepositoryImpl(private val settingsLocalDataSource: SettingsLocalDataSource) : SettingsRepository { override val themeSettingsFlow: Flow = settingsLocalDataSource.themeSettingsFlow override val isGridNoteList: Flow = settingsLocalDataSource.isGridNoteListFlow diff --git a/app/src/main/java/com/pasich/encly/data/repository/StorageCalls.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/StorageCalls.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/data/repository/StorageCalls.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/repository/StorageCalls.kt diff --git a/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/TagsRepositoryImpl.kt similarity index 90% rename from app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/repository/TagsRepositoryImpl.kt index 0241be9e..08071c46 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/TagsRepositoryImpl.kt @@ -4,14 +4,15 @@ 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 dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import javax.inject.Inject private const val TAG = "TagsRepository" /** Bound to the opening that created it; see [NotesRepositoryImpl]. */ -class TagsRepositoryImpl @Inject constructor(private val databaseProvider: DatabaseProvider) : TagsRepository { +@Inject +class TagsRepositoryImpl(private val databaseProvider: DatabaseProvider) : TagsRepository { private val lease: DatabaseLease? = databaseProvider.captureLease() private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).tagsDao() diff --git a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/TasksRepositoryImpl.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/data/repository/TasksRepositoryImpl.kt index 4167bfdd..06f55168 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/TasksRepositoryImpl.kt @@ -5,13 +5,14 @@ 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 dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.Flow -import javax.inject.Inject private const val TAG = "TasksRepository" /** Bound to the opening that created it; see [NotesRepositoryImpl]. */ -class TasksRepositoryImpl @Inject constructor(private val databaseProvider: DatabaseProvider) : TasksRepository { +@Inject +class TasksRepositoryImpl(private val databaseProvider: DatabaseProvider) : TasksRepository { private val lease: DatabaseLease? = databaseProvider.captureLease() private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).tasksDao() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/zip/ZipPlatform.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/zip/ZipPlatform.kt new file mode 100644 index 00000000..e247f2e5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/zip/ZipPlatform.kt @@ -0,0 +1,93 @@ +package com.pasich.encly.data.zip + +import kotlinx.io.RawSource +import kotlinx.io.Sink +import kotlinx.io.files.Path +import kotlin.time.Clock + +/* + * What the ZIP code needs from the platform. kotlinx-io has no ZIP, no CRC-32 and no inflater, and + * the format's default name encoding (CP437) is a charset, so these small interfaces are the whole + * seam: everything that parses, limits or maps archives is platform-free and uses only them. + * The JVM implementation (Android and desktop) is JvmZip.kt, the only file that touches java.util.zip. + */ + +/** A running CRC-32. */ +internal interface Crc32 { + val value: Long + + fun update(bytes: ByteArray, offset: Int, length: Int) +} + +/** + * A raw (headerless) deflate decompressor: the input is handed over in pieces and [inflate] is + * called until [finished]. Corrupt data is a [kotlinx.io.IOException] with the message "bad zip data". + */ +internal interface RawInflater { + /** Input bytes handed over but not yet used. */ + val remaining: Int + + /** Compressed bytes consumed so far. */ + val bytesRead: Long + + /** Uncompressed bytes produced so far. */ + val bytesWritten: Long + + fun needsInput(): Boolean + + fun needsDictionary(): Boolean + + fun finished(): Boolean + + fun setInput(bytes: ByteArray, offset: Int, length: Int) + + /** Up to [length] uncompressed bytes into [bytes]; 0 when it needs input or is finished. */ + fun inflate(bytes: ByteArray, offset: Int, length: Int): Int + + /** Frees the decompressor; it is not used afterwards. */ + fun end() +} + +/** One entry of a [ZipEntrySource]. Everything but [open] comes from the archive's central directory. */ +internal interface ZipEntryHandle { + val name: String + val isDirectory: Boolean + + /** The size the archive claims, or -1 if unknown. A hint only: it can lie, so read through a limit. */ + val declaredSize: Long + + /** The uncompressed data; the caller closes it. */ + fun open(): RawSource +} + +/** An archive with a central directory, read with random access. Closing it closes the file. */ +internal interface ZipEntrySource : AutoCloseable { + val entryCount: Int + + /** The entry called exactly [name] (the platform may also match a directory of that name), or null. */ + fun find(name: String): ZipEntryHandle? +} + +/** Writes a ZIP to a sink: deflated entries, then [finish]. The sink is flushed but not closed. */ +internal interface ZipEntrySink { + /** An entry named [name] holding [bytes], last modified at [time] (epoch millis), or "now" if null. */ + fun put(name: String, time: Long?, bytes: ByteArray) + + /** Writes the central directory; nothing is added afterwards. */ + fun finish() +} + +/** A running CRC-32. */ +internal expect fun newCrc32(): Crc32 + +/** A raw inflater. */ +internal expect fun newRawInflater(): RawInflater + +/** [bytes] as CP437, the ZIP format's default name encoding (ISO-8859-1 where the platform has no CP437). */ +internal expect fun decodeCp437(bytes: ByteArray): String + +/** The ZIP at [path], opened for random access; throws an [kotlinx.io.IOException] if it is not one. */ +internal expect fun openZipFile(path: Path): ZipEntrySource + +/** A ZIP writer over [out]; an entry without a time gets [now] (epoch millis). */ +internal expect fun zipWriter(out: Sink, now: () -> Long = { Clock.System.now().toEpochMilliseconds() }): ZipEntrySink diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt new file mode 100644 index 00000000..288f2913 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt @@ -0,0 +1,99 @@ +package com.pasich.encly.domain.enums + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.sort_created_asc +import com.pasich.encly.core.resources.sort_created_asc_short +import com.pasich.encly.core.resources.sort_created_desc +import com.pasich.encly.core.resources.sort_created_desc_short +import com.pasich.encly.core.resources.sort_title_asc +import com.pasich.encly.core.resources.sort_title_asc_short +import com.pasich.encly.core.resources.sort_title_desc +import com.pasich.encly.core.resources.sort_title_desc_short +import com.pasich.encly.core.resources.sort_updated_asc +import com.pasich.encly.core.resources.sort_updated_asc_short +import com.pasich.encly.core.resources.sort_updated_desc +import com.pasich.encly.core.resources.sort_updated_desc_short +import com.pasich.encly.data.model.Note +import org.jetbrains.compose.resources.StringResource + +enum class NoteSortOption(val labelRes: StringResource, val shortLabelRes: StringResource, val index: Int) { + UPDATED_ASC( + labelRes = Res.string.sort_updated_asc, + shortLabelRes = Res.string.sort_updated_asc_short, + index = 0, + ), + UPDATED_DESC( + labelRes = Res.string.sort_updated_desc, + shortLabelRes = Res.string.sort_updated_desc_short, + index = 1, + ), // DEFAULT + CREATED_ASC( + labelRes = Res.string.sort_created_asc, + shortLabelRes = Res.string.sort_created_asc_short, + index = 2, + ), + CREATED_DESC( + labelRes = Res.string.sort_created_desc, + shortLabelRes = Res.string.sort_created_desc_short, + index = 3, + ), + TITLE_ASC( + labelRes = Res.string.sort_title_asc, + shortLabelRes = Res.string.sort_title_asc_short, + index = 4, + ), + TITLE_DESC( + labelRes = Res.string.sort_title_desc, + shortLabelRes = Res.string.sort_title_desc_short, + index = 5, + ), + ; + + /** Orders notes the way this option names. */ + fun comparator(): Comparator = when (this) { + UPDATED_ASC -> compareBy { it.date } + UPDATED_DESC -> compareByDescending { it.date } + CREATED_ASC -> compareBy { it.createdOrEdited() } + CREATED_DESC -> compareByDescending { it.createdOrEdited() } + TITLE_ASC -> titleComparator(descending = false) + TITLE_DESC -> titleComparator(descending = true) + } + + /** + * Titles by the language's alphabet, case-insensitive ([titleCollator]); equal titles by the + * last edit, newest first. A note without a title (blank) always comes after the titled ones in + * both directions, and those are ordered by the last edit, newest first: their text is no title, + * and the list has no better stable key for them than the date. + */ + private fun titleComparator(descending: Boolean): Comparator { + val collator = titleCollator() + return Comparator { a, b -> + val aTitle = a.title.trim() + val bTitle = b.title.trim() + when { + aTitle.isEmpty() && bTitle.isEmpty() -> b.date.compareTo(a.date) + + aTitle.isEmpty() -> 1 + + bTitle.isEmpty() -> -1 + + else -> { + val byTitle = collator.compare(aTitle, bTitle).let { if (descending) -it else it } + if (byTitle != 0) byTitle else b.date.compareTo(a.date) + } + } + } + } + + /** + * The creation time, or for a note that lost it (an autosave bug stored 0) the only time still + * known, when it was last edited. Never an invented one (an import does the same: created, else + * edited). Without it those notes would all sort as the oldest, ahead of every dated note. Such a + * note moves with its edits in the creation orders; its real creation time cannot be recovered. + */ + private fun Note.createdOrEdited(): Long = if (dateCreate > 0L) dateCreate else date + + companion object { + fun fromIndex(index: Int): NoteSortOption = entries.find { it.index == index } ?: UPDATED_DESC + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.kt new file mode 100644 index 00000000..fce12487 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.kt @@ -0,0 +1,10 @@ +package com.pasich.encly.domain.enums + +/** + * Compares two note titles the way a reader of the current language expects: case-insensitive and + * with the language's own alphabet order ("Ä" next to "A" in German, "Ї" after "І" in Ukrainian). + * Android and desktop use a `java.text.Collator` of the default locale (the app's language, which + * AppCompat and the system keep as the default); iOS uses the system's locale-aware case-insensitive + * comparison. A new instance per call: a collator is not safe to share between threads. + */ +internal expect fun titleCollator(): Comparator diff --git a/app/src/main/java/com/pasich/encly/domain/model/AppearanceSettings.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/AppearanceSettings.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/domain/model/AppearanceSettings.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/model/AppearanceSettings.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/EditorToolbar.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/EditorToolbar.kt new file mode 100644 index 00000000..77124972 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/EditorToolbar.kt @@ -0,0 +1,191 @@ +package com.pasich.encly.domain.model + +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.state.HEADING_TOOL +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A block tool the editor toolbar can offer. The names are stored (and backed up), so they must not change. + * [type] is the block the tool applies, [headingLevel] the level it names ("Heading 3"); [HEADING] is the + * plain "Heading" tool, which makes an H2 and shows active for every level. + */ +enum class ToolbarTool(val type: BlockType, val headingLevel: Int? = null) { + HEADING(HEADING_TOOL), + LIST_CHECK(BlockType.LIST_CHECK), + LIST_BULLET(BlockType.LIST_BULLET), + LIST_NUMBER(BlockType.LIST_NUMBER), + QUOTE(BlockType.QUOTE), + CODE(BlockType.CODE), + TABLE(BlockType.TABLE), + TEXT(BlockType.TEXT), + H1(BlockType.H1, headingLevel = 1), + H2(BlockType.H2, headingLevel = 2), + H3(BlockType.H3, headingLevel = 3), + H4(BlockType.H4, headingLevel = 4), + LINK(BlockType.LINK), + SEPARATOR(BlockType.SEPARATOR), +} + +/** Where a tool is: in the toolbar's own row, behind the "more" chevron, or not offered. */ +enum class ToolbarSection { MAIN, MORE, HIDDEN } + +/** + * Which tools the editor toolbar shows ([main]), which sit behind its "more" chevron ([more]), and which the user + * has put away ([hidden]), each in order. Every [ToolbarTool] is in exactly one of them. Stored as + * `main:A,B;more:C;hidden:D`. + */ +data class ToolbarLayout( + val main: List, + val more: List, + val hidden: List = emptyList(), +) { + /** The tools of [section], in order. */ + fun tools(section: ToolbarSection): List = when (section) { + ToolbarSection.MAIN -> main + ToolbarSection.MORE -> more + ToolbarSection.HIDDEN -> hidden + } + + /** Where [tool] is. */ + fun sectionOf(tool: ToolbarTool): ToolbarSection = when (tool) { + in main -> ToolbarSection.MAIN + in more -> ToolbarSection.MORE + else -> ToolbarSection.HIDDEN + } + + /** This layout with [tool] hidden, or, if it is hidden, back in the section it has by default. */ + fun toggled(tool: ToolbarTool): ToolbarLayout = when { + tool in hidden && tool in DEFAULT.main -> copy(main = main + tool, hidden = hidden - tool) + tool in hidden -> copy(more = more + tool, hidden = hidden - tool) + else -> ToolbarLayout(main - tool, more - tool, hidden + tool) + } + + /** + * This layout with [tool] moved [steps] places down the list of all tools (up for a negative number). The + * list is the main tools, the "more" divider, the more tools, the "hidden" divider, the hidden tools; the + * tool takes the section of the divider it ends up after. + */ + fun moved(tool: ToolbarTool, steps: Int): ToolbarLayout { + val flat: List = main + MORE_DIVIDER + more + HIDDEN_DIVIDER + hidden + val without = flat.filter { it != tool } + val target = (flat.indexOf(tool) + steps).coerceIn(0, without.size) + val moved = without.take(target) + tool + without.drop(target) + val firstDivider = moved.indexOf(MORE_DIVIDER) + val secondDivider = moved.indexOf(HIDDEN_DIVIDER) + return ToolbarLayout( + main = moved.take(firstDivider).filterIsInstance(), + more = moved.subList(firstDivider + 1, secondDivider).filterIsInstance(), + hidden = moved.drop(secondDivider + 1).filterIsInstance(), + ) + } + + /** The stored form. */ + fun encode(): String = ToolbarSection.entries.joinToString(";") { section -> + "${section.name.lowercase()}:${tools(section).joinToString(",") { it.name }}" + } + + companion object { + private val MORE_DIVIDER = Any() + private val HIDDEN_DIVIDER = Any() + + /** The toolbar as it ships: the common tools in the row, the rest behind the chevron. */ + val DEFAULT = ToolbarLayout( + main = listOf( + ToolbarTool.HEADING, + ToolbarTool.LIST_CHECK, + ToolbarTool.LIST_BULLET, + ToolbarTool.LIST_NUMBER, + ToolbarTool.QUOTE, + ToolbarTool.CODE, + ), + more = listOf( + ToolbarTool.TEXT, + ToolbarTool.H1, + ToolbarTool.H2, + ToolbarTool.H3, + ToolbarTool.H4, + ToolbarTool.LINK, + ToolbarTool.SEPARATOR, + ToolbarTool.TABLE, + ), + ) + + /** + * The layout [stored] describes. A tool name this version does not know is dropped, a tool named twice + * stays where it came first, and a tool the text leaves out (one added by a later version) is put where + * it is by default; text that names no section is the default layout. + */ + fun decode(stored: String?): ToolbarLayout { + val sections = stored.orEmpty().split(';').mapNotNull { part -> + val section = ToolbarSection.entries.firstOrNull { it.name.equals(part.substringBefore(':'), true) } + section?.takeIf { ':' in part }?.let { it to part.substringAfter(':').split(',').mapNotNull(::named) } + }.toMap() + if (sections.isEmpty()) return DEFAULT + val seen = HashSet() + val taken = ToolbarSection.entries.associateWith { sections[it].orEmpty().filter(seen::add) } + val missing = ToolbarTool.entries.filter { it !in seen } + return ToolbarLayout( + main = taken.getValue(ToolbarSection.MAIN) + missing.filter { it in DEFAULT.main }, + more = taken.getValue(ToolbarSection.MORE) + missing.filter { it !in DEFAULT.main }, + hidden = taken.getValue(ToolbarSection.HIDDEN), + ) + } + + private fun named(name: String): ToolbarTool? = ToolbarTool.entries.firstOrNull { it.name == name.trim() } + } +} + +/** How big the toolbar's buttons are: the icon is [icon] dp inside a [button] dp target. */ +data class ToolbarScale(val button: Int, val icon: Int) + +/** + * The buttons of a row of [buttons] across [availableDp]: 44 dp with a 22 dp icon while they fit, then 40 and 36 dp. + * A row may run a little past the edge (the last button is still reached by scrolling); beyond that the smallest + * size is used and the row scrolls. + */ +fun toolbarScale(buttons: Int, availableDp: Int): ToolbarScale = + SCALES.firstOrNull { buttons * it.button + ROW_EXTRA_DP <= availableDp + OVERFLOW_ALLOWED_DP } ?: SCALES.last() + +private val SCALES = listOf( + ToolbarScale(button = 44, icon = 22), + ToolbarScale(button = 40, icon = 20), + ToolbarScale(button = 36, icon = 18), +) + +/** What a row needs besides its buttons: the rule between groups and the gap after the toggle. */ +private const val ROW_EXTRA_DP = 10 + +/** How far past the screen's edge a row may reach before its buttons shrink. */ +private const val OVERFLOW_ALLOWED_DP = 24 + +/** + * "Editor toolbar" (Settings → Appearance): which block tools the toolbar offers and in what order. Kept in the + * app's flags and read at once, so the first note opened already shows it. Unreadable text is the default layout. + */ +@SingleIn(AppScope::class) +@Inject +class EditorToolbar(private val flags: KeyValueStore) { + private val _layout = MutableStateFlow(ToolbarLayout.decode(flags.getString(LAYOUT_KEY, null))) + + /** The layout in use, live. */ + val layout: StateFlow = _layout.asStateFlow() + + /** Uses and stores [layout]. */ + fun set(layout: ToolbarLayout) { + flags.edit { putString(LAYOUT_KEY, layout.encode()) } + _layout.value = layout + } + + /** Back to the toolbar as it ships. */ + fun reset() = set(ToolbarLayout.DEFAULT) + + private companion object { + const val LAYOUT_KEY = "editor_toolbar" + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteColor.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteColor.kt new file mode 100644 index 00000000..84b8682e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteColor.kt @@ -0,0 +1,39 @@ +package com.pasich.encly.domain.model + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.note_color_blue +import com.pasich.encly.core.resources.note_color_green +import com.pasich.encly.core.resources.note_color_none +import com.pasich.encly.core.resources.note_color_orange +import com.pasich.encly.core.resources.note_color_pink +import com.pasich.encly.core.resources.note_color_purple +import com.pasich.encly.core.resources.note_color_red +import com.pasich.encly.core.resources.note_color_teal +import com.pasich.encly.core.resources.note_color_yellow +import com.pasich.encly.data.model.Note +import org.jetbrains.compose.resources.StringResource + +/** + * The colour a note can be marked with. Only the stable [key] is stored (database and backups); + * the shade is resolved from the active theme at draw time, so it never goes stale when the + * theme or light/dark mode changes. An unknown key (a newer palette) reads as [NONE]. + */ +enum class NoteColor(val key: String, val labelRes: StringResource) { + NONE(Note.NO_COLOR, Res.string.note_color_none), + RED("red", Res.string.note_color_red), + ORANGE("orange", Res.string.note_color_orange), + YELLOW("yellow", Res.string.note_color_yellow), + GREEN("green", Res.string.note_color_green), + TEAL("teal", Res.string.note_color_teal), + BLUE("blue", Res.string.note_color_blue), + PURPLE("purple", Res.string.note_color_purple), + PINK("pink", Res.string.note_color_pink), + ; + + /** The next colour of the palette; the last one wraps to [NONE]. */ + fun next(): NoteColor = entries[(ordinal + 1) % entries.size] + + companion object { + fun fromKey(key: String?): NoteColor = entries.firstOrNull { it.key == key } ?: NONE + } +} diff --git a/app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteLineSpacing.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteLineSpacing.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteListItem.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteListItem.kt new file mode 100644 index 00000000..e59b74c3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteListItem.kt @@ -0,0 +1,39 @@ +package com.pasich.encly.domain.model + +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.utils.NotesTextFormatter + +/** + * A note as the note list and search show it. [preview] and [searchText] are derived from the + * stored blocks once, when the note is loaded, never during composition. + * + * @property preview the note's description, or else the plain text of its blocks; null when + * the blocks cannot be read (damaged, or written by a newer version) + * @property searchText what search matches besides the title: the description and the text the + * user typed, without list markers or separator lines + */ +data class NoteListItem(val noteWithTags: NoteWithTags, val preview: String?, val searchText: String) { + val note: Note get() = noteWithTags.note + + /** The note's tags in their display order. */ + val tags: List get() = noteWithTags.tags + + /** Case-insensitive match of [query] against the title, description and content. */ + fun matches(query: String): Boolean = + note.title.contains(query, ignoreCase = true) || searchText.contains(query, ignoreCase = true) + + companion object { + /** Parses [noteWithTags]' blocks once; an unreadable note becomes an item, not a crash. */ + fun of(noteWithTags: NoteWithTags): NoteListItem { + val note = noteWithTags.note + val blocks = BlockConverter.jsonToBlocksOrNull(note.value) + val preview = note.description.ifEmpty { blocks?.let(NotesTextFormatter::blocksToPlainText) } + val content = blocks?.let(NotesTextFormatter::blocksToSearchText).orEmpty() + val searchText = listOf(note.description, content).filter { it.isNotEmpty() }.joinToString("\n") + return NoteListItem(noteWithTags, preview, searchText) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NotePreviews.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NotePreviews.kt new file mode 100644 index 00000000..eece7873 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NotePreviews.kt @@ -0,0 +1,31 @@ +package com.pasich.encly.domain.model + +import com.pasich.encly.core.platform.KeyValueStore +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * "Show note previews" (Settings → Security, on by default). Off, the note lists show a note's title, tag and + * date but not the first lines of its text, so nothing of a note's content is read over the owner's shoulder. + * Search still matches the full text. The flag is read at once, from the app's flags, so the first list drawn + * after an unlock already follows it. + */ +@SingleIn(AppScope::class) +@Inject +class NotePreviews(private val flags: KeyValueStore) { + private val _show = MutableStateFlow(flags.getBoolean(SHOW_KEY, true)) + val show: StateFlow = _show.asStateFlow() + + fun setShow(enabled: Boolean) { + flags.edit { putBoolean(SHOW_KEY, enabled) } + _show.value = enabled + } + + private companion object { + const val SHOW_KEY = "show_note_previews" + } +} diff --git a/app/src/main/java/com/pasich/encly/domain/model/NoteTextOverride.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteTextOverride.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/domain/model/NoteTextOverride.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteTextOverride.kt diff --git a/app/src/main/java/com/pasich/encly/domain/model/SwipeAction.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/SwipeAction.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/domain/model/SwipeAction.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/model/SwipeAction.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt new file mode 100644 index 00000000..dab6429e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt @@ -0,0 +1,76 @@ +package com.pasich.encly.domain.repository + +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import kotlinx.coroutines.flow.Flow + +/** + * The notes in the encrypted vault. Flows fail through the flow; a read throws; a write + * returns [Result.failure] (including when no row was written) and never a sentinel value. + */ +@Suppress("TooManyFunctions") // One function per read and per single-column write. +interface NotesRepository { + // A note read here carries its tags twice, the same: [NoteWithTags.tags] sorted by the tags' order (position, + // then id), and their ids in [Note.tagIds]. + fun getAllNotesWithTag(): Flow> + fun getArchivedNotesWithTag(): Flow> + + /** The notes (not in the trash, archived ones included) that carry the tag [tagId], in one read. */ + fun getNotesOfTagWithArchived(tagId: Long): Flow> + fun getTrashNotes(): Flow> + + /** The note as stored now (null once it is gone), following every change to it. */ + fun observeNote(noteId: Long): Flow + + /** + * How many notes outside the trash (archived ones included) carry each tag, by tag id; a tag without notes is + * left out. One read, so a note being archived is never counted twice. + */ + fun tagNoteCounts(): Flow> + + // Each writes one column of the stored row (or one tag of the note) and nothing else, so a caller holding an + // older copy of the note cannot undo another change. + suspend fun setColor(noteId: Long, color: String): Result + + /** Puts the tag [tagId] on the note ([tagged]) or takes it off; the note's other tags stay as they are. */ + suspend fun setTagged(noteId: Long, tagId: Long, tagged: Boolean): Result + + /** Takes every tag off the note. */ + suspend fun clearTags(noteId: Long): Result + suspend fun setDescription(noteId: Long, description: String): Result + + /** Pins or unpins; an archived note stays unpinned. */ + suspend fun setPinned(noteId: Long, pinned: Boolean): Result + + /** Archives (and unpins) or unarchives. */ + suspend fun setArchived(noteId: Long, archived: Boolean): Result + + /** The note with its [Note.tagIds]. */ + suspend fun getNoteById(noteId: Long): Note? + + /** The new note's row id. It gets the tags [Note.tagIds], in the same write. */ + suspend fun insertNote(note: Note): Result + + /** + * Saves what an editor owns of [note]: its title and content, and each of pin, colour, text size and line + * spacing that differs from [base] (all of them when [base] is null), plus the date and summary when those + * differ. Its tags change by what differs from [base]: a tag put on or taken off since [base] is put on or + * taken off, and a tag the editor did not touch stays as another window left it (with no [base], the note gets + * exactly [Note.tagIds]). Never the trash or archive state, and not a column the editor did not change: a note + * trashed, archived or pinned from another window meanwhile stays so. + */ + suspend fun updateEdited(note: Note, base: Note?): Result + + /** Moves the note to the trash ([at]: when) or back, writing those two columns only. */ + suspend fun setTrashed(noteId: Long, trashed: Boolean, at: Long?): Result + suspend fun deleteNoteById(id: Long): Result + + /** Gives the trashed notes that have no trash time (imported ones) [at], so the trash can age them. */ + suspend fun stampUntimedTrash(at: Long): Result + + /** + * Deletes for good, in one write, the notes that are in the trash and were trashed before [cutoff]. A note + * restored meanwhile is not in the trash any more and is kept. The number of notes deleted. + */ + suspend fun deleteTrashedBefore(cutoff: Long): Result +} diff --git a/app/src/main/java/com/pasich/encly/domain/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/SettingsRepository.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/domain/repository/SettingsRepository.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/SettingsRepository.kt diff --git a/app/src/main/java/com/pasich/encly/domain/repository/TagsRepository.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/TagsRepository.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/domain/repository/TagsRepository.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/TagsRepository.kt diff --git a/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/TasksRepository.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/TasksRepository.kt diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/OnboardingUseCase.kt similarity index 90% rename from app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/OnboardingUseCase.kt index b074870a..6a4d3fa1 100644 --- a/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/OnboardingUseCase.kt @@ -1,18 +1,20 @@ package com.pasich.encly.domain.usecase -import androidx.fragment.app.FragmentActivity import com.pasich.encly.core.backup.BackupError import com.pasich.encly.core.backup.BackupException import com.pasich.encly.core.common.suspendRunCatching +import com.pasich.encly.core.security.BiometricHost import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.PendingRestore -import javax.inject.Inject -import javax.inject.Singleton +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn -@Singleton -class OnboardingUseCase @Inject constructor( +@SingleIn(AppScope::class) +@Inject +class OnboardingUseCase( private val securityManager: SecurityManager, private val backupManager: BackupManager, private val pendingRestore: PendingRestore, @@ -75,6 +77,6 @@ class OnboardingUseCase @Inject constructor( fun biometricAvailable(): Boolean = securityManager.biometricAvailable() /** Adds the biometric slot to the vault just created; shows the system prompt. */ - fun enrollBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) = - securityManager.enrollBiometric(activity, onResult) + fun enrollBiometric(host: BiometricHost, onResult: (Boolean) -> Unit) = + securityManager.enrollBiometric(host, onResult) } diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/CleanTrashNotesUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/CleanTrashNotesUseCase.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/domain/usecase/note/CleanTrashNotesUseCase.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/CleanTrashNotesUseCase.kt index 9f621c7e..2191a363 100644 --- a/app/src/main/java/com/pasich/encly/domain/usecase/note/CleanTrashNotesUseCase.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/CleanTrashNotesUseCase.kt @@ -3,11 +3,12 @@ package com.pasich.encly.domain.usecase.note import com.pasich.encly.core.common.suspendRunCatching import com.pasich.encly.data.model.Note import com.pasich.encly.domain.repository.NotesRepository +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.first -import javax.inject.Inject /** Deletes notes from the trash for good, stopping at the first one that fails. */ -class CleanTrashNotesUseCase @Inject constructor(private val repository: NotesRepository) { +@Inject +class CleanTrashNotesUseCase(private val repository: NotesRepository) { /** Empties the trash. */ suspend operator fun invoke(): Result { val trashNotes = suspendRunCatching { repository.getTrashNotes().first() } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/ObserveNotesUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/ObserveNotesUseCase.kt new file mode 100644 index 00000000..f8197fa9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/ObserveNotesUseCase.kt @@ -0,0 +1,67 @@ +package com.pasich.encly.domain.usecase.note + +import com.pasich.encly.core.di.IoDispatcher +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.domain.enums.NoteSortOption +import com.pasich.encly.domain.model.NoteListItem +import com.pasich.encly.domain.repository.NotesRepository +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +/** + * The notes of the main list (not trashed, not archived) or of the archive, sorted with the + * pinned ones first, with their previews. Parsing every note's blocks happens here, + * on [dispatcher], once per database change: never on the main thread and never per keystroke + * or recomposition. A read failure fails the flow; the screen turns it into an error state. + */ +@Inject +class ObserveNotesUseCase( + private val repository: NotesRepository, + @IoDispatcher private val dispatcher: CoroutineDispatcher, +) { + /** + * @param tagId the tag to show, or null for every note without a hidden tag + * @param archived the archive instead of the main list ([tagId] does not apply to it) + */ + operator fun invoke( + tagId: Long?, + sortOption: NoteSortOption, + archived: Boolean = false, + ): Flow> { + val notes = when { + archived -> repository.getArchivedNotesWithTag().map { all -> all.filter { it.isShown() } } + + tagId == null -> repository.getAllNotesWithTag().map { all -> all.filter { it.isShown() } } + + // A hidden tag's archived notes are in no other list (the archive leaves hidden tags out + // too), so its own view is the way to unarchive them. A visible tag's are in the archive. + // One read for both: two flows combined could hold a note that is being archived twice. + // A note that also has a hidden tag stays out of a visible tag's view: a hidden tag keeps its + // notes out of every list but the hidden tags' own. + else -> repository.getNotesOfTagWithArchived(tagId).map { all -> + all.filter { item -> + val viewHidden = item.tags.any { it.id == tagId && !it.isVisible } + if (viewHidden) true else item.isShown() && !item.note.isArchived + } + } + } + return notes.map { list -> list.toSortedItems(sortOption) }.flowOn(dispatcher) + } + + /** The note as stored now, following every change to it; null once it is gone. */ + fun observeNote(noteId: Long): Flow = repository.observeNote(noteId) + + /** A note with any hidden tag stays out of the lists that show every note. */ + private fun NoteWithTags.isShown(): Boolean = tags.all { it.isVisible } + + private fun List.toSortedItems(sortOption: NoteSortOption): List { + // Pinned first whatever the sort, archived notes (a hidden tag's view) last; the sort orders within. + val comparator = compareBy { it.isArchived }.thenByDescending { it.isPinned } + .then(sortOption.comparator()) + return sortedWith { a, b -> comparator.compare(a.note, b.note) }.map(NoteListItem::of) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/PurgeExpiredTrashUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/PurgeExpiredTrashUseCase.kt new file mode 100644 index 00000000..158b3622 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/PurgeExpiredTrashUseCase.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.domain.usecase.note + +import com.pasich.encly.core.common.currentTimeMillis +import com.pasich.encly.domain.repository.NotesRepository +import dev.zacsweers.metro.Inject + +/** How long a note stays in the trash before it is deleted for good. */ +const val TRASH_RETENTION_DAYS = 28 + +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** How long, in milliseconds, a note stays in the trash. */ +const val TRASH_RETENTION_MILLIS = TRASH_RETENTION_DAYS * MILLIS_PER_DAY + +/** + * Deletes the notes that have been in the trash for more than [TRASH_RETENTION_DAYS] days, with the same row delete + * as "Delete forever" ([CleanTrashNotesUseCase]), but in one statement that checks the trash flag as it deletes: a note + * restored meanwhile is kept. A trash time in the future (the clock was set back since) never counts as expired. + * Run when the vault is open: on unlock, and while the app is used. + */ +@Inject +class PurgeExpiredTrashUseCase(private val repository: NotesRepository) { + /** The number of notes deleted. [now] is the time to measure from. */ + suspend operator fun invoke(now: Long = currentTimeMillis()): Result { + // A trashed note without a time (imported, restored from a backup) starts its 28 days now. + repository.stampUntimedTrash(now).onFailure { return Result.failure(it) } + // Expired means older than the retention, so a trash time after `now` is never below the cutoff. + return repository.deleteTrashedBefore(now - TRASH_RETENTION_MILLIS) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteArchiveStatusUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteArchiveStatusUseCase.kt new file mode 100644 index 00000000..b7c01051 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteArchiveStatusUseCase.kt @@ -0,0 +1,12 @@ +package com.pasich.encly.domain.usecase.note + +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.repository.NotesRepository +import dev.zacsweers.metro.Inject + +/** Moves the note to the archive, or back to the list. Archiving unpins it. */ +@Inject +class UpdateNoteArchiveStatusUseCase(private val repository: NotesRepository) { + suspend operator fun invoke(targetNote: Note, isArchived: Boolean): Result = + repository.setArchived(targetNote.id, isArchived) +} diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteColorUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteColorUseCase.kt similarity index 77% rename from app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteColorUseCase.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteColorUseCase.kt index 6934280c..997d0989 100644 --- a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteColorUseCase.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteColorUseCase.kt @@ -3,10 +3,11 @@ package com.pasich.encly.domain.usecase.note import com.pasich.encly.data.model.Note import com.pasich.encly.domain.model.NoteColor import com.pasich.encly.domain.repository.NotesRepository -import javax.inject.Inject +import dev.zacsweers.metro.Inject /** Marks the note with [color] ([NoteColor.NONE] clears it). Writes the colour only. */ -class UpdateNoteColorUseCase @Inject constructor(private val repository: NotesRepository) { +@Inject +class UpdateNoteColorUseCase(private val repository: NotesRepository) { suspend operator fun invoke(targetNote: Note, color: NoteColor): Result = repository.setColor(targetNote.id, color.key) } diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteDescriptionUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteDescriptionUseCase.kt similarity index 75% rename from app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteDescriptionUseCase.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteDescriptionUseCase.kt index 975f956e..afdbf7da 100644 --- a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNoteDescriptionUseCase.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteDescriptionUseCase.kt @@ -2,10 +2,11 @@ package com.pasich.encly.domain.usecase.note import com.pasich.encly.data.model.Note import com.pasich.encly.domain.repository.NotesRepository -import javax.inject.Inject +import dev.zacsweers.metro.Inject /** Sets the short description shown on the note's card. Writes the description only. */ -class UpdateNoteDescriptionUseCase @Inject constructor(private val repository: NotesRepository) { +@Inject +class UpdateNoteDescriptionUseCase(private val repository: NotesRepository) { suspend operator fun invoke(targetNote: Note, description: String): Result = repository.setDescription(targetNote.id, description) } diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNotePinStatusUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNotePinStatusUseCase.kt similarity index 75% rename from app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNotePinStatusUseCase.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNotePinStatusUseCase.kt index eea3e168..4c33350f 100644 --- a/app/src/main/java/com/pasich/encly/domain/usecase/note/UpdateNotePinStatusUseCase.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNotePinStatusUseCase.kt @@ -2,10 +2,11 @@ package com.pasich.encly.domain.usecase.note import com.pasich.encly.data.model.Note import com.pasich.encly.domain.repository.NotesRepository -import javax.inject.Inject +import dev.zacsweers.metro.Inject /** Pins the note to the top of the list, or unpins it. An archived note cannot be pinned. */ -class UpdateNotePinStatusUseCase @Inject constructor(private val repository: NotesRepository) { +@Inject +class UpdateNotePinStatusUseCase(private val repository: NotesRepository) { suspend operator fun invoke(targetNote: Note, isPinned: Boolean): Result = repository.setPinned(targetNote.id, isPinned) } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteTagUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteTagUseCase.kt new file mode 100644 index 00000000..bf3d0731 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteTagUseCase.kt @@ -0,0 +1,18 @@ +package com.pasich.encly.domain.usecase.note + +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.repository.NotesRepository +import dev.zacsweers.metro.Inject + +/** + * Puts the tag [tagId] on the note or takes it off ([tagged]); 0 ("No tag") takes every tag off. Writes that tag + * only: the note's other tags stay as they are. + */ +@Inject +class UpdateNoteTagUseCase(private val repository: NotesRepository) { + suspend operator fun invoke(targetNote: Note, tagId: Long, tagged: Boolean = true): Result = if (tagId > 0L) { + repository.setTagged(targetNote.id, tagId, tagged) + } else { + repository.clearTags(targetNote.id) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteTrashStatusUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteTrashStatusUseCase.kt new file mode 100644 index 00000000..8362cae4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/UpdateNoteTrashStatusUseCase.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.domain.usecase.note + +import com.pasich.encly.core.common.currentTimeMillis +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.repository.NotesRepository +import dev.zacsweers.metro.Inject + +/** + * Moves the note to the trash, remembering when, or back out of it. Only those two columns are written, never the + * rest of the [Note] a screen read earlier. + */ +@Inject +class UpdateNoteTrashStatusUseCase(private val repository: NotesRepository) { + suspend operator fun invoke(targetNote: Note, isTrash: Boolean, now: Long = currentTimeMillis()): Result = + repository.setTrashed(targetNote.id, isTrash, now.takeIf { isTrash }) +} diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCase.kt similarity index 75% rename from app/src/main/java/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCase.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCase.kt index f3d534ca..fa799078 100644 --- a/app/src/main/java/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCase.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCase.kt @@ -2,10 +2,11 @@ package com.pasich.encly.domain.usecase.tag import com.pasich.encly.data.model.Tag import com.pasich.encly.domain.repository.TagsRepository -import javax.inject.Inject +import dev.zacsweers.metro.Inject /** Stores [tags]' list order as their positions. */ -class ReorderTagsUseCase @Inject constructor(private val tagsRepository: TagsRepository) { +@Inject +class ReorderTagsUseCase(private val tagsRepository: TagsRepository) { suspend operator fun invoke(tags: List): Result = tagsRepository.updateTags( tags.mapIndexed { index, tag -> tag.copy(position = index) }, ) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCase.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCase.kt new file mode 100644 index 00000000..4ed361f5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCase.kt @@ -0,0 +1,15 @@ +package com.pasich.encly.domain.usecase.task + +import com.pasich.encly.core.common.currentTimeMillis +import com.pasich.encly.domain.repository.TasksRepository +import dev.zacsweers.metro.Inject + +/** Completes or reopens a task; a completed task records when it was completed. */ +@Inject +class UpdateTaskStatusUseCase(private val tasksRepository: TasksRepository) { + suspend operator fun invoke( + id: Long, + isCompleted: Boolean, + completedDate: Long? = if (isCompleted) currentTimeMillis() else null, + ): Result = tasksRepository.updateTaskStatus(id, isCompleted, completedDate) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/platform/Documents.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/Documents.kt new file mode 100644 index 00000000..0e391f93 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/Documents.kt @@ -0,0 +1,88 @@ +package com.pasich.encly.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import com.pasich.encly.data.importer.DocumentTree +import kotlinx.io.RawSink +import kotlinx.io.RawSource + +/** + * A document or folder the user picked in the system's dialog, as the platform names it (a content URI on + * Android, a path on the desktop). Only the platform looks inside; everything else passes it on. + */ +data class DocumentRef(val value: String) + +/** + * What the platform does with a [DocumentRef]: the one place that touches content providers or the file + * system. Access that is refused (a revoked grant) arrives as a [kotlinx.io.IOException], like a failed + * read; other failures a provider throws across its own boundary may be a `RuntimeException`. The callers + * map them to their own errors without the (path-quoting) message. + */ +interface PlatformDocuments { + /** [doc] for reading, or null when the provider has no content stream for it. */ + fun openRead(doc: DocumentRef): RawSource? + + /** [doc] for writing, replacing what it holds; null when the provider has no stream for it. */ + fun openWrite(doc: DocumentRef): RawSink? + + /** The document's display name, for showing which file was picked; null when unknown. */ + fun displayName(doc: DocumentRef): String? + + /** Best effort: deletes [doc] where the provider allows it. A failure is the caller's to ignore. */ + fun delete(doc: DocumentRef) + + /** The folder [root], to be walked listing at most [maxEntries] entries per folder. */ + fun openTree(root: DocumentRef, maxEntries: Int): DocumentTree +} + +/** Thrown when no app on the device can show the requested system dialog. */ +class PickerUnavailableException : RuntimeException("no app can show the dialog") + +/** One system dialog, ready to be shown with an [I]nput. */ +fun interface PickerLauncher { + /** Shows the dialog; its answer goes to the callback the launcher was created with. */ + fun launch(input: I) +} + +/** + * The system's document dialogs. The `remember` functions are composable because a launcher belongs to + * the screen that registered it (on Android it is bound to the Activity result registry). Showing a dialog + * with [PickerLauncher.launch] throws [PickerUnavailableException] when no app can handle it. + */ +interface DocumentPickers { + /** "Save as": [mimeType] is the type of the new document; the input is the suggested file name. */ + @Composable + fun rememberCreateDocument(mimeType: String, onPick: (DocumentRef?) -> Unit): PickerLauncher + + /** "Open": the input is the MIME types to offer. */ + @Composable + fun rememberOpenDocument(onPick: (DocumentRef?) -> Unit): PickerLauncher> + + /** "Open" for several documents at once. */ + @Composable + fun rememberOpenDocuments(onPick: (List) -> Unit): PickerLauncher> + + /** "Choose a folder". */ + @Composable + fun rememberOpenFolder(onPick: (DocumentRef?) -> Unit): PickerLauncher +} + +/** The pickers of a host that has none: every launch reports that no app can show the dialog. */ +object NoDocumentPickers : DocumentPickers { + @Composable + override fun rememberCreateDocument(mimeType: String, onPick: (DocumentRef?) -> Unit) = unavailable() + + @Composable + override fun rememberOpenDocument(onPick: (DocumentRef?) -> Unit) = unavailable>() + + @Composable + override fun rememberOpenDocuments(onPick: (List) -> Unit) = unavailable>() + + @Composable + override fun rememberOpenFolder(onPick: (DocumentRef?) -> Unit) = unavailable() + + private fun unavailable() = PickerLauncher { throw PickerUnavailableException() } +} + +/** The host's document dialogs, provided once by its Activity or window. */ +val LocalDocumentPickers = staticCompositionLocalOf { NoDocumentPickers } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt new file mode 100644 index 00000000..f8d05391 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt @@ -0,0 +1,200 @@ +package com.pasich.encly.platform + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.LifecycleOwner +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.security.BiometricHost +import com.pasich.encly.core.security.ScreenshotAllowance +import kotlinx.io.files.Path +import org.jetbrains.compose.resources.StringResource + +// Small interfaces for what the UI and the ViewModels need from the platform they run on. The host app +// implements them (the Android ones are in `:app`) and gives the composables theirs through the `Local...` +// values below, the ViewModels theirs through the dependency graph. + +/** How long a [Toaster] message stays. */ +enum class ToastDuration { SHORT, LONG } + +/** Brief messages the system shows over any screen (a toast on Android). */ +interface Toaster { + /** + * Shows the string resource [text] (filled with [args]) in the in-app language. It may be called + * from any thread and from a plain click handler; the resource is read and the message shown for it. + */ + fun show(text: StringResource, duration: ToastDuration, vararg args: Any) +} + +/** Hands a link or an e-mail to another app. Nothing is fetched by Encly itself. */ +interface ExternalLinks { + /** + * Opens [url] in an app the user picks when [chooser] is set, else in the default one. False when no + * app can open it or the system refused; the caller says so. + */ + fun openUrl(url: String, chooser: Boolean = false): Boolean + + /** Starts a new e-mail to [address]. False when there is no mail app. */ + fun writeMail(address: String): Boolean +} + +/** + * Encly's clipboard policy for what is copied out of the app: every clip is marked sensitive, so the + * system does not preview its content or suggest it, and it is cleared after a minute unless something + * else was copied since. + */ +interface SensitiveClipboard { + /** True when the system already confirms a copy itself, so the app need not say it. */ + val systemConfirmsCopy: Boolean + + /** Copies [text] under [label] as a sensitive clip that clears itself. False when nothing was copied. */ + fun copy(label: String, text: String): Boolean + + /** Empties the clipboard now (e.g. after the recovery phrase was pasted). */ + fun clear() +} + +/** What the app window offers the UI. */ +interface AppWindow { + /** True while the hosting Activity is being recreated (a rotation): not the user leaving the screen. */ + val isChangingConfigurations: Boolean + + /** + * Re-asserts that the window cannot be captured (screenshots, recents, casting), unless the user allowed + * screenshots until the next lock ([com.pasich.encly.core.security.ScreenshotAllowance]). + */ + fun enforceSecure() + + /** Dark icons on the status bar when [light] is true (a light page), light ones otherwise. */ + fun setLightStatusBar(light: Boolean) + + /** Paints the window behind the content, which shows during window transitions and the keyboard. */ + fun setBackgroundColor(argb: Int) + + fun hideKeyboard() + + fun showKeyboard() + + /** A host with no window to manage (previews, tests). */ + object None : AppWindow { + override val isChangingConfigurations: Boolean = false + + override fun enforceSecure() = Unit + + override fun setLightStatusBar(light: Boolean) = Unit + + override fun setBackgroundColor(argb: Int) = Unit + + override fun hideKeyboard() = Unit + + override fun showKeyboard() = Unit + } +} + +/** + * What a biometric prompt is shown on ([biometric], an Activity on Android) and the lifetime it belongs to + * ([owner], the same Activity). The security core only passes [biometric] on; the ViewModels use [owner] to + * release a prompt whose screen died. Two of the same Activity are equal. + */ +data class ScreenBiometricHost(val biometric: BiometricHost, val owner: LifecycleOwner) + +/** The in-app language: the one the user picked in Settings, which the platform applies to the whole app. */ +interface AppLocaleController { + /** The language in force; [AppLanguage.SYSTEM] when the device's own is followed. */ + fun current(): AppLanguage + + /** Applies [language] to the app; the running screen is recreated to read its strings again. */ + fun apply(language: AppLanguage) +} + +/** Facts about this build of the app that the UI shows or branches on. */ +interface AppInfo { + val appName: String + val versionName: String + + /** The store build has no donation links (store policy); the other one has. */ + val donationsEnabled: Boolean + + /** Only the store build links to its own store page to rate the app. */ + val storeRatingEnabled: Boolean + + /** This app's page in the device's store, the store app's own link first and a web page after it. */ + val storePageLinks: List + + /** A host that says nothing about itself (previews, tests). */ + object Default : AppInfo { + override val appName: String = "Encly" + override val versionName: String = "0.0.0" + override val donationsEnabled: Boolean = true + override val storeRatingEnabled: Boolean = false + override val storePageLinks: List = emptyList() + } +} + +/** A private, app-owned directory for temporary files (the app's cache on Android). */ +interface CacheDirectory { + val path: Path +} + +/** The host's window, provided once by its Activity. */ +val LocalAppWindow = staticCompositionLocalOf { AppWindow.None } + +/** The host's external links, provided once by its Activity. */ +val LocalExternalLinks = staticCompositionLocalOf { NoExternalLinks } + +/** The host's toasts. */ +val LocalToaster = staticCompositionLocalOf { NoToasts } + +/** The host's clipboard policy. */ +val LocalSensitiveClipboard = staticCompositionLocalOf { NoClipboard } + +/** The biometric host of the screen, or null where there is none (no prompt can be shown). */ +val LocalBiometricHost = staticCompositionLocalOf { null } + +/** + * The temporary "Allow screenshots until the app locks", or null where the host does not offer it (the setting is + * hidden there and nothing is exempt from capture protection). + */ +val LocalScreenshotAllowance = staticCompositionLocalOf { null } + +/** + * Whether the host can act on "Strict keyboard privacy" (Android turns text fields into password-like fields without + * suggestions). The desktop has no such keyboard, and iOS cannot ask for more than it always sets, so both say no and + * Settings does not show a switch that would do nothing. + */ +val LocalKeyboardPrivacyOffered = staticCompositionLocalOf { true } + +/** A third-party work a host ships besides the ones every host does: its name, its licence and where to read both. */ +class OpenSourceNotice(val name: String, val license: String, val url: String) + +/** The works only this host ships (the desktop's PDF library and native bindings), listed on the licences screen. */ +val LocalExtraLicenses = staticCompositionLocalOf> { emptyList() } + +/** The in-app language controller. */ +val LocalAppLocales = staticCompositionLocalOf { FixedLocale } + +/** Facts about the build. */ +val LocalAppInfo = staticCompositionLocalOf { AppInfo.Default } + +private object NoToasts : Toaster { + override fun show(text: StringResource, duration: ToastDuration, vararg args: Any) = Unit +} + +private object NoExternalLinks : ExternalLinks { + override fun openUrl(url: String, chooser: Boolean): Boolean = false + + override fun writeMail(address: String): Boolean = false +} + +/** A clipboard policy that does nothing, for a host with no clipboard. */ +private object NoClipboard : SensitiveClipboard { + override val systemConfirmsCopy: Boolean = false + + override fun copy(label: String, text: String): Boolean = false + + override fun clear() = Unit +} + +private object FixedLocale : AppLocaleController { + override fun current(): AppLanguage = AppLanguage.SYSTEM + + override fun apply(language: AppLanguage) = Unit +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/platform/NavigationRequests.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/NavigationRequests.kt new file mode 100644 index 00000000..18d69e00 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/NavigationRequests.kt @@ -0,0 +1,82 @@ +package com.pasich.encly.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.navigation.NavHostController +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.navigation.AppCommandRouter +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +/** + * What the host asks the running UI to do: a desktop menu bar item or a dropped file, which have no + * navigation controller of their own. A request is a route of `NavRoutes`, with its arguments if any, or an + * [AppCommand] that the screens run the way a keyboard shortcut runs it (a new note opens as a tab where + * there are tabs). + */ +class NavigationRequests { + private val channel = Channel(capacity = Channel.BUFFERED) + private val commandChannel = Channel(capacity = Channel.BUFFERED) + + /** The requested routes, each delivered once. */ + val routes: Flow = channel.receiveAsFlow() + + /** The requested commands, each delivered once. */ + val commands: Flow = commandChannel.receiveAsFlow() + + private val _editorOpen = MutableStateFlow(false) + + /** Whether the window shows a note editor (a note to print): the UI sets it, the host's menu reads it. */ + val editorOpen: StateFlow = _editorOpen.asStateFlow() + + internal fun setEditorOpen(open: Boolean) { + _editorOpen.value = open + } + + /** Asks for [route]; false if the queue is full (the request is dropped, never blocks). */ + fun request(route: String): Boolean = channel.trySend(route).isSuccess + + /** Asks for [command]; false if the queue is full (the request is dropped, never blocks). */ + fun request(command: AppCommand): Boolean = commandChannel.trySend(command).isSuccess +} + +/** The host's navigation requests, or null where the host sends none (Android). */ +val LocalNavigationRequests = staticCompositionLocalOf { null } + +/** + * Opens what [LocalNavigationRequests] asks for, but only while the vault is open ([isOpen]): a request that + * arrives on the lock screen is dropped, so a menu item can never skip the lock. Commands go to [router]. + */ +@Composable +internal fun HostNavigationRequests( + navController: NavHostController, + router: AppCommandRouter, + isOpen: () -> Boolean, +) { + val requests = LocalNavigationRequests.current ?: return + val currentIsOpen by rememberUpdatedState(isOpen) + LaunchedEffect(requests, navController) { + launch { + requests.routes.collect { route -> + if (currentIsOpen()) navController.navigate(route) { launchSingleTop = true } + } + } + launch { + requests.commands.collect { command -> if (currentIsOpen()) router.run(command) } + } + launch { + snapshotFlow { router.hasEditor }.collect(requests::setEditorOpen) + } + } + DisposableEffect(requests) { onDispose { requests.setEditorOpen(false) } } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt new file mode 100644 index 00000000..fdc60d3d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt @@ -0,0 +1,124 @@ +package com.pasich.encly.platform + +import androidx.compose.runtime.staticCompositionLocalOf +import com.pasich.encly.data.export.NoteDocument +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.io.Sink + +/** What came of a print request that was not an error. */ +enum class PrintResult { + /** The job was handed to the printer. */ + PRINTED, + + /** The user closed the print dialog. */ + CANCELLED, +} + +/** + * Turns a [NoteDocument] into a PDF or a printout. The desktop provides one ([LocalNoteDocumentRenderer]); on + * Android there is none and the screens offer neither action. Both receive plaintext, so a caller reaches + * them only after the plaintext-export warning and the re-authentication. + */ +interface NoteDocumentRenderer { + /** Writes [document] as one PDF to [out]. The whole file is built before the first byte is written. */ + fun writePdf(document: NoteDocument, out: Sink) + + /** + * Shows the system print dialog and prints [document]; throws when printing fails. [mayProceed] is asked once the + * user confirmed the dialog and again before each page: when it says no (the vault was locked meanwhile) nothing + * more is sent to the printer and the result is [PrintResult.CANCELLED]. + */ + suspend fun print(document: NoteDocument, mayProceed: () -> Boolean = { true }): PrintResult +} + +/** The host's PDF and print support, or null where the host has none (Android). */ +val LocalNoteDocumentRenderer = staticCompositionLocalOf { null } + +/** What a host with several windows lets a note do ("Open in new window"). */ +fun interface NoteWindows { + /** Opens a window showing note [noteId]. */ + fun openNote(noteId: Long) +} + +/** The host's window opener, or null where the host has one window only (Android). */ +val LocalNoteWindows = staticCompositionLocalOf { null } + +/** + * Which window edits which note. A note has one editor at a time across the windows of a host: the editor + * saves the whole note, so a second editor would overwrite the first one's work with what it loaded earlier. + * Windows are told apart by the key they hold (compared by equality); a window may claim a note more than once + * (an editor replaced by another of the same note), and a claim of another window refuses. + */ +class NoteEditRegistry { + private val lock = SynchronizedObject() + private val holders = HashMap() + private val _releases = MutableStateFlow(0L) + + /** Counts the notes given back so far: a window that was refused tries again when it changes. */ + val releases: StateFlow = _releases.asStateFlow() + + private class Holder(val window: Any, var count: Int) + + /** The claims of one window. */ + fun forWindow(window: Any): NoteEditClaims = NoteEditClaims(this, window) + + /** Claims [noteId] for [window]; false when another window holds it. */ + internal fun claim(window: Any, noteId: Long): Boolean = synchronized(lock) { + val holder = holders[noteId] + when { + holder == null -> { + holders[noteId] = Holder(window, 1) + true + } + + holder.window == window -> { + holder.count++ + true + } + + else -> false + } + } + + /** Gives back one claim of [window] on [noteId]. */ + internal fun release(window: Any, noteId: Long) { + synchronized(lock) { + val holder = holders[noteId] ?: return + if (holder.window != window) return + if (--holder.count <= 0) { + holders.remove(noteId) + _releases.update { it + 1 } + } + } + } + + /** Whether any window edits [noteId]. */ + fun isClaimed(noteId: Long): Boolean = synchronized(lock) { holders.containsKey(noteId) } +} + +/** The claims of one window of a [NoteEditRegistry]. */ +class NoteEditClaims internal constructor(private val registry: NoteEditRegistry, private val window: Any) { + /** True when this window may edit [noteId] (and now holds a claim it must [release]). */ + fun claim(noteId: Long): Boolean = registry.claim(window, noteId) + + fun release(noteId: Long) = registry.release(window, noteId) + + /** The registry's [NoteEditRegistry.releases]. */ + val releases: StateFlow get() = registry.releases + + fun releaseCount(): Long = registry.releases.value +} + +/** This window's note claims, or null where the host has one window only (Android): editors never refuse. */ +val LocalNoteEditClaims = staticCompositionLocalOf { null } + +/** + * True in a host whose several windows share one vault and one lock: a lock screen then follows an unlock made on + * another window's lock screen. False (Android: one window) leaves the lock screen to its own unlock only. + */ +val LocalSessionSharedAcrossWindows = staticCompositionLocalOf { false } diff --git a/app/src/main/java/com/pasich/encly/presentation/components/FontSizeSlider.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/FontSizeSlider.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/presentation/components/FontSizeSlider.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/FontSizeSlider.kt index 1e13b736..343020dc 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/FontSizeSlider.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/FontSizeSlider.kt @@ -10,9 +10,11 @@ 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.stringResource -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.font_size_title +import com.pasich.encly.core.resources.font_size_value import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource private const val MIN_SIZE = 10f private const val MAX_SIZE = 32f @@ -24,8 +26,8 @@ private const val SIZE_STEPS = 21 @Composable fun FontSizeSlider(currentSize: Int, onSizeChange: (Int) -> Unit, modifier: Modifier = Modifier) { LabeledSlider( - label = stringResource(R.string.font_size_title), - valueText = stringResource(R.string.font_size_value, currentSize), + label = stringResource(Res.string.font_size_title), + valueText = stringResource(Res.string.font_size_value, currentSize), value = currentSize.toFloat(), onValueChange = { onSizeChange(it.toInt()) }, scale = SliderScale(MIN_SIZE..MAX_SIZE, SIZE_STEPS), diff --git a/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/HomeTaskWidget.kt similarity index 75% rename from app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/HomeTaskWidget.kt index 778dbbab..3e90e8b1 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/HomeTaskWidget.kt @@ -8,9 +8,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.core.resources.Res +import com.pasich.encly.core.resources.home_tasks_none_open +import com.pasich.encly.core.resources.home_tasks_open +import com.pasich.encly.core.resources.task_add +import com.pasich.encly.core.resources.task_filter_all import com.pasich.encly.presentation.components.tasks.PriorityValues import com.pasich.encly.presentation.designsystem.EnclyCard import com.pasich.encly.presentation.designsystem.EnclyCardStyle @@ -18,9 +20,11 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource /** * The tasks card on the notes screen (design spec §4.3): "TASKS · N OPEN", a link to all tasks @@ -32,7 +36,7 @@ fun HomeTaskWidget( modifier: Modifier = Modifier, onTasksClick: () -> Unit = {}, onNewTask: () -> Unit = onTasksClick, - viewModel: TasksViewModel = enclyViewModel(), + viewModel: TasksViewModel = metroViewModel(), ) { val uiState by viewModel.uiState.collectAsState() val preview = remember(uiState.activeTasks) { widgetTasks(uiState.activeTasks) } @@ -53,16 +57,20 @@ fun HomeTaskWidget( Row(verticalAlignment = Alignment.CenterVertically) { SectionOverline( text = if (noneOpen) { - stringResource(R.string.home_tasks_none_open) + stringResource(Res.string.home_tasks_none_open) } else { - pluralStringResource(R.plurals.home_tasks_open, uiState.activeTasksCount, uiState.activeTasksCount) + pluralStringResource( + Res.plurals.home_tasks_open, + uiState.activeTasksCount, + uiState.activeTasksCount, + ) }, modifier = Modifier.weight(1f), ) if (noneOpen) { - EnclyInlineLink(text = stringResource(R.string.task_add), onClick = onNewTask) + EnclyInlineLink(text = stringResource(Res.string.task_add), onClick = onNewTask) } else { - EnclyInlineLink(text = stringResource(R.string.task_filter_all), onClick = onTasksClick) + EnclyInlineLink(text = stringResource(Res.string.task_filter_all), onClick = onTasksClick) } } if (!noneOpen) { diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/NoteLineSpacingSlider.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/NoteLineSpacingSlider.kt new file mode 100644 index 00000000..98bae6bd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/NoteLineSpacingSlider.kt @@ -0,0 +1,37 @@ +package com.pasich.encly.presentation.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.note_line_spacing_comfortable +import com.pasich.encly.core.resources.note_line_spacing_compact +import com.pasich.encly.core.resources.note_line_spacing_default +import com.pasich.encly.core.resources.note_line_spacing_title +import com.pasich.encly.domain.model.NoteLineSpacing +import org.jetbrains.compose.resources.stringResource +import kotlin.math.roundToInt + +/** The reading densities in display order; the enum's own order is fixed by what is stored. */ +internal val lineSpacingOrder = listOf( + NoteLineSpacing.COMPACT to Res.string.note_line_spacing_compact, + NoteLineSpacing.DEFAULT to Res.string.note_line_spacing_default, + NoteLineSpacing.COMFORTABLE to Res.string.note_line_spacing_comfortable, +) + +/** The reading density as a three-step slider, in the same row style as the text size. */ +@Composable +fun NoteLineSpacingSlider( + selected: NoteLineSpacing, + onSelect: (NoteLineSpacing) -> Unit, + modifier: Modifier = Modifier, +) { + val index = lineSpacingOrder.indexOfFirst { it.first == selected }.coerceAtLeast(0) + LabeledSlider( + modifier = modifier, + label = stringResource(Res.string.note_line_spacing_title), + valueText = stringResource(lineSpacingOrder[index].second), + value = index.toFloat(), + onValueChange = { onSelect(lineSpacingOrder[it.roundToInt().coerceIn(lineSpacingOrder.indices)].first) }, + scale = SliderScale(0f..lineSpacingOrder.lastIndex.toFloat(), steps = lineSpacingOrder.size - 2), + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/BlockTool.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/BlockTool.kt new file mode 100644 index 00000000..7a506c85 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/BlockTool.kt @@ -0,0 +1,79 @@ +package com.pasich.encly.presentation.components.editNote + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.block_bulleted_list +import com.pasich.encly.core.resources.block_checklist +import com.pasich.encly.core.resources.block_code +import com.pasich.encly.core.resources.block_heading +import com.pasich.encly.core.resources.block_heading_level +import com.pasich.encly.core.resources.block_link +import com.pasich.encly.core.resources.block_numbered_list +import com.pasich.encly.core.resources.block_separator +import com.pasich.encly.core.resources.block_table +import com.pasich.encly.core.resources.quote +import com.pasich.encly.core.resources.text +import com.pasich.encly.domain.model.ToolbarTool +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.presentation.designsystem.Code +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyToolButton +import com.pasich.encly.presentation.designsystem.Table +import com.pasich.encly.presentation.designsystem.ToolStyle +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** One block tool of the editor toolbar. [headingLevel] fills the "Heading %d" label. */ +data class BlockTool( + val icon: ImageVector, + val type: BlockType, + val label: StringResource, + val headingLevel: Int? = null, +) + +/** The icon, block type and label of toolbar tool [this]. */ +fun ToolbarTool.toBlockTool(): BlockTool = when (this) { + ToolbarTool.HEADING -> BlockTool(EnclyIcons.Heading1, type, Res.string.block_heading) + + ToolbarTool.LIST_CHECK -> BlockTool(EnclyIcons.Checklist, type, Res.string.block_checklist) + + ToolbarTool.LIST_BULLET -> BlockTool(EnclyIcons.ListBullet, type, Res.string.block_bulleted_list) + + ToolbarTool.LIST_NUMBER -> BlockTool(EnclyIcons.ListNumbered, type, Res.string.block_numbered_list) + + ToolbarTool.QUOTE -> BlockTool(EnclyIcons.Quote, type, Res.string.quote) + + ToolbarTool.CODE -> BlockTool(EnclyIcons.Code, type, Res.string.block_code) + + ToolbarTool.TABLE -> BlockTool(EnclyIcons.Table, type, Res.string.block_table) + + ToolbarTool.TEXT -> BlockTool(EnclyIcons.Type, type, Res.string.text) + + ToolbarTool.H1, ToolbarTool.H2, ToolbarTool.H3, ToolbarTool.H4 -> + BlockTool(headingIcon(), type, Res.string.block_heading_level, headingLevel) + + ToolbarTool.LINK -> BlockTool(EnclyIcons.Link, type, Res.string.block_link) + + ToolbarTool.SEPARATOR -> BlockTool(EnclyIcons.Separator, type, Res.string.block_separator) +} + +private fun ToolbarTool.headingIcon() = when (this) { + ToolbarTool.H1 -> EnclyIcons.Heading1 + ToolbarTool.H2 -> EnclyIcons.Heading2 + ToolbarTool.H3 -> EnclyIcons.Heading3 + else -> EnclyIcons.Heading4 +} + +/** A tool button; [active] marks the tool of the block the user works on. */ +@Composable +fun BlockToolButton(tool: BlockTool, active: Boolean, onClick: (() -> Unit)?, modifier: Modifier = Modifier) { + EnclyToolButton( + icon = tool.icon, + contentDescription = tool.headingLevel?.let { stringResource(tool.label, it) } ?: stringResource(tool.label), + onClick = onClick, + style = if (active) ToolStyle.ACTIVE else ToolStyle.PLAIN, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/KeyboardAnchor.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/KeyboardAnchor.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/components/editNote/KeyboardAnchor.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/KeyboardAnchor.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt new file mode 100644 index 00000000..78adffa5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt @@ -0,0 +1,364 @@ +package com.pasich.encly.presentation.components.editNote + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.block_duplicate +import com.pasich.encly.core.resources.block_more_tools +import com.pasich.encly.core.resources.block_move_down +import com.pasich.encly.core.resources.block_move_up +import com.pasich.encly.core.resources.checklist_lock +import com.pasich.encly.core.resources.checklist_unlock +import com.pasich.encly.core.resources.close +import com.pasich.encly.core.resources.delete_block +import com.pasich.encly.core.resources.keyboard_hide +import com.pasich.encly.core.resources.keyboard_show +import com.pasich.encly.core.resources.list_item_indent +import com.pasich.encly.core.resources.list_item_outdent +import com.pasich.encly.core.resources.redo +import com.pasich.encly.core.resources.undo +import com.pasich.encly.domain.model.ToolbarLayout +import com.pasich.encly.domain.model.toolbarScale +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.state.toolType +import com.pasich.encly.platform.LocalAppWindow +import com.pasich.encly.presentation.designsystem.EnclyEditorToolbar +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyToolButton +import com.pasich.encly.presentation.designsystem.EnclyToolbarRule +import com.pasich.encly.presentation.designsystem.Indent +import com.pasich.encly.presentation.designsystem.LocalToolSizes +import com.pasich.encly.presentation.designsystem.Outdent +import com.pasich.encly.presentation.designsystem.ToolSizes +import com.pasich.encly.presentation.designsystem.ToolStyle +import com.pasich.encly.presentation.effects.rememberImeVisible +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +/** The user's choice of toolbar tools (Settings → Appearance → Editor toolbar), provided once around the app. */ +val LocalEditorToolbar = staticCompositionLocalOf { ToolbarLayout.DEFAULT } + +/** + * The editor's formatting toolbar, pinned to the bottom above the keyboard (design spec §3.3): + * the filled "Add block", then the block tools, the one of the block the user works on marked + * active, and undo and redo. The chevron at the start swaps in a paragraph, every heading level, a link + * and a separator, and moving, duplicating or deleting the block. In simple editing only undo and redo + * are offered. + */ +@Composable +fun NoteBottomBar( + modifier: Modifier = Modifier, + viewModel: EditNoteViewModel = metroViewModel(), + simpleEdit: Boolean = false, +) { + var showMore by rememberSaveable { mutableStateOf(false) } + // Re-evaluated when the block the user works on or the blocks (their order) change. + val interactedBlockId by viewModel.interactedBlockId.collectAsState() + val activeTool by remember(viewModel) { + derivedStateOf { viewModel.blocks.firstOrNull { it.id == interactedBlockId }?.toolType() } + } + val checklistLock = rememberChecklistLock(viewModel, interactedBlockId) + val blockFocused = viewModel.focusedBlockId.collectAsState().value != null + val anchor = rememberKeyboardAnchor() + // A change that replaces the focused field runs inside the anchor, so the keyboard stays up. + val keepingKeyboard: (() -> Unit) -> Unit = { change -> + anchor.around(viewModel.focusedBlockId.value, { id -> viewModel.blocks.any { it.id == id } }, change) + } + val history = rememberHistoryTools(viewModel, keepingKeyboard) + val blockTools = rememberBlockMoveTools(viewModel, interactedBlockId, checklistLock, keepingKeyboard) + val layout = LocalEditorToolbar.current + EnclyEditorToolbar(modifier = modifier.imePadding()) { + KeyboardAnchorField(anchor) + when { + simpleEdit -> ToolRow(spread = false) { + HistoryButtons(history) + Spacer(Modifier.weight(1f)) + KeyboardToggle(blockFocused = blockFocused, onShow = viewModel::focusWorkingBlock) + } + + else -> AnimatedContent( + targetState = showMore, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + modifier = Modifier.weight(1f), + label = "toolbar", + ) { more -> + if (more) { + MoreTools( + layout = layout, + blockTools = blockTools, + onApply = toolApplier(checklistLock) { type -> + keepingKeyboard { viewModel.applyTool(type, exact = true) } + }, + onClose = { showMore = false }, + ) + } else { + MainTools( + tools = RowTools( + layout = layout, + activeTool = activeTool, + onApply = toolApplier(checklistLock) { type -> + keepingKeyboard { viewModel.applyTool(type) } + }, + ), + history = history, + onMore = { showMore = true }, + keyboard = KeyboardToggleState(blockFocused, viewModel::focusWorkingBlock), + ) + } + } + } + } +} + +/** The tools of the row: the user's [layout], the [activeTool] of the block in use, and how to [onApply] one. */ +private class RowTools(val layout: ToolbarLayout, val activeTool: BlockType?, val onApply: ((BlockType) -> Unit)?) + +/** The block-tools toggle, the block tools (the active one marked), then undo and redo. */ +@Composable +private fun MainTools(tools: RowTools, history: HistoryTools, onMore: () -> Unit, keyboard: KeyboardToggleState) { + ToolRow(spread = true, buttons = tools.layout.main.size + MAIN_ROW_EXTRAS) { + EnclyToolButton( + icon = EnclyIcons.ChevronUp, + contentDescription = stringResource(Res.string.block_more_tools), + onClick = onMore, + style = ToolStyle.FILLED, + ) + // The filled toggle never touches a highlighted tool next to it, even when the row is tight. + Spacer(Modifier.width(EnclyTheme.spacing.xs)) + tools.layout.main.forEach { entry -> + val tool = entry.toBlockTool() + BlockToolButton( + tool = tool, + active = tool.type == tools.activeTool, + onClick = tools.onApply?.let { apply -> { apply(tool.type) } }, + ) + } + EnclyToolbarRule() + HistoryButtons(history) + EnclyToolbarRule() + KeyboardToggle(blockFocused = keyboard.blockFocused, onShow = keyboard.onShow) + } +} + +/** What the keyboard toggle needs: whether a block's field has focus, and how to focus one. */ +private class KeyboardToggleState(val blockFocused: Boolean, val onShow: () -> Unit) + +/** + * Hides the keyboard while it is up, and brings it back into the block the user was writing in + * ([onShow] focuses it, the cursor where it was) while it is down. + * + * Built to work on every keyboard and skin: + * - Whether the keyboard is up comes from the window insets, but a floating or split keyboard + * (Gboard floating, Samsung's) reports none. So a focused block ([blockFocused]) counts as the + * keyboard being up too, unless the insets just showed it being put away with the block still + * focused (Back on a docked keyboard). + * - Hiding clears focus as well as asking the keyboard to hide: an editor without a focused field + * has no input connection, which closes any keyboard, however it handles hide requests. + * - Showing focuses the working block first (a keyboard cannot open for nothing), then asks + * both Compose's controller and the window's insets controller, since some OEM keyboards and + * skins ignore one of the two. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun KeyboardToggle(blockFocused: Boolean, onShow: () -> Unit) { + val imeVisible = rememberImeVisible() + val keyboard = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val window = LocalAppWindow.current + // True once the insets saw the keyboard go away while a block kept focus: it is down now, + // even though the block is focused. A new focus or a show starts over. + var dismissedWhileFocused by remember { mutableStateOf(false) } + var wasImeVisible by remember { mutableStateOf(imeVisible) } + LaunchedEffect(imeVisible, blockFocused) { + when { + !blockFocused || imeVisible -> dismissedWhileFocused = false + wasImeVisible -> dismissedWhileFocused = true + } + wasImeVisible = imeVisible + } + val shown = imeVisible || (blockFocused && !dismissedWhileFocused) + EnclyToolButton( + icon = if (shown) EnclyIcons.KeyboardHide else EnclyIcons.Keyboard, + contentDescription = stringResource(if (shown) Res.string.keyboard_hide else Res.string.keyboard_show), + onClick = { + if (shown) { + keyboard?.hide() + window.hideKeyboard() + focusManager.clearFocus() + } else { + dismissedWhileFocused = false + // A field that still has focus reopens its input connection when focused again. + if (blockFocused) focusManager.clearFocus() + onShow() + scope.launch { + // Once the field has taken focus (the next frames), in case the keyboard did + // not come up on its own. + repeat(SHOW_AFTER_FRAMES) { withFrameNanos { } } + keyboard?.show() + window.showKeyboard() + } + } + }, + ) +} + +/** Frames to wait for the focused field before asking the keyboard to show. */ +private const val SHOW_AFTER_FRAMES = 2 + +/** + * One row of tools across the toolbar's width: [spread] evenly (`SpaceBetween`) when they fit, + * scrolling sideways on a screen too narrow for them. + */ +@Composable +private fun ToolRow(spread: Boolean, buttons: Int? = null, content: @Composable RowScope.() -> Unit) { + BoxWithConstraints { + // The buttons shrink as the row holds more of them, so a crowded toolbar still fits without scrolling. + val scale = buttons?.let { toolbarScale(it, maxWidth.value.toInt()) } + val sizes = remember(scale) { scale?.let { ToolSizes(it.button.dp, it.icon.dp) } } + CompositionLocalProvider(LocalToolSizes provides sizes) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (spread) { + Arrangement.SpaceBetween + } else { + Arrangement.spacedBy(EnclyTheme.spacing.xxs) + }, + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .widthIn(min = maxWidth), + content = content, + ) + } + } +} + +/** Buttons in the row besides the tools: the toggle, undo, redo and the keyboard. */ +private const val MAIN_ROW_EXTRAS = 4 + +/** Buttons in the "more" row besides the tools: close, move up and down, duplicate and delete (and the lock). */ +private const val MORE_ROW_EXTRAS = 6 + +@Composable +private fun HistoryButtons(history: HistoryTools) { + EnclyToolButton( + icon = EnclyIcons.Undo, + contentDescription = stringResource(Res.string.undo), + onClick = history.onUndo, + ) + EnclyToolButton( + icon = EnclyIcons.Redo, + contentDescription = stringResource(Res.string.redo), + onClick = history.onRedo, + ) +} + +/** Behind the toggle: close, paragraph, heading levels, link and separator, move, duplicate and delete. */ +@Composable +private fun MoreTools( + layout: ToolbarLayout, + blockTools: BlockMoveTools, + onApply: ((BlockType) -> Unit)?, + onClose: () -> Unit, +) { + ToolRow(spread = false, buttons = layout.more.size + MORE_ROW_EXTRAS) { + EnclyToolButton( + icon = EnclyIcons.Close, + contentDescription = stringResource(Res.string.close), + onClick = onClose, + ) + layout.more.forEach { entry -> + val tool = entry.toBlockTool() + BlockToolButton( + tool = tool, + active = false, + onClick = onApply?.let { apply -> + { + apply(tool.type) + onClose() + } + }, + ) + } + EnclyToolbarRule() + EnclyToolButton( + icon = EnclyIcons.ArrowUp, + contentDescription = stringResource(Res.string.block_move_up), + onClick = blockTools.onMoveUp, + ) + EnclyToolButton( + icon = EnclyIcons.ArrowDown, + contentDescription = stringResource(Res.string.block_move_down), + onClick = blockTools.onMoveDown, + ) + EnclyToolButton( + icon = EnclyIcons.Duplicate, + contentDescription = stringResource(Res.string.block_duplicate), + onClick = blockTools.onDuplicate, + ) + blockTools.lock?.let { lock -> + EnclyToolButton( + icon = if (lock.locked) EnclyIcons.LockOpen else EnclyIcons.Lock, + contentDescription = stringResource( + if (lock.locked) Res.string.checklist_unlock else Res.string.checklist_lock, + ), + onClick = lock.onToggle, + ) + } + blockTools.indent?.let { ListIndentButtons(it) } + EnclyToolButton( + icon = EnclyIcons.Trash, + contentDescription = stringResource(Res.string.delete_block), + onClick = blockTools.onDelete, + ) + } +} + +/** Outdent and indent, for the list item the user works in. */ +@Composable +private fun ListIndentButtons(indent: ListIndentTools) { + EnclyToolButton( + icon = EnclyIcons.Outdent, + contentDescription = stringResource(Res.string.list_item_outdent), + onClick = indent.onOutdent, + ) + EnclyToolButton( + icon = EnclyIcons.Indent, + contentDescription = stringResource(Res.string.list_item_indent), + onClick = indent.onIndent, + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/NoteHeader.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/NoteHeader.kt new file mode 100644 index 00000000..364710ac --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/NoteHeader.kt @@ -0,0 +1,110 @@ +package com.pasich.encly.presentation.components.editNote + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.create_new_tag +import com.pasich.encly.core.resources.edit_tags +import com.pasich.encly.core.resources.no_tag +import com.pasich.encly.core.resources.note_not_saved +import com.pasich.encly.core.resources.tags_manage +import com.pasich.encly.data.model.Tag +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.EnclySheetRow +import com.pasich.encly.utils.formatNoteDate +import com.pasich.encly.utils.noteInstant +import org.jetbrains.compose.resources.stringResource + +/** + * The note's overline (design spec §4.4): "PERSONAL · WORK · TODAY 9:40 AM", every tag of the note and when it + * was last edited; only the date without a tag. [notSaved] adds a persistent "Not saved" in + * `error`, announced politely, after a save failed. + */ +@Composable +fun NoteOverline(tagNames: List, date: Long, notSaved: Boolean, modifier: Modifier = Modifier) { + val edited = formatNoteDate(noteInstant(date)) + val style = MaterialTheme.typography.labelSmall + Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = (tagNames + edited).joinToString(OVERLINE_SEPARATOR).uppercase(), + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (notSaved) { + Text( + text = (OVERLINE_SEPARATOR + stringResource(Res.string.note_not_saved)).uppercase(), + style = style, + color = MaterialTheme.colorScheme.error, + maxLines = 1, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + } +} + +private const val OVERLINE_SEPARATOR = " · " + +/** + * Chooses the note's tags: "No tag" and every tag, the note's ones checked, then a way to + * create or manage tags ([onManageTags]). A tap puts a tag on or takes it off ([onToggle]) and the + * sheet stays open for the next one; "No tag" ([onToggle] with 0) takes every tag off. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoteTagSheet( + tags: List, + selectedTagIds: Set, + onToggle: (Long) -> Unit, + onManageTags: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + EnclyBottomSheet(onDismissRequest = onDismiss, modifier = modifier, title = stringResource(Res.string.edit_tags)) { + // Many tags scroll; the manage row below stays in view. + Column(modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { + val untagged = selectedTagIds.isEmpty() + EnclySheetRow( + title = stringResource(Res.string.no_tag), + onClick = { onToggle(0L) }, + selected = untagged, + muted = true, + modifier = Modifier.semantics { selected = untagged }, + ) + tags.forEach { tag -> + val checked = tag.id in selectedTagIds + EnclySheetRow( + title = tag.nameTag, + icon = EnclyIcons.Tag, + onClick = { onToggle(tag.id) }, + selected = checked, + modifier = Modifier.semantics { selected = checked }, + ) + } + } + EnclyGroupDivider() + EnclySheetRow( + title = stringResource(if (tags.isEmpty()) Res.string.create_new_tag else Res.string.tags_manage), + icon = if (tags.isEmpty()) EnclyIcons.Plus else EnclyIcons.Edit, + onClick = onManageTags, + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/TitleField.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/TitleField.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/presentation/components/editNote/TitleField.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/TitleField.kt index 9d54c3e8..2e6295a0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/TitleField.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/TitleField.kt @@ -19,15 +19,16 @@ 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 androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import com.pasich.encly.R -import com.pasich.encly.presentation.editor.blocks.WritingKeyboard -import com.pasich.encly.presentation.editor.blocks.rememberSyncedTextFieldState -import com.pasich.encly.presentation.editor.state.textDiff +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.note_title_placeholder +import com.pasich.encly.editor.state.textDiff +import com.pasich.encly.editor.ui.blocks.WritingKeyboard +import com.pasich.encly.editor.ui.blocks.rememberSyncedTextFieldState import com.pasich.encly.presentation.screen.editnote.rememberFontStyles import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.flow.Flow +import org.jetbrains.compose.resources.stringResource /** * A title is one paragraph. Enter alone (a keyboard that commits "\n" instead of sending the key, @@ -100,7 +101,7 @@ fun TitleField( ) { if (state.text.isEmpty()) { Text( - text = stringResource(R.string.note_title_placeholder), + text = stringResource(Res.string.note_title_placeholder), style = style.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), ) } diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/ToolbarState.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/ToolbarState.kt similarity index 75% rename from app/src/main/java/com/pasich/encly/presentation/components/editNote/ToolbarState.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/ToolbarState.kt index cbc698f5..0f203037 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/ToolbarState.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/ToolbarState.kt @@ -6,8 +6,8 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -45,6 +45,23 @@ internal fun rememberBlockMoveTools( onDuplicate = viewModel::duplicateInteractedBlock, onDelete = if (canDelete) ({ keepingKeyboard(viewModel::removeInteractedBlock) }) else null, lock = lock, + indent = rememberListIndentTools(viewModel, interactedBlockId), + ) +} + +/** Indenting and outdenting the list item the user works in; null while they work on anything but a list. */ +@Composable +private fun rememberListIndentTools(viewModel: EditNoteViewModel, interactedBlockId: String?): ListIndentTools? { + val current by rememberUpdatedState(interactedBlockId) + val inList by remember(viewModel) { + derivedStateOf { viewModel.blocks.firstOrNull { it.id == current } is Block.ListBlock } + } + if (!inList) return null + // Always offered while the user works in a list: which item they are in changes with the cursor, which the + // toolbar does not watch, so the buttons do nothing for an item that cannot go that way. + return ListIndentTools( + onIndent = { viewModel.shiftInteractedListItem(true) }, + onOutdent = { viewModel.shiftInteractedListItem(false) }, ) } @@ -73,7 +90,11 @@ internal class BlockMoveTools( val onDuplicate: (() -> Unit)?, val onDelete: (() -> Unit)?, val lock: ChecklistLock?, + val indent: ListIndentTools? = null, ) +/** Indenting and outdenting a list item; a null action shows its button disabled. */ +internal class ListIndentTools(val onIndent: (() -> Unit)?, val onOutdent: (() -> Unit)?) + /** Locking the checklist the user works on: whether it is [locked], and how to flip that. */ internal class ChecklistLock(val locked: Boolean, val onToggle: () -> Unit) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt new file mode 100644 index 00000000..6a7b7d08 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt @@ -0,0 +1,192 @@ +package com.pasich.encly.presentation.components.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.about +import com.pasich.encly.core.resources.appearance_settings_subtitle +import com.pasich.encly.core.resources.backup_settings_subtitle +import com.pasich.encly.core.resources.backup_title +import com.pasich.encly.core.resources.import_settings_subtitle +import com.pasich.encly.core.resources.import_title +import com.pasich.encly.core.resources.main_drawer_faq +import com.pasich.encly.core.resources.security_subtitle +import com.pasich.encly.core.resources.security_title +import com.pasich.encly.core.resources.settings_about_desc +import com.pasich.encly.core.resources.settings_appearance +import com.pasich.encly.core.resources.settings_backup +import com.pasich.encly.core.resources.settings_faq_desc +import com.pasich.encly.core.resources.settings_general +import com.pasich.encly.core.resources.settings_import +import com.pasich.encly.core.resources.settings_language +import com.pasich.encly.core.resources.settings_notes_and_tasks +import com.pasich.encly.core.resources.settings_privacy +import com.pasich.encly.core.resources.settings_show_tasks +import com.pasich.encly.core.resources.settings_show_tasks_desc +import com.pasich.encly.core.resources.settings_simple_edit +import com.pasich.encly.core.resources.settings_simple_edit_desc +import com.pasich.encly.core.resources.settings_swipe_enabled +import com.pasich.encly.core.resources.settings_swipe_enabled_desc +import com.pasich.encly.core.resources.settings_swipe_left +import com.pasich.encly.core.resources.settings_swipe_right +import com.pasich.encly.core.resources.settings_swipe_section +import com.pasich.encly.domain.model.SwipeAction +import com.pasich.encly.domain.model.SwipeSettings +import com.pasich.encly.domain.model.SwipeSide +import com.pasich.encly.presentation.components.swipe.choiceLabel +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.viewmodel.SettingsEvent +import com.pasich.encly.presentation.viewmodel.SettingsViewModel +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** Destinations the settings list links to. */ +data class SettingsNavigation( + val onAppearance: () -> Unit, + val onSecurity: () -> Unit, + val onBackup: () -> Unit, + val onImportNotes: () -> Unit, + val onAbout: () -> Unit, + val onFaq: () -> Unit, +) + +/** + * The settings root (design spec §4.6): General (Appearance, Language), Notes and tasks (the + * switches), Privacy, Backup, and About with the FAQ last. + */ +class SettingsBuilder { + + @Composable + fun buildSettingsCategories( + viewModel: SettingsViewModel, + showTasks: Boolean, + simpleEdit: Boolean, + swipe: SwipeSettings, + navigation: SettingsNavigation, + ): List = listOf( + general(viewModel, navigation), + notesAndTasks(viewModel, showTasks, simpleEdit), + swipeActions(viewModel, swipe), + single( + Res.string.settings_privacy, + Res.string.security_title, + Res.string.security_subtitle, + EnclyIcons.Shield, + ) { + navigation.onSecurity() + }, + single( + Res.string.settings_backup, + Res.string.backup_title, + Res.string.backup_settings_subtitle, + EnclyIcons.Download, + ) { + navigation.onBackup() + }, + single( + Res.string.settings_import, + Res.string.import_title, + Res.string.import_settings_subtitle, + EnclyIcons.File, + ) { + navigation.onImportNotes() + }, + SettingsCategory( + titleRes = Res.string.about, + items = listOf( + SettingsItem.Navigation( + titleRes = Res.string.about, + subtitleRes = Res.string.settings_about_desc, + action = navigation.onAbout, + icon = EnclyIcons.Info, + ), + SettingsItem.Navigation( + titleRes = Res.string.main_drawer_faq, + subtitleRes = Res.string.settings_faq_desc, + action = navigation.onFaq, + icon = EnclyIcons.Help, + ), + ), + ), + ) + + @Composable + private fun general(viewModel: SettingsViewModel, navigation: SettingsNavigation) = SettingsCategory( + titleRes = Res.string.settings_general, + items = listOf( + SettingsItem.Navigation( + titleRes = Res.string.settings_appearance, + subtitleRes = Res.string.appearance_settings_subtitle, + action = navigation.onAppearance, + icon = EnclyIcons.Palette, + ), + SettingsItem.Selection( + titleRes = Res.string.settings_language, + currentValue = stringResource(viewModel.currentLanguage().nativeName), + action = { viewModel.setLanguageDialogVisibility(true) }, + icon = EnclyIcons.Globe, + ), + ), + ) + + private fun notesAndTasks(viewModel: SettingsViewModel, showTasks: Boolean, simpleEdit: Boolean) = SettingsCategory( + titleRes = Res.string.settings_notes_and_tasks, + items = listOf( + SettingsItem.Switch( + titleRes = Res.string.settings_show_tasks, + subtitleRes = Res.string.settings_show_tasks_desc, + checked = showTasks, + icon = EnclyIcons.Checklist, + onCheckedChange = { viewModel.onEvent(SettingsEvent.UpdateShowTasks(it)) }, + ), + SettingsItem.Switch( + titleRes = Res.string.settings_simple_edit, + subtitleRes = Res.string.settings_simple_edit_desc, + checked = simpleEdit, + icon = EnclyIcons.Type, + onCheckedChange = { viewModel.onEvent(SettingsEvent.UpdateSimpleEdit(it)) }, + ), + ), + ) + + /** The master switch for swipes on note cards and, while it is on, the action for each direction. */ + @Composable + private fun swipeActions(viewModel: SettingsViewModel, swipe: SwipeSettings) = SettingsCategory( + titleRes = Res.string.settings_swipe_section, + items = buildList { + add( + SettingsItem.Switch( + titleRes = Res.string.settings_swipe_enabled, + subtitleRes = Res.string.settings_swipe_enabled_desc, + checked = swipe.enabled, + icon = EnclyIcons.Sliders, + onCheckedChange = { viewModel.onEvent(SettingsEvent.UpdateSwipeEnabled(it)) }, + ), + ) + if (swipe.enabled) { + add(swipeRow(viewModel, SwipeSide.RIGHT, swipe.right)) + add(swipeRow(viewModel, SwipeSide.LEFT, swipe.left)) + } + }, + ) + + @Composable + private fun swipeRow(viewModel: SettingsViewModel, side: SwipeSide, action: SwipeAction) = SettingsItem.Selection( + titleRes = if (side == SwipeSide.RIGHT) Res.string.settings_swipe_right else Res.string.settings_swipe_left, + currentValue = stringResource(action.choiceLabel()), + action = { viewModel.chooseSwipeAction(side) }, + icon = if (side == SwipeSide.RIGHT) EnclyIcons.Chevron else EnclyIcons.Back, + ) + + /** A section holding one navigation row. */ + private fun single( + section: StringResource, + title: StringResource, + subtitle: StringResource, + icon: ImageVector, + action: () -> Unit, + ) = SettingsCategory( + titleRes = section, + items = listOf(SettingsItem.Navigation(titleRes = title, subtitleRes = subtitle, action = action, icon = icon)), + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsCategory.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsCategory.kt new file mode 100644 index 00000000..1db8e321 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsCategory.kt @@ -0,0 +1,43 @@ +package com.pasich.encly.presentation.components.settings + +import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.StringResource + +data class SettingsCategory(val titleRes: StringResource, val items: List) + +sealed class SettingsItem( + open val titleRes: StringResource, + open val subtitleRes: StringResource? = null, + open val isEnabled: Boolean = true, + open val requiresValidation: Boolean = false, + /** Leading icon of the row, in `primary`. */ + open val icon: ImageVector? = null, +) { + data class Switch( + override val titleRes: StringResource, + override val subtitleRes: StringResource? = null, + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit, + override val isEnabled: Boolean = true, + override val requiresValidation: Boolean = false, + val validationMessage: String? = null, + override val icon: ImageVector? = null, + ) : SettingsItem(titleRes, subtitleRes, isEnabled, requiresValidation, icon) + + data class Navigation( + override val titleRes: StringResource, + override val subtitleRes: StringResource? = null, + val action: () -> Unit, + override val isEnabled: Boolean = true, + override val icon: ImageVector? = null, + ) : SettingsItem(titleRes, subtitleRes, isEnabled, icon = icon) + + data class Selection( + override val titleRes: StringResource, + override val subtitleRes: StringResource? = null, + val currentValue: String, + val action: () -> Unit, + override val isEnabled: Boolean = true, + override val icon: ImageVector? = null, + ) : SettingsItem(titleRes, subtitleRes, isEnabled, icon = icon) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsCategoryRenderer.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsCategoryRenderer.kt similarity index 87% rename from app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsCategoryRenderer.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsCategoryRenderer.kt index dfafa8eb..d8482d4b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsCategoryRenderer.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/settings/SettingsCategoryRenderer.kt @@ -5,19 +5,19 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import com.pasich.encly.presentation.designsystem.EnclyGroup import com.pasich.encly.presentation.designsystem.EnclyGroupDivider 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.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource /** A settings section: its overline, then its rows as one grouped list with hairlines between. */ @Composable fun SettingsCategoryRenderer(category: SettingsCategory, modifier: Modifier = Modifier) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - SectionOverline(stringResource(id = category.titleRes)) + SectionOverline(stringResource(category.titleRes)) EnclyGroup { category.items.forEachIndexed { index, item -> if (index > 0) EnclyGroupDivider() @@ -32,8 +32,8 @@ private fun SettingsItemRenderer(item: SettingsItem) { val rowModifier = Modifier.padding(horizontal = EnclyTheme.spacing.s) when (item) { is SettingsItem.Switch -> EnclySwitchRow( - title = stringResource(id = item.titleRes), - supporting = item.subtitleRes?.let { stringResource(id = it) }, + title = stringResource(item.titleRes), + supporting = item.subtitleRes?.let { stringResource(it) }, icon = item.icon, checked = item.checked, onCheckedChange = item.onCheckedChange, @@ -42,15 +42,15 @@ private fun SettingsItemRenderer(item: SettingsItem) { ) is SettingsItem.Navigation -> EnclyNavigationRow( - title = stringResource(id = item.titleRes), - supporting = item.subtitleRes?.let { stringResource(id = it) }, + title = stringResource(item.titleRes), + supporting = item.subtitleRes?.let { stringResource(it) }, icon = item.icon, onClick = item.action, modifier = rowModifier, ) is SettingsItem.Selection -> EnclyNavigationRow( - title = stringResource(id = item.titleRes), + title = stringResource(item.titleRes), supporting = item.currentValue, icon = item.icon, onClick = item.action, diff --git a/app/src/main/java/com/pasich/encly/presentation/components/swipe/SwipeContext.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/swipe/SwipeContext.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/components/swipe/SwipeContext.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/swipe/SwipeContext.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/swipe/SwipeLabels.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/swipe/SwipeLabels.kt new file mode 100644 index 00000000..ef1c1c3e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/swipe/SwipeLabels.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.presentation.components.swipe + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.note_archive +import com.pasich.encly.core.resources.note_pin +import com.pasich.encly.core.resources.note_unarchive +import com.pasich.encly.core.resources.note_unpin +import com.pasich.encly.core.resources.swipe_choice_color +import com.pasich.encly.core.resources.swipe_choice_delete +import com.pasich.encly.core.resources.swipe_choice_none +import com.pasich.encly.core.resources.swipe_choice_pin +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.model.SwipeAction +import org.jetbrains.compose.resources.StringResource + +/** The name of [this] action in the settings choice. */ +fun SwipeAction.choiceLabel(): StringResource = when (this) { + SwipeAction.PIN -> Res.string.swipe_choice_pin + SwipeAction.ARCHIVE -> Res.string.note_archive + SwipeAction.NEXT_COLOR -> Res.string.swipe_choice_color + SwipeAction.DELETE -> Res.string.swipe_choice_delete + SwipeAction.NONE -> Res.string.swipe_choice_none +} + +/** What [this] action does to [note] right now, as a label ("Unpin" for a pinned note). */ +fun SwipeAction.noteLabel(note: Note): StringResource = when (this) { + SwipeAction.PIN -> if (note.isPinned) Res.string.note_unpin else Res.string.note_pin + SwipeAction.ARCHIVE -> if (note.isArchived) Res.string.note_unarchive else Res.string.note_archive + else -> choiceLabel() +} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/swipe/SwipeableNote.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/swipe/SwipeableNote.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/presentation/components/swipe/SwipeableNote.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/swipe/SwipeableNote.kt index 3f5d132a..86dda15d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/swipe/SwipeableNote.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/swipe/SwipeableNote.kt @@ -1,6 +1,5 @@ package com.pasich.encly.presentation.components.swipe -import android.provider.Settings import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.snap @@ -34,16 +33,16 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.swipe_delete_message +import com.pasich.encly.core.resources.swipe_delete_title import com.pasich.encly.data.model.Note import com.pasich.encly.domain.model.NoteColor import com.pasich.encly.domain.model.SwipeAction @@ -51,6 +50,7 @@ import com.pasich.encly.domain.model.SwipeSettings import com.pasich.encly.domain.model.SwipeSide import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.dialogs.ConfirmDialog +import com.pasich.encly.presentation.effects.rememberAnimationsOff import com.pasich.encly.presentation.viewmodel.NoteListEvent import com.pasich.encly.ui.theme.EnclyTheme import com.pasich.encly.ui.theme.noteColorStyle @@ -59,6 +59,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource import kotlin.math.abs import kotlin.math.roundToInt import kotlin.math.sign @@ -157,8 +158,8 @@ fun SwipeableNote( ConfirmDialog( isVisible = confirmDelete, - titleText = stringResource(R.string.swipe_delete_title), - messageText = stringResource(R.string.swipe_delete_message), + titleText = stringResource(Res.string.swipe_delete_title), + messageText = stringResource(Res.string.swipe_delete_message), onConfirm = { confirmDelete = false scope.launch { perform(NoteListEvent.NoteToTrash(note)) } @@ -348,10 +349,4 @@ private fun swipeLook(action: SwipeAction, note: Note): SwipeLook { /** Whether the system asks for no animation (animator duration scale 0, "remove animations"). */ @Composable -internal fun rememberReducedMotion(): Boolean { - val resolver = LocalContext.current.contentResolver - return remember(resolver) { - runCatching { Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f } - .getOrDefault(false) - } -} +internal fun rememberReducedMotion(): Boolean = rememberAnimationsOff() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/PriorityValues.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/PriorityValues.kt new file mode 100644 index 00000000..135fd778 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/PriorityValues.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.presentation.components.tasks + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.priority_high +import com.pasich.encly.core.resources.priority_low +import com.pasich.encly.core.resources.priority_medium +import org.jetbrains.compose.resources.StringResource + +/** A task priority: its stored [id] and label. [emphasis] marks the one shown in `error` (High). */ +data class PriorityData(val id: Int, val label: StringResource, val emphasis: Boolean = false) + +object PriorityValues { + + /** Highest first, the order the widget and the editor sheet list them in. */ + val priorities = listOf( + PriorityData(id = 2, label = Res.string.priority_high, emphasis = true), + PriorityData(id = 1, label = Res.string.priority_medium), + PriorityData(id = 0, label = Res.string.priority_low), + ) + + fun getById(id: Int): PriorityData = priorities.firstOrNull { it.id == id } ?: priorities.last() +} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt index 5ae10119..d37fed2f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt @@ -1,15 +1,11 @@ package com.pasich.encly.presentation.components.tasks -import android.provider.Settings -import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalActivity import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable @@ -24,19 +20,29 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.edit +import com.pasich.encly.core.resources.subtask_add +import com.pasich.encly.core.resources.subtask_delete +import com.pasich.encly.core.resources.subtask_first_step +import com.pasich.encly.core.resources.subtask_next_step +import com.pasich.encly.core.resources.subtask_placeholder +import com.pasich.encly.core.resources.subtasks_hide +import com.pasich.encly.core.resources.subtasks_show +import com.pasich.encly.core.resources.tag_move_down +import com.pasich.encly.core.resources.tag_move_up import com.pasich.encly.data.model.Subtask +import com.pasich.encly.platform.LocalAppWindow import com.pasich.encly.presentation.designsystem.CheckboxSize import com.pasich.encly.presentation.designsystem.EnclyAddSubtaskButton import com.pasich.encly.presentation.designsystem.EnclyCheckbox @@ -47,9 +53,12 @@ import com.pasich.encly.presentation.designsystem.EnclyStepTransition import com.pasich.encly.presentation.designsystem.EnclySubtaskAddIcon import com.pasich.encly.presentation.designsystem.EnclySubtaskField import com.pasich.encly.presentation.designsystem.EnclySubtaskRow +import com.pasich.encly.presentation.effects.rememberAnimationsOff +import com.pasich.encly.presentation.effects.rememberImeVisible import com.pasich.encly.presentation.viewmodel.InlineSubtaskEdit import com.pasich.encly.presentation.viewmodel.InlineSubtaskTarget import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableColumn /** What the list's inline sub-task field reports back (see TasksViewModel). */ @@ -111,7 +120,7 @@ internal fun NextStepLeaf( } val step = updateTransition(targetState = shownId, label = "nextStep") val moving = shownId != targetId || step.currentState != step.targetState - val expand = stringResource(R.string.subtasks_show) + val expand = stringResource(Res.string.subtasks_show) EnclyStepTransition(step = step, animate = animate, modifier = modifier) { id -> val subtask = subtasks.find { it.id == id } if (subtask != null) { @@ -124,7 +133,7 @@ internal fun NextStepLeaf( enabled = enabled && !moving, onClick = { actions.onToggleTree(taskId) }, onClickLabel = expand, - checkboxDescription = stringResource(R.string.subtask_next_step, subtask.title), + checkboxDescription = stringResource(Res.string.subtask_next_step, subtask.title), trailing = { EnclyExpandButton(expanded = false, contentDescription = expand, onClick = { actions.onToggleTree(taskId) @@ -137,12 +146,7 @@ internal fun NextStepLeaf( /** False when the system's animator duration scale is 0 (animations turned off). */ @Composable -private fun rememberAnimationsEnabled(): Boolean { - val resolver = LocalContext.current.contentResolver - return remember(resolver) { - Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) != 0f - } -} +private fun rememberAnimationsEnabled(): Boolean = !rememberAnimationsOff() /** * A task's whole tree: each of [subtasks] in order (tick in place; a tap on the title renames it @@ -166,7 +170,7 @@ internal fun SubtaskTree( // Shows a dropped row in its new place until the stored order comes back (see moveRow). val rows = remember(subtasks) { mutableStateOf(subtasks) } val currentSubtasks by rememberUpdatedState(subtasks) - val collapse = stringResource(R.string.subtasks_hide) + val collapse = stringResource(Res.string.subtasks_hide) val chevron: @Composable () -> Unit = { EnclyExpandButton(expanded = true, contentDescription = collapse, onClick = { actions.onToggleTree(taskId) }) } @@ -206,7 +210,7 @@ internal fun SubtaskTree( dimmed = dimmed, enabled = enabled, onClick = { actions.onStartRenaming(subtask) }, - onClickLabel = stringResource(R.string.edit), + onClickLabel = stringResource(Res.string.edit), customActions = moves, dragging = dragging, trailing = chevron.takeIf { index == 0 }, @@ -240,8 +244,8 @@ private fun MutableState>.moveRow(from: Int, to: Int, store: (move /** "Move up" and "Move down" for TalkBack, where the row can move that way. */ @Composable private fun moveActions(moveUp: (() -> Unit)?, moveDown: (() -> Unit)?): List { - val up = stringResource(R.string.tag_move_up) - val down = stringResource(R.string.tag_move_down) + val up = stringResource(Res.string.tag_move_up) + val down = stringResource(Res.string.tag_move_down) return listOfNotNull( moveUp?.let { action -> CustomAccessibilityAction(up) { @@ -269,14 +273,14 @@ private fun AddLeaf( first: Boolean, trailing: (@Composable () -> Unit)?, ) { - val label = stringResource(if (first) R.string.subtask_first_step else R.string.subtask_placeholder) + val label = stringResource(if (first) Res.string.subtask_first_step else Res.string.subtask_placeholder) if (edit != null && edit.target == InlineSubtaskTarget.Add(taskId)) { InlineSubtaskField(edit = edit, label = label, actions = actions.inline) } else { EnclyAddSubtaskButton( text = label, onClick = { actions.onStartAdding(taskId) }, - description = stringResource(R.string.subtask_add), + description = stringResource(Res.string.subtask_add), enabled = enabled, trailing = trailing, ) @@ -288,7 +292,7 @@ private fun AddLeaf( private fun RenameField(subtask: Subtask, edit: InlineSubtaskEdit, actions: SubtaskTreeActions, enabled: Boolean) { InlineSubtaskField( edit = edit, - label = stringResource(R.string.subtask_placeholder), + label = stringResource(Res.string.subtask_placeholder), actions = actions.inline, isLast = false, done = subtask.isCompleted, @@ -304,7 +308,7 @@ private fun RenameField(subtask: Subtask, edit: InlineSubtaskEdit, actions: Subt trailing = { EnclyRowIconButton( icon = EnclyIcons.Close, - contentDescription = stringResource(R.string.subtask_delete), + contentDescription = stringResource(Res.string.subtask_delete), onClick = actions.inline.onDelete, ) }, @@ -337,11 +341,11 @@ private fun InlineSubtaskField( val close = { currentActions.onClose(target) } // A rotation disposes the field, which takes its focus away: that is not the user leaving it // (the field lives in the ViewModel and comes back open, with its text). - val activity = LocalActivity.current + val window = LocalAppWindow.current LaunchedEffect(Unit) { focusRequester.requestFocus() } BackHandler(onBack = close) - CloseWhenKeyboardHides(focused = focused, onClose = { if (activity?.isChangingConfigurations != true) close() }) + CloseWhenKeyboardHides(focused = focused, onClose = { if (!window.isChangingConfigurations) close() }) // The list is ime-padded: once the keyboard is up (or its height changes), scroll to the field. val keyboardHeight = WindowInsets.ime.getBottom(LocalDensity.current) LaunchedEffect(focused, keyboardHeight) { @@ -362,7 +366,7 @@ private fun InlineSubtaskField( focused = true } else if (focused) { focused = false - if (activity?.isChangingConfigurations != true) close() + if (!window.isChangingConfigurations) close() } }, done = done, @@ -379,7 +383,7 @@ private fun InlineSubtaskField( @OptIn(ExperimentalLayoutApi::class) @Composable private fun CloseWhenKeyboardHides(focused: Boolean, onClose: () -> Unit) { - val keyboardVisible = WindowInsets.isImeVisible + val keyboardVisible = rememberImeVisible() var seenOpen by remember { mutableStateOf(false) } val currentOnClose by rememberUpdatedState(onClose) LaunchedEffect(focused, keyboardVisible) { diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tasks/TaskFilterChips.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/TaskFilterChips.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/presentation/components/tasks/TaskFilterChips.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/TaskFilterChips.kt index 7d3d1cca..c65c9149 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tasks/TaskFilterChips.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/TaskFilterChips.kt @@ -7,10 +7,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import com.pasich.encly.presentation.designsystem.EnclyChip import com.pasich.encly.presentation.viewmodel.TaskFilter import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource /** [selectedFilterIds]: ids of the selected chips; filter ids are unique across filter types. */ @Composable diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tasks/TaskProgressCard.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/TaskProgressCard.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/presentation/components/tasks/TaskProgressCard.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/TaskProgressCard.kt index 38567469..40d92203 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tasks/TaskProgressCard.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tasks/TaskProgressCard.kt @@ -9,13 +9,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.core.resources.Res +import com.pasich.encly.core.resources.percent_value +import com.pasich.encly.core.resources.task_progress_summary +import com.pasich.encly.core.resources.task_progress_title import com.pasich.encly.presentation.designsystem.EnclyCard import com.pasich.encly.presentation.designsystem.EnclyCardStyle import com.pasich.encly.presentation.designsystem.EnclyProgressTrack import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource private const val PERCENT = 100f private const val PROGRESS_ANIMATION_MS = 250 @@ -36,13 +39,13 @@ fun TaskProgressCard( EnclyCard(modifier = modifier, style = EnclyCardStyle.OUTLINED) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(R.string.task_progress_title), + text = stringResource(Res.string.task_progress_title), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), ) Text( - text = stringResource(R.string.percent_value, completionPercentage), + text = stringResource(Res.string.percent_value, completionPercentage), style = EnclyTheme.typography.dataSmall, color = MaterialTheme.colorScheme.primary, ) @@ -50,7 +53,7 @@ fun TaskProgressCard( EnclyProgressTrack(progress = animatedProgress) Text( text = pluralStringResource( - R.plurals.task_progress_summary, + Res.plurals.task_progress_summary, totalTasksCount, completedTasksCount, totalTasksCount, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/NoteContextActions.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/NoteContextActions.kt new file mode 100644 index 00000000..fe4bccbd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/NoteContextActions.kt @@ -0,0 +1,18 @@ +package com.pasich.encly.presentation.components.tiles + +import androidx.compose.runtime.Immutable +import com.pasich.encly.presentation.dialogs.NoteAction + +/** + * What a right click on a note card offers on a desktop: the actions of the long-press sheet + * ([onAction], the same [NoteAction]s) and, where the host has them, [onOpenInNewTab] (tabs), [onOpenInNewWindow] + * (several windows) and [onExportPdf] and [onPrint] (a PDF writer and a printer). A null entry is not offered. + */ +@Immutable +class NoteContextActions( + val onAction: (NoteAction) -> Unit, + val onOpenInNewTab: (() -> Unit)? = null, + val onOpenInNewWindow: (() -> Unit)? = null, + val onExportPdf: (() -> Unit)? = null, + val onPrint: (() -> Unit)? = null, +) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/NoteItem.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/NoteItem.kt new file mode 100644 index 00000000..977a55b5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/NoteItem.kt @@ -0,0 +1,163 @@ +package com.pasich.encly.presentation.components.tiles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.duplicate +import com.pasich.encly.core.resources.note_archive +import com.pasich.encly.core.resources.note_color_title +import com.pasich.encly.core.resources.note_export_pdf +import com.pasich.encly.core.resources.note_open +import com.pasich.encly.core.resources.note_open_in_new_tab +import com.pasich.encly.core.resources.note_open_in_new_window +import com.pasich.encly.core.resources.note_pin +import com.pasich.encly.core.resources.note_preview_unreadable +import com.pasich.encly.core.resources.note_print +import com.pasich.encly.core.resources.note_unarchive +import com.pasich.encly.core.resources.note_unpin +import com.pasich.encly.core.resources.swipe_choice_delete +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.model.NoteColor +import com.pasich.encly.domain.model.NoteListItem +import com.pasich.encly.presentation.designsystem.EnclyContextMenu +import com.pasich.encly.presentation.designsystem.EnclyDesktopIcons +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyNoteCard +import com.pasich.encly.presentation.designsystem.MenuEntry +import com.pasich.encly.presentation.designsystem.NoteCardMeta +import com.pasich.encly.presentation.dialogs.NoteAction +import com.pasich.encly.presentation.effects.ContainerEnd +import com.pasich.encly.presentation.effects.rememberNoteContainerModifier +import com.pasich.encly.utils.NotesTextFormatter +import com.pasich.encly.utils.formatCardDate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.stringResource +import kotlin.time.Instant + +/** + * Whether a note card shows a preview of the note's text. Off, a card is the title, tag and date (see + * [com.pasich.encly.domain.model.NotePreviews]). Provided once around the app; every list draws its cards with + * [NoteItem], so the one switch covers the home list and grid, the tabs' list, search, the archive and the trash. + */ +val LocalShowNotePreviews = staticCompositionLocalOf { true } + +/** + * A note in a list: the design-system note card with its tag and date. Long-press opens the + * note's actions. [dimmed] is the trash look; a checked note ([Note.isChecked]) is shown selected. + * With [contextActions] a right click (or the Menu key) on the card opens a menu of the same actions; + * a finger never opens it, and the trash ([dimmed]) has none. [current] marks the note open beside the list, + * and [onCommandClick] is a click with the command key held (open in a new tab). + */ +@Composable +fun NoteItem( + onItemClick: (Note) -> Unit, + onItemLongClick: (Note) -> Unit, + modifier: Modifier = Modifier, + item: NoteListItem? = null, + itemNote: Note? = null, + excerptLines: Int = 2, + highlight: String = "", + dimmed: Boolean = false, + contextActions: NoteContextActions? = null, + current: Boolean = false, + onCommandClick: ((Note) -> Unit)? = null, +) { + val note: Note = item?.note ?: itemNote ?: Note() + val preview = if (LocalShowNotePreviews.current) { + rememberNotePreview(item, note) ?: stringResource(Res.string.note_preview_unreadable) + } else { + "" + } + val hapticFeedback = LocalHapticFeedback.current + + val menu = noteMenuEntries(note, contextActions) + EnclyContextMenu(entries = { menu }, enabled = contextActions != null && !dimmed) { + EnclyNoteCard( + title = note.title, + excerpt = preview, + meta = NoteCardMeta( + tags = item?.tags?.map { it.nameTag }.orEmpty(), + date = note.date.takeIf { it > 0L }?.let { formatCardDate(Instant.fromEpochMilliseconds(it)) }, + ), + excerptLines = excerptLines, + highlight = highlight, + selected = note.isChecked, + dimmed = dimmed, + pinned = note.isPinned && !dimmed, + noteColor = if (dimmed) NoteColor.NONE else NoteColor.fromKey(note.color), + current = current, + onCommandClick = onCommandClick?.let { { it(note) } }, + onClick = { onItemClick(note) }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onItemLongClick(note) + }, + // The trash opens its notes read-only, with the plain screen motion: no card to grow. + modifier = modifier.then(rememberNoteContainerModifier(if (dimmed) 0L else note.id, ContainerEnd.CARD)), + ) + } +} + +/** Open, open in a new tab or window, pin, archive, the next colour, duplicate, PDF, print and move to trash. */ +@Composable +private fun noteMenuEntries(note: Note, actions: NoteContextActions?): List { + if (actions == null) return emptyList() + val nextColor = NoteColor.fromKey(note.color).next() + val open = stringResource(Res.string.note_open) + val newTab = stringResource(Res.string.note_open_in_new_tab) + val newWindow = stringResource(Res.string.note_open_in_new_window) + val pdf = stringResource(Res.string.note_export_pdf) + val print = stringResource(Res.string.note_print) + val pin = stringResource(if (note.isPinned) Res.string.note_unpin else Res.string.note_pin) + val archive = stringResource(if (note.isArchived) Res.string.note_unarchive else Res.string.note_archive) + val color = "${stringResource(Res.string.note_color_title)}: ${stringResource(nextColor.labelRes)}" + val duplicate = stringResource(Res.string.duplicate) + val trash = stringResource(Res.string.swipe_choice_delete) + return buildList { + add(MenuEntry(open, { actions.onAction(NoteAction.Edit) }, EnclyIcons.Edit)) + actions.onOpenInNewTab?.let { add(MenuEntry(newTab, it, EnclyIcons.Plus)) } + actions.onOpenInNewWindow?.let { add(MenuEntry(newWindow, it, EnclyDesktopIcons.Window)) } + if (!note.isArchived) { + add( + MenuEntry( + pin, + { actions.onAction(NoteAction.Pin(!note.isPinned)) }, + if (note.isPinned) EnclyIcons.PinOff else EnclyIcons.Pin, + ), + ) + } + add( + MenuEntry( + archive, + { actions.onAction(NoteAction.Archive(!note.isArchived)) }, + if (note.isArchived) EnclyIcons.Unarchive else EnclyIcons.Archive, + ), + ) + add(MenuEntry(color, { actions.onAction(NoteAction.ChangeColor(nextColor)) }, EnclyIcons.Palette)) + add(MenuEntry(duplicate, { actions.onAction(NoteAction.Duplicate) }, EnclyIcons.Duplicate)) + actions.onExportPdf?.let { add(MenuEntry(pdf, it, EnclyIcons.Paper)) } + actions.onPrint?.let { add(MenuEntry(print, it, EnclyDesktopIcons.Print)) } + add(MenuEntry(trash, { actions.onAction(NoteAction.Delete) }, EnclyIcons.Trash, destructive = true)) + } +} + +/** + * The note list passes a preview built off the main thread; a bare note (the trash) is parsed + * here, also off the main thread. Null means unreadable content. + */ +@Composable +private fun rememberNotePreview(item: NoteListItem?, note: Note): String? { + if (item != null) return item.preview + val parsed by produceState(initialValue = note.description, note.description, note.value) { + value = withContext(Dispatchers.Default) { + note.description.ifEmpty { NotesTextFormatter.jsonToPlainText(note.value) } + } + } + return parsed +} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tiles/TagItem.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/TagItem.kt similarity index 83% rename from app/src/main/java/com/pasich/encly/presentation/components/tiles/TagItem.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/TagItem.kt index 5fe9dae9..ac2c3a43 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tiles/TagItem.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/TagItem.kt @@ -3,12 +3,13 @@ package com.pasich.encly.presentation.components.tiles import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.tag_hidden import com.pasich.encly.data.model.Tag import com.pasich.encly.presentation.designsystem.EnclyChip +import org.jetbrains.compose.resources.stringResource /** Opacity of a hidden tag's chip. */ private const val HIDDEN_TAG_ALPHA = 0.6f @@ -22,7 +23,7 @@ fun TagItem( count: Int? = null, onItemClick: () -> Unit = {}, ) { - val hiddenLabel = stringResource(R.string.tag_hidden) + val hiddenLabel = stringResource(Res.string.tag_hidden) EnclyChip( label = item.nameTag, selected = isSelected, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/TaskItem.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/TaskItem.kt new file mode 100644 index 00000000..5cacd23d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/tiles/TaskItem.kt @@ -0,0 +1,261 @@ +package com.pasich.encly.presentation.components.tiles + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.subtask_add +import com.pasich.encly.core.resources.subtasks_done_summary +import com.pasich.encly.core.resources.subtasks_hide +import com.pasich.encly.core.resources.subtasks_show +import com.pasich.encly.core.resources.subtasks_state_hidden +import com.pasich.encly.core.resources.subtasks_state_shown +import com.pasich.encly.core.resources.task_completed_at +import com.pasich.encly.core.resources.task_edit_placeholder +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Task +import com.pasich.encly.presentation.components.tasks.NextStepLeaf +import com.pasich.encly.presentation.components.tasks.PriorityValues +import com.pasich.encly.presentation.components.tasks.SubtaskTree +import com.pasich.encly.presentation.components.tasks.SubtaskTreeActions +import com.pasich.encly.presentation.designsystem.EnclyContextMenu +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyRowIconButton +import com.pasich.encly.presentation.designsystem.EnclySubtaskTree +import com.pasich.encly.presentation.designsystem.EnclyTaskRow +import com.pasich.encly.presentation.designsystem.MenuEntry +import com.pasich.encly.presentation.viewmodel.InlineSubtaskEdit +import com.pasich.encly.utils.formatDateTime +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import kotlin.time.Instant + +private const val TASK_REMOVAL_ANIMATION_MS = 400 +private const val TASK_TRANSLATION_X = 100f + +/** + * What a task's block on the Tasks list can do; the screen builds it once from its ViewModel. + * A tap on the row opens or folds its sub-task tree ([SubtaskTreeActions.onToggleTree]). + */ +@Immutable +class TaskItemActions( + val onToggleTask: (taskId: Long, completed: Boolean) -> Unit, + /** The pencil: opens the task sheet, the only way to it from the list. */ + val onEdit: (Task) -> Unit, + val tree: SubtaskTreeActions, +) + +private data class TaskCardState(val enabled: Boolean, val isRemoving: Boolean, val animationProgress: Float) + +/** + * A task and its sub-tasks. Folded: an open task shows its next step as one leaf + * ([NextStepLeaf]) and every task with sub-tasks a segment bar of their progress; a task + * without sub-tasks looks like a plain task. [expanded]: the whole tree ([SubtaskTree]). A tap on + * the row opens or folds it; the pencil opens the task sheet. The block is one list item, so it + * fades out as a whole when the task is completed. [inlineEdit] is the list's inline sub-task + * field when it belongs to this task. + */ +@Composable +fun TaskItem( + task: Task, + subtasks: List, + expanded: Boolean, + actions: TaskItemActions, + modifier: Modifier = Modifier, + inlineEdit: InlineSubtaskEdit? = null, + enabled: Boolean = true, +) { + var isRemoving by remember(task.id) { mutableStateOf(false) } + var shouldComplete by remember(task.id) { mutableStateOf(false) } + val animationProgress by animateFloatAsState( + targetValue = if (isRemoving) 0f else 1f, + animationSpec = tween(durationMillis = TASK_REMOVAL_ANIMATION_MS), + label = "task_removal_animation", + ) + + CompleteTaskAfterAnimation( + task = task, + shouldComplete = shouldComplete, + onRemovingChange = { isRemoving = it }, + onCompleteChange = { shouldComplete = it }, + onTaskToggle = actions.onToggleTask, + ) + + val state = TaskCardState( + enabled = enabled, + isRemoving = isRemoving, + animationProgress = animationProgress, + ) + val tree = updateTransition(targetState = expanded, label = "subtasks") + val enabledNow = state.enabled && !state.isRemoving + // Done tasks show only the bar while folded. + val showsNextStep = !task.isCompleted && subtasks.isNotEmpty() + val towardsEnd = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1f else 1f + Column( + modifier = modifier.graphicsLayer { + alpha = state.animationProgress + scaleX = state.animationProgress + scaleY = state.animationProgress + translationX = towardsEnd * (1f - state.animationProgress) * TASK_TRANSLATION_X + }, + ) { + TaskRow( + task = task, + subtasks = subtasks, + expanded = expanded, + // The trunk stays drawn until the tree has fully folded. + connectorBelow = showsNextStep || tree.currentState || tree.targetState, + enabled = enabledNow, + actions = actions, + onComplete = { shouldComplete = true }, + ) + EnclySubtaskTree( + expanded = tree, + collapsed = { + if (showsNextStep) NextStepLeaf(task.id, subtasks, actions.tree, enabled = enabledNow) + }, + ) { + SubtaskTree( + taskId = task.id, + subtasks = subtasks, + edit = inlineEdit, + actions = actions.tree, + dimmed = task.isCompleted, + enabled = enabledNow, + ) + } + } +} + +@Composable +private fun CompleteTaskAfterAnimation( + task: Task, + shouldComplete: Boolean, + onRemovingChange: (Boolean) -> Unit, + onCompleteChange: (Boolean) -> Unit, + onTaskToggle: (Long, Boolean) -> Unit, +) { + // The effect outlives recompositions (it restarts only on shouldComplete); always call the + // latest callbacks. + val currentOnRemovingChange by rememberUpdatedState(onRemovingChange) + val currentOnCompleteChange by rememberUpdatedState(onCompleteChange) + val currentOnTaskToggle by rememberUpdatedState(onTaskToggle) + LaunchedEffect(shouldComplete) { + if (!shouldComplete) return@LaunchedEffect + currentOnRemovingChange(true) + delay(TASK_REMOVAL_ANIMATION_MS.toLong()) + currentOnTaskToggle(task.id, true) + currentOnCompleteChange(false) + currentOnRemovingChange(false) + } +} + +/** + * The task's own row: checkbox (completes it after the removal animation, or reopens it), the + * text with the segment bar of its sub-tasks, and the pencil. A tap elsewhere opens or folds the + * tree. For TalkBack the row is one node that reads the title, priority and how many sub-tasks + * are done, with Expand/Collapse, Edit and Add sub-task as actions. + */ +@Suppress("LongParameterList", "LongMethod") // The row's state, callbacks and semantics, split out of TaskItem. +@Composable +private fun TaskRow( + task: Task, + subtasks: List, + expanded: Boolean, + connectorBelow: Boolean, + enabled: Boolean, + actions: TaskItemActions, + onComplete: () -> Unit, +) { + val focusManager = LocalFocusManager.current + val priority = PriorityValues.getById(task.priority) + val completedAt = task.completedDate?.takeIf { task.isCompleted }?.let { + stringResource(Res.string.task_completed_at, formatDateTime(Instant.fromEpochMilliseconds(it))) + } + val done = subtasks.count { it.isCompleted } + val summary = pluralStringResource(Res.plurals.subtasks_done_summary, subtasks.size, done, subtasks.size) + .takeIf { subtasks.isNotEmpty() } + val toggleLabel = stringResource(if (expanded) Res.string.subtasks_hide else Res.string.subtasks_show) + val stateLabel = stringResource(if (expanded) Res.string.subtasks_state_shown else Res.string.subtasks_state_hidden) + val editLabel = stringResource(Res.string.task_edit_placeholder) + val addLabel = stringResource(Res.string.subtask_add) + val toggle = { actions.tree.onToggleTree(task.id) } + // The same actions TalkBack gets, on a right click (or the Menu key) for a mouse and a keyboard. + EnclyContextMenu( + entries = { + listOf( + MenuEntry(toggleLabel, toggle, EnclyIcons.ChevronDown), + MenuEntry(editLabel, { actions.onEdit(task) }, EnclyIcons.Edit), + MenuEntry(addLabel, { actions.tree.onStartAdding(task.id) }, EnclyIcons.Plus), + ) + }, + enabled = enabled, + ) { + EnclyTaskRow( + title = task.title, + checked = task.isCompleted, + onCheckedChange = { checked -> + // Saves and closes an inline sub-task field first, as any other tap on the list does. + focusManager.clearFocus() + when { + checked && !task.isCompleted -> onComplete() + !checked && task.isCompleted -> actions.onToggleTask(task.id, false) + } + }, + description = task.description, + meta = completedAt, + priority = stringResource(priority.label).takeIf { !task.isCompleted }, + priorityEmphasis = priority.emphasis, + large = true, + enabled = enabled, + // Done tasks open too: their sub-tasks can still be ticked, renamed and added. + onClick = toggle, + onClickLabel = toggleLabel, + connectorBelow = connectorBelow, + progress = subtasks.map { it.isCompleted }.takeIf { it.isNotEmpty() }, + modifier = Modifier.semantics { + stateDescription = listOfNotNull(summary, stateLabel).joinToString(", ") + customActions = listOf( + CustomAccessibilityAction(toggleLabel) { + toggle() + true + }, + CustomAccessibilityAction(editLabel) { + actions.onEdit(task) + true + }, + CustomAccessibilityAction(addLabel) { + actions.tree.onStartAdding(task.id) + true + }, + ) + }, + ) { + EnclyRowIconButton( + icon = EnclyIcons.Edit, + contentDescription = editLabel, + onClick = { actions.onEdit(task) }, + enabled = enabled, + ) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Brand.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Brand.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Brand.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Brand.kt index f8d56f21..69420a97 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Brand.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Brand.kt @@ -20,13 +20,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.pasich.encly.R +import com.pasich.encly.shared.resources.Res +import com.pasich.encly.shared.resources.ic_launcher_foreground import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.painterResource /** Size of the wordmark tile. */ private val WordmarkTile = 34.dp @@ -80,7 +81,7 @@ fun LanguagePill(label: String, description: String, onClick: () -> Unit, modifi border = BorderStroke(EnclyTheme.spacing.hairline, MaterialTheme.colorScheme.outlineVariant), color = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface, - modifier = modifier.heightIn(min = EnclyTheme.spacing.minTouchTarget), + modifier = modifier.enclyInteractiveRound().heightIn(min = EnclyTheme.spacing.minTouchTarget), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -125,7 +126,7 @@ fun LabelHeader(label: String, onBack: () -> Unit, modifier: Modifier = Modifier fun EnclyLogoMark(modifier: Modifier = Modifier, size: Dp = EnclyTheme.spacing.tile, shape: Shape = TileShape) { Surface(shape = shape, color = MaterialTheme.colorScheme.primary, modifier = modifier) { Icon( - painterResource(R.drawable.ic_launcher_foreground), + painterResource(Res.drawable.ic_launcher_foreground), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(size), diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Buttons.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Buttons.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Buttons.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Buttons.kt index 8d423326..74ffa295 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Buttons.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Buttons.kt @@ -65,6 +65,7 @@ fun EnclyButton( }, ), modifier = modifier + .enclyInteractiveRound(enabled && !loading) .fillMaxWidth() .heightIn(min = EnclyTheme.spacing.buttonHeight), ) { @@ -105,6 +106,7 @@ fun EnclyTonalButton(text: String, onClick: () -> Unit, modifier: Modifier = Mod contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), modifier = modifier + .enclyInteractiveRound(enabled) .fillMaxWidth() .heightIn(min = EnclyTheme.spacing.buttonHeight), ) { @@ -138,7 +140,7 @@ fun EnclyTextButton( else -> colors.primary }, ), - modifier = modifier.heightIn(min = EnclyTheme.spacing.textButtonHeight), + modifier = modifier.enclyInteractiveRound(enabled).heightIn(min = EnclyTheme.spacing.textButtonHeight), ) { if (icon != null) { Icon( @@ -169,7 +171,7 @@ fun EnclyPillButton( border = BorderStroke(EnclyTheme.spacing.hairline, MaterialTheme.colorScheme.outlineVariant), colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.m), - modifier = modifier.heightIn(min = EnclyTheme.spacing.textButtonHeight), + modifier = modifier.enclyInteractiveRound(enabled).heightIn(min = EnclyTheme.spacing.textButtonHeight), ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/ColorPicker.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ColorPicker.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/ColorPicker.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ColorPicker.kt index e4e23b26..0ff004c3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/ColorPicker.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ColorPicker.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -27,6 +26,7 @@ import androidx.compose.ui.unit.dp import com.pasich.encly.domain.model.NoteColor import com.pasich.encly.ui.theme.EnclyTheme import com.pasich.encly.ui.theme.noteColorStyle +import org.jetbrains.compose.resources.stringResource private val SwatchSize = 36.dp private const val SLASH_INSET = 0.28f @@ -65,6 +65,7 @@ private fun ColorSwatch(color: NoteColor, selected: Boolean, onClick: () -> Unit contentAlignment = Alignment.Center, modifier = Modifier .size(EnclyTheme.spacing.minTouchTarget) + .enclyInteractiveRound() .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) .semantics { contentDescription = label }, ) { diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Content.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Content.kt similarity index 98% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Content.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Content.kt index d0a7ef43..f1453d5b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Content.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Content.kt @@ -135,7 +135,7 @@ fun FilePickerButton( color = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurface, border = BorderStroke(EnclyTheme.spacing.hairline, MaterialTheme.colorScheme.outlineVariant), - modifier = modifier.fillMaxWidth(), + modifier = modifier.enclyInteractive(MaterialTheme.shapes.medium, enabled).fillMaxWidth(), ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ContextMenu.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ContextMenu.kt new file mode 100644 index 00000000..9cff3b75 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ContextMenu.kt @@ -0,0 +1,149 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection + +/** + * A menu that opens at the pointer on a right click (the secondary mouse button) on [content], or on the + * Menu key / Shift+F10 while [content] holds keyboard focus, listing the [entries] built at that moment. + * It is a Material menu, so it is themed, closes on Esc or a click outside, and its rows take the arrow + * keys, Enter and hover. A finger never opens it: touch keeps the long-press sheets. The menu takes the + * right click before its children, so a card or a button inside does not act on it; with [claimPress] + * false it takes only a right click that no child used (around a text field, whose own cut / copy / paste + * menu is then left alone). With [enabled] false the content is shown as is. + */ +@Composable +fun EnclyContextMenu( + entries: () -> List, + modifier: Modifier = Modifier, + enabled: Boolean = true, + claimPress: Boolean = true, + content: @Composable () -> Unit, +) { + var anchor by remember { mutableStateOf(null) } + // One lambda for the life of the menu, so the pointer loop below is never restarted. + val open = remember { { position: Offset -> anchor = position } } + var size by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box( + modifier = modifier + .onSizeChanged { size = it } + .contextMenuTriggers(enabled, claimPress, open), + ) { + content() + anchor?.let { position -> + ContextMenuPopup( + entries = remember(position) { entries() }, + // A menu is placed from the bottom start of its anchor: its left edge, or its right edge in a + // right-to-left layout, where the horizontal offset counts from there. + offset = with(density) { + val fromStart = if (rtl) size.width - position.x else position.x + DpOffset(fromStart.toDp(), (position.y - size.height).toDp()) + }, + onDismiss = { anchor = null }, + ) + } + } +} + +@Composable +private fun ContextMenuPopup(entries: List, offset: DpOffset, onDismiss: () -> Unit) { + val colors = MaterialTheme.colorScheme + DropdownMenu( + expanded = true, + onDismissRequest = onDismiss, + offset = offset, + // Esc closes the menu from any row, also where the platform's popup does not do it itself. + modifier = Modifier.onKeyEvent { event -> + (event.key == Key.Escape && event.type == KeyEventType.KeyDown).also { if (it) onDismiss() } + }, + ) { + entries.forEach { entry -> + DropdownMenuItem( + text = { Text(entry.label, style = MaterialTheme.typography.labelLarge) }, + onClick = { + onDismiss() + entry.onClick() + }, + enabled = entry.enabled, + leadingIcon = entry.icon?.let { icon -> { Icon(icon, contentDescription = null) } }, + colors = if (entry.destructive) { + MenuDefaults.itemColors( + textColor = colors.error, + leadingIconColor = colors.error, + ) + } else { + MenuDefaults.itemColors() + }, + modifier = Modifier.enclyInteractive(enabled = entry.enabled), + ) + } + } +} + +/** Opens at the pointer on a secondary press that nothing below used, and at the corner on the Menu key. */ +private fun Modifier.contextMenuTriggers(enabled: Boolean, claimPress: Boolean, open: (Offset) -> Unit): Modifier = + if (enabled) { + this + .secondaryPress(if (claimPress) PointerEventPass.Initial else PointerEventPass.Final, open) + .onKeyEvent { event -> + val menuKey = event.key == Key.Menu || (event.key == Key.F10 && event.isShiftPressed) + (menuKey && event.type == KeyEventType.KeyDown).also { if (it) open(KeyboardMenuAnchor) } + } + } else { + this + } + +/** Where the menu opens for the keyboard: just inside the top start corner of the content, in pixels. */ +private val KeyboardMenuAnchor = Offset(x = 24f, y = 24f) + +/** + * Calls [open] with the position of a secondary-button press, seen in [pass] (the initial pass is before + * the children, the final one after them); a press that is used here is consumed, so it starts nothing else. + */ +private fun Modifier.secondaryPress(pass: PointerEventPass, open: (Offset) -> Unit): Modifier = + pointerInput(pass, open) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(pass) + val press = event.changes.firstOrNull() + if (press != null && event.isSecondaryPress() && press.changedToDown()) { + press.consume() + open(press.position) + } + } + } + } + +private fun PointerEvent.isSecondaryPress() = type == PointerEventType.Press && buttons.isSecondaryPressed diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/DialogAction.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/DialogAction.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/DialogAction.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/DialogAction.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Editor.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Editor.kt index 191b57d6..c277e0d1 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Editor.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -33,6 +34,12 @@ import com.pasich.encly.ui.theme.EnclyTheme private val ToolSize = 44.dp private val ToolShape = RoundedCornerShape(14.dp) +/** A toolbar button's [button] target and [icon] size. */ +class ToolSizes(val button: Dp, val icon: Dp) + +/** The size of the toolbar's buttons below it; a toolbar crowded with tools provides smaller ones. */ +val LocalToolSizes = staticCompositionLocalOf { null } + /** * The editor's formatting toolbar, pinned above the keyboard: `surfaceContainerHigh`, a 1 dp * `outlineVariant` hairline on top, padding 8/10 with 16 below (plus the navigation bar). @@ -64,7 +71,7 @@ enum class ToolStyle { PLAIN, ACTIVE, FILLED } /** * A 44 dp toolbar button with radius 14 and a 22 dp icon. [ToolStyle.FILLED] is the `primary` * "Add block"; [ToolStyle.ACTIVE] marks the tool of the focused block (`primaryContainer`). - * A null [onClick] shows it disabled. + * A null [onClick] shows it disabled. A toolbar crowded with tools passes a smaller [size] and [iconSize]. */ @Composable fun EnclyToolButton( @@ -73,6 +80,8 @@ fun EnclyToolButton( onClick: (() -> Unit)?, modifier: Modifier = Modifier, style: ToolStyle = ToolStyle.PLAIN, + size: Dp = LocalToolSizes.current?.button ?: ToolSize, + iconSize: Dp = LocalToolSizes.current?.icon ?: EnclyTheme.spacing.iconMedium, ) { val colors = MaterialTheme.colorScheme val enabled = onClick != null @@ -89,7 +98,8 @@ fun EnclyToolButton( color = container, contentColor = if (enabled) content else content.disabled(), modifier = Modifier - .size(ToolSize) + .enclyInteractive(ToolShape, enabled) + .size(size) .semantics { role = Role.Button selected = style == ToolStyle.ACTIVE @@ -99,24 +109,13 @@ fun EnclyToolButton( Icon( icon, contentDescription = contentDescription, - modifier = Modifier.size(EnclyTheme.spacing.iconMedium), + modifier = Modifier.size(iconSize), ) } } } } -/** Padding inside a block frame; the editor column is inset by [BlockBleed] less than the gutter. */ -private val BlockBleed = 8.dp -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(verticalInset: Dp? = null): Modifier = - padding(horizontal = BlockBleed, vertical = verticalInset ?: BlockInset) - /** A thin vertical rule between groups of toolbar buttons. */ @Composable fun EnclyToolbarRule(modifier: Modifier = Modifier) { @@ -126,6 +125,3 @@ 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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyDesktopIcons.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyDesktopIcons.kt new file mode 100644 index 00000000..e7a7c83f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyDesktopIcons.kt @@ -0,0 +1,27 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.ui.graphics.vector.ImageVector + +/** The glyphs of what only a host with several windows and a printer offers, in the grammar of [EnclyIcons]. */ +object EnclyDesktopIcons { + /** A printer: print the note. */ + val Print: ImageVector by lazy { + enclyIcon( + "Print", + "M7 9V4h10v5", + "M7 17H5.5A1.5 1.5 0 0 1 4 15.5v-5A1.5 1.5 0 0 1 5.5 9h13a1.5 1.5 0 0 1 1.5 1.5" + + "v5a1.5 1.5 0 0 1-1.5 1.5H17", + "M7 14h10v6H7z", + ) + } + + /** A window with its title bar: open in a new window. */ + val Window: ImageVector by lazy { + enclyIcon( + "Window", + "M4 5.5A1.5 1.5 0 0 1 5.5 4h13A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5" + + "h-13A1.5 1.5 0 0 1 4 18.5z", + "M4 9h16", + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/EnclyDialog.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyDialog.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/EnclyDialog.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyDialog.kt index 265f2b6e..cbc3c817 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/EnclyDialog.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyDialog.kt @@ -59,6 +59,7 @@ private fun DialogButton(action: DialogAction) { TextButton( onClick = action.onClick, enabled = action.enabled, + modifier = Modifier.enclyInteractiveRound(action.enabled), colors = ButtonDefaults.textButtonColors( contentColor = if (action.destructive) colors.error else colors.primary, ), diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyEditorIcons.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyEditorIcons.kt new file mode 100644 index 00000000..83f1eadb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyEditorIcons.kt @@ -0,0 +1,28 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.ui.graphics.vector.ImageVector + +// The editor's block icons for code and tables, kept apart from EnclyIcons, which is as large as a class may be. + +private val CodeIcon: ImageVector by lazy { + enclyIcon( + "Code", + "M8 8l-4 4 4 4", + "M16 8l4 4-4 4", + "M13.5 6l-3 12", + ) +} + +private val TableIcon: ImageVector by lazy { + enclyIcon( + "Table", + "M4 6.5A1.5 1.5 0 0 1 5.5 5h13A1.5 1.5 0 0 1 20 6.5v11a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z", + "M4 10.5h16M4 14.8h16M10 5v14", + ) +} + +/** The `< >` of a code block. */ +val EnclyIcons.Code: ImageVector get() = CodeIcon + +/** A grid of rows and columns, for a table block. */ +val EnclyIcons.Table: ImageVector get() = TableIcon diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/EnclyIcons.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyIcons.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/EnclyIcons.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyIcons.kt index 6d89d9b9..5088e5ee 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/EnclyIcons.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyIcons.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp * Encly's line icons: 24 viewBox, stroke 1.75, round caps and joins (SPEC Appendix B, plus the extra * glyphs drawn in the same grammar). They carry no colour of their own; `Icon` tints them. * The `*Bold` variants are the heavier strokes the canvas uses for the FAB, the add-block button, - * the wordmark and small done checks. + * the wordmark and small done checks. The glyphs with a direction flip in a right-to-left layout. */ object EnclyIcons { /** A drag handle: two columns of three dots. */ @@ -694,9 +694,15 @@ object EnclyIcons { } private const val STROKE = 1.75f + +/** The icons with a direction, drawn flipped in a right-to-left layout (a back arrow, undo, a list marker...). */ +private val MirroredIcons = setOf( + "Back", "Chevron", "Undo", "Redo", "Send", "ListBullet", "Checklist", "Quote", "External", "Help", "Comment", + "Indent", "Outdent", +) private val Ink = SolidColor(Color.Black) -private fun enclyIcon( +internal fun enclyIcon( name: String, vararg paths: String, strokeWidth: Float = STROKE, @@ -707,6 +713,7 @@ private fun enclyIcon( defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f, + autoMirror = name in MirroredIcons, ).apply { paths.forEach { data -> addPath( diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyListIcons.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyListIcons.kt new file mode 100644 index 00000000..478a20dd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyListIcons.kt @@ -0,0 +1,27 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.ui.graphics.vector.ImageVector + +// The icons of nested lists. Drawn like the others (see enclyIcon), mirrored in a right-to-left layout. + +private val IndentIcon: ImageVector by lazy { + enclyIcon( + "Indent", + "M4 5.5h16M12 10h8M12 14h8M4 18.5h16", + "M4 9.5l3 2.5-3 2.5", + ) +} + +private val OutdentIcon: ImageVector by lazy { + enclyIcon( + "Outdent", + "M4 5.5h16M12 10h8M12 14h8M4 18.5h16", + "M7 9.5l-3 2.5 3 2.5", + ) +} + +/** Moves a list item one level deeper. */ +internal val EnclyIcons.Indent: ImageVector get() = IndentIcon + +/** Brings a list item out one level. */ +internal val EnclyIcons.Outdent: ImageVector get() = OutdentIcon diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcuts.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcuts.kt new file mode 100644 index 00000000..d4296def --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcuts.kt @@ -0,0 +1,221 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +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.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type + +/** What a keyboard shortcut asks the app to do. */ +enum class AppCommand { + /** Start a new note. */ + NewNote, + + /** Find in the note that is open. */ + FindInNote, + + /** Lock the vault now. */ + LockApp, + + /** Close the current note tab. */ + CloseTab, + + /** Go to the next open tab. */ + NextTab, + + /** Go to the previous open tab. */ + PreviousTab, + + /** Move the current note to the trash (the app asks first). */ + DeleteNote, + + /** Close what is open on top: a dialog, a sheet, the find bar. */ + Dismiss, + + /** Print the open note. Asked by the host's menu, which owns its chord. */ + PrintNote, + + /** Save the open note as a PDF. Asked by the host's menu, which owns its chord. */ + ExportPdf, +} + +/** + * The keyboard shortcuts of the desktop app, as one map from key chords to [AppCommand]s. The command + * key is Meta (⌘) on macOS and Ctrl elsewhere ([commandKeyIsMeta]); a chord with the other one, or with + * Alt, is not ours, so text editing, the window manager and the OS keep theirs. + * + * | Command | macOS | Windows and Linux | + * |---|---|---| + * | [AppCommand.NewNote] | ⌘N | Ctrl+N | + * | [AppCommand.FindInNote] | ⌘F | Ctrl+F | + * | [AppCommand.LockApp] | ⌘L | Ctrl+L | + * | [AppCommand.CloseTab] | ⌘W | Ctrl+W | + * | [AppCommand.NextTab] | ⌃Tab, ⌘⇧] | Ctrl+Tab, Ctrl+PageDown | + * | [AppCommand.PreviousTab] | ⌃⇧Tab, ⌘⇧[ | Ctrl+Shift+Tab, Ctrl+PageUp | + * | [AppCommand.DeleteNote] | ⌘⇧⌫ | Ctrl+Shift+Backspace | + * | [AppCommand.Dismiss] | Esc | Esc | + * | [AppCommand.PrintNote] | ⌘P (menu) | Ctrl+P (menu) | + * | [AppCommand.ExportPdf] | ⌘⇧P (menu) | Ctrl+Shift+P (menu) | + * + * The last two are asked by the host's menu only; the map has no key for them, so the host never sees their chord. + * + * Delete is Shift-qualified on purpose: plain ⌘⌫ / Ctrl+Backspace belong to the text field (delete to + * the line start / the previous word), and the app confirms before it trashes a note anyway. + * + * Wire it once at the app root with [enclyShortcuts] or [EnclyShortcutHost]; the commands are the app's to run. + * A host that has its own menu with accelerators (the desktop menu bar) must not register a chord of this map + * as well as the host: [keyOf] gives the menu the key of a command it owns, and the host is told to decline that + * command, so a chord has exactly one owner. + */ +class EnclyShortcuts(private val commandKeyIsMeta: Boolean = platformUsesCommandKey()) { + /** + * The command a key press asks for, or null when it is not one of ours (or not a key down). + * [AppCommand.Dismiss] is Esc alone. + */ + fun commandFor(event: KeyEvent): AppCommand? { + if (event.type != KeyEventType.KeyDown) return null + return when { + event.key == Key.Escape -> AppCommand.Dismiss.takeIf { !anyModifier(event) } + event.isAltPressed -> null + tabSwitch(event) != null -> tabSwitch(event) + !commandPressed(event) || otherCommandPressed(event) -> null + else -> chordCommand(event) + } + } + + /** + * The key of [command] when it is the command key plus one letter (New, Find, Lock, Close tab), else null. + * A menu that registers the accelerator itself builds it from this, so the map stays the one source. + */ + fun keyOf(command: AppCommand): Key? = LETTER_COMMANDS[command]?.key + + /** Whether the command key is Meta (⌘) rather than Ctrl, as this map was made for. */ + val usesMetaKey: Boolean get() = commandKeyIsMeta + + /** The shortcut of [command] as the user reads it: "⌘N" on macOS, "Ctrl+N" elsewhere. */ + fun label(command: AppCommand): String { + val mod = if (commandKeyIsMeta) "⌘" else "Ctrl+" + return when (command) { + AppCommand.NewNote, AppCommand.FindInNote, AppCommand.LockApp, AppCommand.CloseTab -> + "$mod${LETTER_COMMANDS.getValue(command).label}" + + AppCommand.NextTab -> if (commandKeyIsMeta) "⌃Tab" else "Ctrl+Tab" + + AppCommand.PreviousTab -> if (commandKeyIsMeta) "⌃⇧Tab" else "Ctrl+Shift+Tab" + + AppCommand.DeleteNote -> if (commandKeyIsMeta) "⌘⇧⌫" else "Ctrl+Shift+Backspace" + + AppCommand.Dismiss -> "Esc" + + AppCommand.PrintNote -> "${mod}P" + + AppCommand.ExportPdf -> if (commandKeyIsMeta) "⌘⇧P" else "Ctrl+Shift+P" + } + } + + private fun commandPressed(event: KeyEvent) = if (commandKeyIsMeta) event.isMetaPressed else event.isCtrlPressed + + private fun otherCommandPressed(event: KeyEvent) = + if (commandKeyIsMeta) event.isCtrlPressed else event.isMetaPressed + + private fun anyModifier(event: KeyEvent) = + event.isCtrlPressed || event.isMetaPressed || event.isAltPressed || event.isShiftPressed + + /** The Mac habit for tabs, ⌘⇧] and ⌘⇧[; elsewhere Page Down and Up. */ + private fun tabChord(event: KeyEvent): AppCommand? = when { + event.key == Key.RightBracket && event.isShiftPressed -> AppCommand.NextTab + event.key == Key.LeftBracket && event.isShiftPressed -> AppCommand.PreviousTab + event.key == Key.PageDown && !event.isShiftPressed -> AppCommand.NextTab + event.key == Key.PageUp && !event.isShiftPressed -> AppCommand.PreviousTab + else -> null + } + + /** Ctrl+Tab and Ctrl+Shift+Tab switch tabs on every platform (a browser habit), also on a Mac. */ + private fun tabSwitch(event: KeyEvent): AppCommand? = when { + event.key == Key.Tab && event.isCtrlPressed && !event.isMetaPressed -> + if (event.isShiftPressed) AppCommand.PreviousTab else AppCommand.NextTab + + else -> null + } + + private fun chordCommand(event: KeyEvent): AppCommand? = when { + event.key == Key.Backspace -> AppCommand.DeleteNote.takeIf { event.isShiftPressed } + tabChord(event) != null -> tabChord(event) + event.isShiftPressed -> null + else -> LETTER_COMMANDS.entries.firstOrNull { it.value.key == event.key }?.key + } + + private class Letter(val key: Key, val label: String) + + private companion object { + /** The commands that are the command key plus a letter: the one table both the keys and the labels read. */ + val LETTER_COMMANDS: Map = mapOf( + AppCommand.NewNote to Letter(Key.N, "N"), + AppCommand.FindInNote to Letter(Key.F, "F"), + AppCommand.LockApp to Letter(Key.L, "L"), + AppCommand.CloseTab to Letter(Key.W, "W"), + ) + } +} + +/** The shortcut map of the window: the host provides its own, so every label under it reads the same keys. */ +val LocalEnclyShortcuts = staticCompositionLocalOf { EnclyShortcuts() } + +/** + * Handles the app's shortcuts for everything below this modifier: [onCommand] gets the command and + * returns whether it did something (the key press is then consumed). Chords are seen before the focused + * field, so ⌘N works while typing; Esc is seen only after the focused element passed on it, so a dialog, + * a menu or a field that uses Esc keeps it. Key events reach it only while something below holds focus: + * see [EnclyShortcutHost], which also makes sure something does. + */ +fun Modifier.enclyShortcuts( + shortcuts: EnclyShortcuts = EnclyShortcuts(), + onCommand: (AppCommand) -> Boolean, +): Modifier = this + .onPreviewKeyEvent { event -> + shortcuts.commandFor(event)?.takeIf { it != AppCommand.Dismiss }?.let(onCommand) ?: false + } + .onKeyEvent { event -> + shortcuts.commandFor(event)?.takeIf { it == AppCommand.Dismiss }?.let(onCommand) ?: false + } + +/** + * The app root's key handler: a [Box] around [content] that handles [enclyShortcuts] and holds the + * keyboard focus until something inside asks for it, so a shortcut works right after the window opens + * and again after a click on a part of the screen that takes no focus. Wire it once, around the whole + * app (`EnclyApp`'s content), and run the [AppCommand]s in [onCommand]. + */ +@Composable +fun EnclyShortcutHost( + onCommand: (AppCommand) -> Boolean, + modifier: Modifier = Modifier, + shortcuts: EnclyShortcuts = remember { EnclyShortcuts() }, + content: @Composable () -> Unit, +) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + Box( + modifier = modifier + .enclyShortcuts(shortcuts, onCommand) + .focusRequester(focus) + .focusable(), + ) { + CompositionLocalProvider(LocalEnclyShortcuts provides shortcuts, content = content) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Fab.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Fab.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Fab.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Fab.kt index b433e02d..513ccb65 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Fab.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Fab.kt @@ -40,7 +40,9 @@ fun EnclyFab( containerColor = if (destructive) colors.error else colors.primary, contentColor = if (destructive) colors.onError else colors.onPrimary, elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), - modifier = modifier.shadow(ShadowElevation, shape, ambientColor = shadow, spotColor = shadow), + modifier = modifier + .shadow(ShadowElevation, shape, ambientColor = shadow, spotColor = shadow) + .enclyInteractive(shape), ) } } diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/HighlightedText.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/HighlightedText.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/HighlightedText.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/HighlightedText.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Layout.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Layout.kt new file mode 100644 index 00000000..4b621f9d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Layout.kt @@ -0,0 +1,31 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +/** + * Lays [content] out left to right whatever the layout direction: the PIN keypad and its dots, and the + * recovery phrase words (English, BIP-39) keep their order and numbering in a right-to-left layout. + */ +@Composable +fun LtrContent(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr, content = content) +} + +/** + * Widens the element by [horizontal] on both sides, past its parent's padding: a full-width row + * (the search field and tag chips) inside a padded grid still runs to the screen edges. + */ +fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints -> + val extra = horizontal.roundToPx() * 2 + val width = constraints.maxWidth + extra + val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width)) + layout(constraints.maxWidth, placeable.height) { + placeable.place(-extra / 2, 0) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/MenuEntry.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/MenuEntry.kt new file mode 100644 index 00000000..1b0c559f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/MenuEntry.kt @@ -0,0 +1,17 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * One row of a context menu: [label], an optional leading [icon], the [onClick] it runs (the menu + * closes first), and whether it is [destructive] (drawn in `error`) or not [enabled]. + */ +@Immutable +data class MenuEntry( + val label: String, + val onClick: () -> Unit, + val icon: ImageVector? = null, + val destructive: Boolean = false, + val enabled: Boolean = true, +) diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Misc.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Misc.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Misc.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Misc.kt index 1bf6515c..c50f4a38 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Misc.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Misc.kt @@ -7,6 +7,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -41,9 +44,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.onLongClick import androidx.compose.ui.semantics.role @@ -51,12 +54,16 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.collapse +import com.pasich.encly.core.resources.expand import com.pasich.encly.presentation.effects.shimmerEffect import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource private const val HOLD_TO_CONFIRM_MS = 4_000 private const val HALF = 0.5f +private const val HOVER_LAYER_ALPHA = 0.08f private const val CHEVRON_TURN = 180f private const val SKELETON_TITLE_FRACTION = 0.6f private const val SKELETON_LINE_FRACTION = 0.9f @@ -130,6 +137,9 @@ fun EnclyHoldToConfirmButton( finishedListener = { if (it >= 1f) onConfirm() }, ) val colors = MaterialTheme.colorScheme + // This button has no ripple, so the pointer's hover is shown with the Material state layer by hand. + val hoverSource = remember { MutableInteractionSource() } + val hovered by hoverSource.collectIsHoveredAsState() Box( contentAlignment = Alignment.Center, modifier = modifier @@ -137,6 +147,9 @@ fun EnclyHoldToConfirmButton( .height(EnclyTheme.spacing.buttonHeight) .clip(CircleShape) .background(colors.errorContainer) + .background(if (hovered) colors.onErrorContainer.copy(alpha = HOVER_LAYER_ALPHA) else Color.Transparent) + .enclyInteractive(CircleShape) + .hoverable(hoverSource) .semantics { role = Role.Button onLongClick { @@ -180,6 +193,7 @@ fun EnclyExpandableRow(question: String, answer: String, modifier: Modifier = Mo Column( modifier = modifier .fillMaxWidth() + .enclyInteractive(MaterialTheme.shapes.medium) .clickable(role = Role.Button) { expanded = !expanded } .padding(horizontal = EnclyTheme.spacing.m, vertical = EnclyTheme.spacing.rowGap), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), @@ -196,7 +210,7 @@ fun EnclyExpandableRow(question: String, answer: String, modifier: Modifier = Mo ) Icon( EnclyIcons.ChevronDown, - contentDescription = stringResource(if (expanded) R.string.collapse else R.string.expand), + contentDescription = stringResource(if (expanded) Res.string.collapse else Res.string.expand), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .size(EnclyTheme.spacing.iconSmall) @@ -237,7 +251,9 @@ fun ThemeSwatch( Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.labelGap), - modifier = modifier.selectable(selected = selected, role = Role.RadioButton, onClick = onClick), + modifier = modifier + .enclyInteractive(SwatchShape) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick), ) { Column( verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), @@ -322,6 +338,7 @@ fun EnclyDrawerItem( unselectedBadgeColor = MaterialTheme.colorScheme.onSurfaceVariant, ), modifier = modifier + .enclyInteractiveRound() .heightIn(min = EnclyTheme.spacing.rowHeight) .padding(NavigationDrawerItemDefaults.ItemPadding), ) @@ -341,6 +358,7 @@ fun EnclyDrawerHighlight(label: String, icon: ImageVector, onClick: () -> Unit, modifier = modifier .padding(NavigationDrawerItemDefaults.ItemPadding) .padding(vertical = EnclyTheme.spacing.xxs) + .enclyInteractiveRound() .fillMaxWidth() .heightIn(min = EnclyTheme.spacing.rowHeight), ) { @@ -364,6 +382,7 @@ fun EnclyInlineLink(text: String, onClick: () -> Unit, modifier: Modifier = Modi color = MaterialTheme.colorScheme.primary, modifier = modifier .clip(CircleShape) + .enclyInteractiveRound() .clickable(role = Role.Button, onClick = onClick) .padding(horizontal = EnclyTheme.spacing.xs, vertical = EnclyTheme.spacing.xxs), ) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/NoteCard.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/NoteCard.kt new file mode 100644 index 00000000..05688065 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/NoteCard.kt @@ -0,0 +1,264 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.note_tags_more +import com.pasich.encly.core.resources.notes_section_pinned +import com.pasich.encly.domain.model.NoteColor +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.ui.theme.noteColorStyle +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource + +/** + * Title opacity of a card in the trash (design spec §4.6). Only the title fades: the excerpt and + * meta line are already muted (`onSurfaceVariant`) and faded too would drop below 4.5:1. + */ +const val DIMMED_CARD_ALPHA = 0.7f + +private val MetaIcon = 14.dp + +/** The meta line of a note card: the note's tags, relative date and checklist progress; each is optional. */ +@Immutable +data class NoteCardMeta(val tags: List = emptyList(), val date: String? = null, val progress: String? = null) + +/** How many of a note's tags its card names; the rest are counted ("+2"). */ +const val CARD_TAGS_SHOWN = 2 + +/** + * A note card (design spec §3.3): `surfaceContainer`, radius 16, padding 16, no border or shadow. + * Title in titleMedium, excerpt in bodyMedium `onSurfaceVariant` cut at [excerptLines], then the + * meta line. [highlight] marks search matches. A [noteColor] tints the card and adds an accent edge. + * [selected] draws a 2 dp `primary` ring and a check; [dimmed] fades the title (trash), keeping every + * text at 4.5:1 or more. A [pinned] note shows a small pin after its title. [current] marks the note that is + * open beside the list (the shown tab of the desktop layout): a raised container and an `outline` edge, so it + * reads apart from a [selected] (checked) card, which it can also be. With [onCommandClick] a click with the + * command key held calls it instead of [onClick] (see [enclyCommandClick]). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EnclyNoteCard( + title: String?, + excerpt: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + meta: NoteCardMeta = NoteCardMeta(), + excerptLines: Int = 2, + highlight: String = "", + selected: Boolean = false, + dimmed: Boolean = false, + pinned: Boolean = false, + noteColor: NoteColor = NoteColor.NONE, + current: Boolean = false, + onCommandClick: (() -> Unit)? = null, +) { + val colors = MaterialTheme.colorScheme + val tint = remember(noteColor, colors) { noteColorStyle(noteColor, colors) } + val muted = tint?.onCardVariant ?: colors.onSurfaceVariant + Surface( + shape = MaterialTheme.shapes.medium, + color = tint?.card ?: untintedCardColor(current), + contentColor = colors.onSurface, + border = cardBorder(selected, current), + modifier = modifier + .fillMaxWidth() + .semantics { this.selected = selected || current } + .enclyInteractive(MaterialTheme.shapes.medium) + .then(if (onCommandClick != null) Modifier.enclyCommandClick(onCommandClick) else Modifier) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.labelGap), + modifier = Modifier + .then(if (tint != null) Modifier.accentEdge(tint.accent, EnclyTheme.spacing.xxs) else Modifier) + .padding(EnclyTheme.spacing.m), + ) { + if (!title.isNullOrEmpty() || selected || pinned) { + NoteTitleRow( + title = title.orEmpty(), + highlight = highlight, + state = TitleState(dimmed = dimmed, pinned = pinned, selected = selected, muted = muted), + ) + } + if (excerpt.isNotEmpty()) { + HighlightedText( + text = excerpt, + searchQuery = highlight, + style = MaterialTheme.typography.bodyMedium, + color = muted, + maxLines = excerptLines, + ) + } + if (meta != NoteCardMeta()) NoteMetaLine(meta, muted) + } + } +} + +/** The container of a card without a note colour: raised when it is the note open beside the list. */ +@Composable +private fun untintedCardColor(current: Boolean): Color = + if (current) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.surfaceContainer + +/** The edge of a card: the `primary` ring of a checked one, else the `outline` edge of the open one. */ +@Composable +private fun cardBorder(selected: Boolean, current: Boolean): BorderStroke? { + val colors = MaterialTheme.colorScheme + return when { + selected -> BorderStroke(EnclyTheme.spacing.stroke, colors.primary) + current -> BorderStroke(EnclyTheme.spacing.stroke, colors.outline) + else -> null + } +} + +/** What a card's title row marks besides the title. */ +private class TitleState(val dimmed: Boolean, val pinned: Boolean, val selected: Boolean, val muted: Color) + +/** The title, then the pin of a pinned note and the check of a selected one. */ +@Composable +private fun NoteTitleRow(title: String, highlight: String, state: TitleState) { + val colors = MaterialTheme.colorScheme + Row(verticalAlignment = Alignment.CenterVertically) { + HighlightedText( + text = title, + searchQuery = highlight, + style = MaterialTheme.typography.titleMedium, + color = if (state.dimmed) colors.onSurface.copy(alpha = DIMMED_CARD_ALPHA) else colors.onSurface, + maxLines = 2, + modifier = Modifier.weight(1f), + ) + if (state.pinned) { + Icon( + EnclyIcons.Pin, + contentDescription = stringResource(Res.string.notes_section_pinned), + tint = state.muted, + modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + ) + } + if (state.selected) { + Icon( + EnclyIcons.CheckCircle, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + ) + } + } +} + +@Composable +private fun NoteMetaLine(meta: NoteCardMeta, muted: Color) { + val style = EnclyTheme.typography.meta + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), + modifier = Modifier + .fillMaxWidth() + .padding(top = EnclyTheme.spacing.xxs), + ) { + if (meta.tags.isNotEmpty()) CardTags(meta.tags, muted) + meta.date?.let { Text(text = it, style = style, color = muted, maxLines = 1) } + Spacer(modifier = Modifier.weight(1f)) + meta.progress?.let { + MetaItem( + icon = EnclyIcons.Checklist, + text = it, + color = MaterialTheme.colorScheme.primary, + weight = FontWeight.SemiBold, + ) + } + } +} + +/** The first [CARD_TAGS_SHOWN] tags by name, then how many more there are ("+2", read as "2 more tags"). */ +@Composable +private fun CardTags(tags: List, color: Color) { + val more = tags.size - CARD_TAGS_SHOWN + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + ) { + MetaItem(icon = EnclyIcons.Tag, text = tags.take(CARD_TAGS_SHOWN).joinToString(", "), color = color) + if (more > 0) { + val description = pluralStringResource(Res.plurals.note_tags_more, more, more) + Text( + text = "+$more", + style = EnclyTheme.typography.meta, + color = color, + maxLines = 1, + modifier = Modifier.semantics { contentDescription = description }, + ) + } + } +} + +@Composable +private fun MetaItem(icon: ImageVector, text: String, color: Color, weight: FontWeight? = null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + ) { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(MetaIcon)) + Text( + text = text, + style = EnclyTheme.typography.meta.copy(fontWeight = weight ?: EnclyTheme.typography.meta.fontWeight), + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The left edge of a coloured note card: a [width]-wide strip of [color] the full height of the + * card. The card's own shape clips it, so its ends follow the rounded corners. + */ +private fun Modifier.accentEdge(color: Color, width: Dp): Modifier = drawBehind { + drawRect(color = color, size = Size(width.toPx(), size.height)) +} + +/** A small tag label (e.g. on the Appearance preview): `primaryContainer`, radius 12. */ +@Composable +fun EnclyTagPill(text: String, modifier: Modifier = Modifier) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = modifier, + ) { + Text( + text = text, + style = EnclyTheme.typography.meta.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 10.dp, vertical = EnclyTheme.spacing.textGap), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Pin.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Pin.kt new file mode 100644 index 00000000..421b3618 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Pin.kt @@ -0,0 +1,236 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.pin_delete_digit +import com.pasich.encly.core.resources.pin_digits_count +import com.pasich.encly.core.resources.pin_unlock_with_fingerprint +import com.pasich.encly.core.security.MAX_PIN_LENGTH +import com.pasich.encly.core.security.MIN_PIN_LENGTH +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +/** Keys above the bottom row, row by row. */ +private const val KEYPAD_DIGITS = "123456789" +private const val KEYPAD_COLUMNS = 3 + +/** A wrong PIN shakes the dots three times, 8 dp each way, in 300 ms. */ +private const val SHAKE_CYCLES = 3 +private const val SHAKE_DP = 8f +private const val SHAKE_QUARTER_MS = 25 + +/** + * The PIN progress dots: filled `primary` for entered digits, a 2 dp `outline` ring otherwise. A PIN is + * [MIN_PIN_LENGTH] to [MAX_PIN_LENGTH] digits long and the screen never says which: the row starts with + * the shortest PIN's rings and grows by one ring with every digit past them, so the dots show what has + * been typed and nothing about what is expected. [error] turns every dot `error`; each new [shakeKey] + * above zero shakes them once. + */ +@Composable +fun PinDots(entered: Int, modifier: Modifier = Modifier, error: Boolean = false, shakeKey: Int = 0) { + val colors = MaterialTheme.colorScheme + val length = entered.coerceIn(MIN_PIN_LENGTH, MAX_PIN_LENGTH) + val description = stringResource(Res.string.pin_digits_count, entered) + val shake = remember { Animatable(0f) } + LaunchedEffect(shakeKey) { + if (shakeKey == 0) return@LaunchedEffect + repeat(SHAKE_CYCLES) { + shake.animateTo(SHAKE_DP, tween(SHAKE_QUARTER_MS)) + shake.animateTo(-SHAKE_DP, tween(SHAKE_QUARTER_MS * 2)) + shake.animateTo(0f, tween(SHAKE_QUARTER_MS)) + } + } + LtrContent { + Row( + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.m, Alignment.CenterHorizontally), + modifier = modifier + .fillMaxWidth() + .offset { IntOffset(shake.value.dp.roundToPx(), 0) } + .semantics { contentDescription = description }, + ) { + repeat(length) { index -> + val filled = index < entered + val tint = if (error) colors.error else colors.primary + Box( + modifier = Modifier + .size(14.dp) + .border( + 2.dp, + if (filled) { + tint + } else if (error) { + colors.error + } else { + colors.outline + }, + CircleShape, + ), + ) { + if (filled) Surface(shape = CircleShape, color = tint, modifier = Modifier.size(14.dp)) {} + } + } + } + } +} + +/** Keypad sizes: onboarding setup (56 dp keys, 10 dp gap) and the lock screen (64 dp, 12 dp). */ +enum class KeypadSize(val keyHeight: Dp, val gap: Dp) { + SETUP(56.dp, 10.dp), + LOCK(64.dp, 12.dp), +} + +/** + * The PIN keypad: a 3-column grid of round `surfaceContainer` keys, bottom row + * [biometric] [0] [backspace]. [onBiometric] null leaves the bottom-left slot empty. A long press on + * backspace clears every digit ([onClear]; by default delete repeated for a whole PIN) with a haptic tick. + */ +@Composable +fun PinKeypad( + onDigit: (Int) -> Unit, + onBackspace: () -> Unit, + modifier: Modifier = Modifier, + size: KeypadSize = KeypadSize.SETUP, + enabled: Boolean = true, + onBiometric: (() -> Unit)? = null, + onClear: (() -> Unit)? = null, +) { + val haptic = LocalHapticFeedback.current + LtrContent { + Column( + verticalArrangement = Arrangement.spacedBy(size.gap), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 40.dp), + ) { + KEYPAD_DIGITS.chunked(KEYPAD_COLUMNS).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(size.gap)) { + row.map { it.digitToInt() }.forEach { digit -> + DigitKey(digit, size, enabled, { onDigit(digit) }, Modifier.weight(1f)) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(size.gap)) { + if (onBiometric != null) { + Key( + onClick = onBiometric, + size = size, + enabled = enabled, + container = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = EnclyIcons.Fingerprint, + contentDescription = stringResource(Res.string.pin_unlock_with_fingerprint), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(28.dp), + ) + } + } else { + Box(modifier = Modifier.weight(1f)) + } + DigitKey(0, size, enabled, { onDigit(0) }, Modifier.weight(1f)) + Key( + onClick = onBackspace, + size = size, + enabled = enabled, + container = Color.Transparent, + modifier = Modifier.weight(1f), + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + // No clear handler: delete once per digit a PIN can hold. + onClear?.invoke() ?: repeat(MAX_PIN_LENGTH) { onBackspace() } + }, + ) { + Icon( + imageVector = EnclyIcons.Backspace, + contentDescription = stringResource(Res.string.pin_delete_digit), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(26.dp), + ) + } + } + } + } +} + +@Composable +private fun DigitKey( + digit: Int, + size: KeypadSize, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Key( + onClick = onClick, + size = size, + enabled = enabled, + container = MaterialTheme.colorScheme.surfaceContainer, + modifier = modifier, + ) { + Text(text = digit.toString(), style = EnclyTheme.typography.keypadDigit) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Key( + onClick: () -> Unit, + size: KeypadSize, + enabled: Boolean, + container: Color, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val color = if (enabled) container else container.copy(alpha = container.alpha * DISABLED_CONTENT_ALPHA) + val contentColor = MaterialTheme.colorScheme.onSurface.let { if (enabled) it else it.disabled() } + Surface( + shape = CircleShape, + color = color, + contentColor = contentColor, + modifier = modifier.height(size.keyHeight), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.enclyInteractive(CircleShape, enabled).combinedClickable( + enabled = enabled, + role = Role.Button, + onClick = onClick, + onLongClick = onLongClick, + ), + ) { content() } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.kt new file mode 100644 index 00000000..f1b01af6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.kt @@ -0,0 +1,4 @@ +package com.pasich.encly.presentation.designsystem + +/** Whether the command key of shortcuts is Meta (⌘, on macOS) rather than Ctrl (everywhere else). */ +internal expect fun platformUsesCommandKey(): Boolean diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/PointerInteraction.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/PointerInteraction.kt new file mode 100644 index 00000000..a1c08ef5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/PointerInteraction.kt @@ -0,0 +1,191 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusEventModifierNode +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.isCtrlPressed +import androidx.compose.ui.input.pointer.isMetaPressed +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +private val FocusRingWidth = 2.dp +private val FocusRingInset = 1.dp + +/** + * What a desktop pointer and keyboard expect of anything that can be pressed, to go before its + * `clickable` / `selectable` / `toggleable` (or on a Material button): a hand cursor while the + * pointer is over it, and a focus ring while it holds keyboard focus. The hover and pressed state + * layers are the Material ripple's, so this adds none of its own. Neither effect shows on touch: + * a finger has no pointer, and the ring is drawn only for focus that did not come from a press, so + * after Tab or the arrows, never after a tap or a mouse click. A disabled target ([enabled] false) keeps the + * arrow and gets no ring. [shape] is the ring's outline, so pass the target's own shape. + * + * The ring is 2 dp, drawn just inside the bounds in the content colour at the target, which is + * the colour that contrasts with the target's fill (the label colour of a button, `onSurface` of + * a card). + */ +fun Modifier.enclyInteractive(shape: Shape = RectangleShape, enabled: Boolean = true): Modifier = if (enabled) { + this + .pointerHoverIcon(PointerIcon.Hand) + .then(KeyboardFocusRingElement(shape)) +} else { + this +} + +/** [enclyInteractive] for a fully round target (a pill button, an icon button, a chip). */ +fun Modifier.enclyInteractiveRound(enabled: Boolean = true): Modifier = enclyInteractive(CircleShape, enabled) + +/** + * Calls [onClick] when the middle mouse button is pressed and let go over this: a browser's way to close a tab. + * The press is used up here, so nothing under it starts a click. A finger and the other buttons do nothing. + */ +@Composable +fun Modifier.enclyMiddleClick(onClick: () -> Unit): Modifier { + val current by rememberUpdatedState(onClick) + return pointerInput(Unit) { + val bounds = size + awaitPointerEventScope { + while (true) { + if (awaitClickOf(bounds) { it.buttons.isTertiaryPressed }) current() + } + } + } +} + +/** + * Calls [onClick] instead of the click below when the primary mouse button is pressed with the command key held + * (⌘ on a Mac, Ctrl elsewhere, as [LocalEnclyShortcuts] says) and let go over this: the "open in a new tab" click + * of a browser. An ordinary click, a touch and the other buttons pass through to the click below. + */ +@Composable +fun Modifier.enclyCommandClick(onClick: () -> Unit): Modifier { + val current by rememberUpdatedState(onClick) + val meta = LocalEnclyShortcuts.current.usesMetaKey + return pointerInput(meta) { + val bounds = size + awaitPointerEventScope { + while (true) { + val clicked = awaitClickOf(bounds) { press -> + val modifiers = press.keyboardModifiers + press.buttons.isPrimaryPressed && if (meta) modifiers.isMetaPressed else modifiers.isCtrlPressed + } + if (clicked) current() + } + } + } +} + +/** + * Waits for a press that [accepts] and uses it up, then for the release that ends it. True when the pointer was + * let go inside [bounds]; any other press is passed over without being used. + */ +private suspend fun AwaitPointerEventScope.awaitClickOf(bounds: IntSize, accepts: (PointerEvent) -> Boolean): Boolean { + var press: PointerInputChange? + do { + press = awaitPointerEvent(PointerEventPass.Initial).acceptedPress(accepts) + } while (press == null) + press.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val up = event.changes.firstOrNull() + if (up != null && event.type == PointerEventType.Release) { + up.consume() + return up.position.x in 0f..bounds.width.toFloat() && up.position.y in 0f..bounds.height.toFloat() + } + } +} + +/** The change of a press that [accepts], or null for any other event. */ +private fun PointerEvent.acceptedPress(accepts: (PointerEvent) -> Boolean): PointerInputChange? { + val change = changes.firstOrNull() ?: return null + return change.takeIf { type == PointerEventType.Press && it.changedToDown() && accepts(this) } +} + +private data class KeyboardFocusRingElement(val shape: Shape) : ModifierNodeElement() { + override fun create() = KeyboardFocusRingNode(shape) + + override fun update(node: KeyboardFocusRingNode) { + node.shape = shape + node.invalidateDraw() + } + + override fun InspectorInfo.inspectableProperties() { + name = "keyboardFocusRing" + properties["shape"] = shape + } +} + +private class KeyboardFocusRingNode(var shape: Shape) : + Modifier.Node(), + FocusEventModifierNode, + DrawModifierNode, + PointerInputModifierNode, + CompositionLocalConsumerModifierNode { + private var focused = false + + // Focus that a press gave (a click, a tap) is not keyboard focus: no ring until focus leaves and returns. + private var pressed = false + + override fun onFocusEvent(focusState: FocusState) { + if (focused != focusState.hasFocus) { + focused = focusState.hasFocus + if (!focused) pressed = false + invalidateDraw() + } + } + + override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) { + if (pass == PointerEventPass.Initial && pointerEvent.type == PointerEventType.Press && !pressed) { + pressed = true + invalidateDraw() + } + } + + override fun onCancelPointerInput() = Unit + + override fun sharePointerInputWithSiblings() = true + + override fun ContentDrawScope.draw() { + drawContent() + // Read while drawing, so a change of input mode redraws. + if (!focused || pressed || currentValueOf(LocalInputModeManager).inputMode != InputMode.Keyboard) return + val inset = FocusRingInset.toPx() + FocusRingWidth.toPx() / 2 + val ringSize = Size((size.width - 2 * inset).coerceAtLeast(0f), (size.height - 2 * inset).coerceAtLeast(0f)) + val outline = shape.createOutline(ringSize, layoutDirection, this) + translate(inset, inset) { + drawOutline(outline, currentValueOf(LocalContentColor), style = Stroke(FocusRingWidth.toPx())) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ProtectFromCapture.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ProtectFromCapture.kt new file mode 100644 index 00000000..1f15bfa1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ProtectFromCapture.kt @@ -0,0 +1,35 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.remember +import com.pasich.encly.core.security.ScreenshotAllowance +import com.pasich.encly.platform.LocalScreenshotAllowance + +/** + * Keeps the window protected from screenshots and recordings while this is in the composition, even when the + * user allowed screenshots until the next lock: the lock screen, PIN entry and the recovery words call it. + * + * The hold starts as the composition is built, not after it: a dialog made in the same pass reads the window's + * flag when it is created. Without an allowance (a host that never allows screenshots) it does nothing. + */ +@Composable +fun ProtectFromCapture() { + val allowance = LocalScreenshotAllowance.current + remember(allowance) { CaptureHold(allowance) } +} + +private class CaptureHold(allowance: ScreenshotAllowance?) : RememberObserver { + private var release: (() -> Unit)? = allowance?.protect() + + override fun onRemembered() = Unit + + override fun onForgotten() = end() + + override fun onAbandoned() = end() + + private fun end() { + release?.invoke() + release = null + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt index bdf1a669..3aea7382 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt @@ -34,10 +34,8 @@ 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 androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion @@ -48,13 +46,18 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.recovery_phrase_checksum +import com.pasich.encly.core.resources.recovery_word_cell +import com.pasich.encly.core.resources.recovery_word_unknown import com.pasich.encly.core.security.RecoveryWords import com.pasich.encly.core.security.SensitiveDataCleaner -import com.pasich.encly.presentation.components.SensitiveClip +import com.pasich.encly.platform.LocalSensitiveClipboard import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource /** How long the keyboard takes to slide in; the focused cell is brought into view again after it. */ private const val IME_SETTLE_MS = 350L @@ -186,6 +189,7 @@ fun RecoveryPhraseInput( onEdit: () -> Unit = {}, onDone: () -> Unit = {}, ) { + ProtectFromCapture() val requesters = remember(state.count) { List(state.count) { FocusRequester() } } val currentOnEdit by rememberUpdatedState(onEdit) val currentOnDone by rememberUpdatedState(onDone) @@ -206,8 +210,8 @@ fun RecoveryPhraseInput( measurer.measure(it, style, softWrap = false).size.width } } - val message = error ?: state.firstInvalid?.let { stringResource(R.string.recovery_word_unknown, it + 1) } - ?: stringResource(R.string.recovery_phrase_checksum).takeIf { state.checksumFailed } + val message = error ?: state.firstInvalid?.let { stringResource(Res.string.recovery_word_unknown, it + 1) } + ?: stringResource(Res.string.recovery_phrase_checksum).takeIf { state.checksumFailed } Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { WordGridLayout(longestWordPx = longest) { @@ -290,10 +294,10 @@ private fun PhraseField( ) { val colors = MaterialTheme.colorScheme val focusManager = LocalFocusManager.current - val clipboard = LocalClipboard.current + val clipboard = LocalSensitiveClipboard.current val focused by interactionSource.collectIsFocusedAsState() val word = state.words[index] - val description = stringResource(R.string.recovery_word_cell, index + 1, state.count) + val description = stringResource(Res.string.recovery_word_cell, index + 1, state.count) // Holds the cursor; the word itself lives in [state], which pastes into other cells also change. var field by remember { mutableStateOf(TextFieldValue(word, TextRange(word.length))) } // Moved here by Next, space or backspace: type on at the end of the word. @@ -302,7 +306,7 @@ private fun PhraseField( value = if (field.text == word) field else TextFieldValue(word, TextRange(word.length)), onValueChange = { value -> // A pasted phrase does not stay on the clipboard once it is in the cells. - if (value.text.trim().contains(WHITESPACE)) SensitiveClip.clear(clipboard.nativeClipboard) + if (value.text.trim().contains(WHITESPACE)) clipboard.clear() val target = state.onValueChange(index, value.text) val cleaned = state.words[index] field = if (cleaned == value.text) value else TextFieldValue(cleaned, TextRange(cleaned.length)) @@ -313,6 +317,8 @@ private fun PhraseField( singleLine = true, textStyle = EnclyTheme.typography.dataLarge.copy( color = if (enabled) colors.onSurface else colors.onSurface.disabled(), + // The words are English: the caret starts on the left in a right-to-left layout too. + textDirection = TextDirection.Ltr, ), cursorBrush = SolidColor(colors.primary), keyboardOptions = phraseKeyboard(last = index == state.count - 1), diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Rows.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Rows.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Rows.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Rows.kt index 5a355c4b..bc42ee30 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Rows.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Rows.kt @@ -108,7 +108,7 @@ fun EnclySwitchRow( supporting = supporting, icon = icon, enabled = enabled, - modifier = modifier.toggleable( + modifier = modifier.enclyInteractive(MaterialTheme.shapes.medium, enabled).toggleable( value = checked, enabled = enabled, role = Role.Switch, @@ -133,7 +133,9 @@ fun EnclyNavigationRow( supporting = supporting, icon = icon, enabled = true, - modifier = modifier.clickable(role = Role.Button, onClick = onClick), + modifier = modifier.enclyInteractive( + MaterialTheme.shapes.medium, + ).clickable(role = Role.Button, onClick = onClick), ) { Icon( imageVector = EnclyIcons.Chevron, @@ -167,7 +169,9 @@ fun EnclyListRow( modifier = if (onClick != null ) { - Modifier.clickable(role = Role.Button, onClick = onClick).then(modifier) + Modifier.enclyInteractive( + MaterialTheme.shapes.medium, + ).clickable(role = Role.Button, onClick = onClick).then(modifier) } else { modifier }, @@ -204,6 +208,7 @@ fun EnclyRadioRow( modifier = modifier .fillMaxWidth() .heightIn(min = 60.dp) + .enclyInteractive(MaterialTheme.shapes.medium) .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) .padding(horizontal = EnclyTheme.spacing.m, vertical = EnclyTheme.spacing.xs), ) { @@ -295,6 +300,7 @@ private fun SegmentButton( modifier = modifier .heightIn(min = EnclyTheme.spacing.textButtonHeight) .clip(CircleShape) + .enclyInteractiveRound(enabled) .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onClick), ) { Surface(shape = CircleShape, color = container, contentColor = content, modifier = Modifier.fillMaxWidth()) { diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.kt new file mode 100644 index 00000000..4cd4ede0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.kt @@ -0,0 +1,79 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/* + * A visible, theme-styled scrollbar for the desktop, where a mouse wheel is not the only way to scroll and + * a list should show how long it is. Put it in the Box that holds the scrolling content, after it, so it is + * drawn on top at the end edge: + * + * Box { + * LazyColumn(state = state) { ... } + * EnclyScrollbar(state) + * } + * + * It shows only while the content does not fit, takes drag and a click on the track, and its thumb brightens + * under the pointer. On Android and iOS touch scrolling is the way and these draw nothing. + */ + +/** The scrollbar of a [LazyListState] list (the notes list, settings, a sheet). */ +@Composable +expect fun BoxScope.EnclyScrollbar(state: LazyListState, modifier: Modifier = Modifier) + +/** The scrollbar of a [ScrollState] column (a dialog, a form). */ +@Composable +expect fun BoxScope.EnclyScrollbar(state: ScrollState, modifier: Modifier = Modifier) + +/** + * The scrollbar of a [LazyStaggeredGridState] grid (the notes grid). A staggered grid has no known length, + * so the thumb's size and place are estimated from the cells in view and are refined as the user scrolls. + */ +@Composable +expect fun BoxScope.EnclyScrollbar(state: LazyStaggeredGridState, modifier: Modifier = Modifier) + +/** + * The scrollbar of a [ScrollState] row that scrolls sideways (the strip of note tabs), along the bottom edge. It + * shows only while the row does not fit, like the vertical ones. + */ +@Composable +expect fun BoxScope.EnclyHorizontalScrollbar(state: ScrollState, modifier: Modifier = Modifier) + +/** + * A column that scrolls when its content does not fit, with the desktop scrollbar at its end edge (nothing is + * drawn for it on Android). [modifier] sizes and places the whole thing; [contentPadding] is inside the + * scrolling area, so the content scrolls under the page's gutters, as `Modifier.padding` after `verticalScroll`. + */ +@Composable +fun EnclyScrollColumn( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + contentPadding: PaddingValues = PaddingValues(), + content: @Composable ColumnScope.() -> Unit, +) { + val scroll = rememberScrollState() + Box(modifier = modifier) { + Column( + verticalArrangement = verticalArrangement, + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(contentPadding), + content = content, + ) + EnclyScrollbar(scroll) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Sheets.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Sheets.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Sheets.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Sheets.kt index 0a4dab07..980c8d77 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Sheets.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Sheets.kt @@ -33,14 +33,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.tap_again_to_delete import com.pasich.encly.ui.theme.EnclyTheme import com.pasich.encly.ui.theme.SheetShape import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource /** How long a destructive row waits for the confirming second tap. */ private const val CONFIRM_WINDOW_MS = 5_000L @@ -82,28 +83,32 @@ fun EnclyBottomSheet( // animation and cut the whole content away, leaving an empty sheet. modifier = modifier, ) { - Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier) - .padding( - start = EnclyTheme.spacing.gutter, - end = EnclyTheme.spacing.gutter, - top = if (showHandle) 0.dp else EnclyTheme.spacing.l, - bottom = EnclyTheme.spacing.l, - ), - ) { - if (title != null) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = EnclyTheme.spacing.xs), - ) + val scroll = rememberScrollState() + Box { + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .then(if (scrollable) Modifier.verticalScroll(scroll) else Modifier) + .padding( + start = EnclyTheme.spacing.gutter, + end = EnclyTheme.spacing.gutter, + top = if (showHandle) 0.dp else EnclyTheme.spacing.l, + bottom = EnclyTheme.spacing.l, + ), + ) { + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = EnclyTheme.spacing.xs), + ) + } + content() } - content() + if (scrollable) EnclyScrollbar(scroll) } } } @@ -154,6 +159,7 @@ fun EnclySheetRow( modifier = modifier .fillMaxWidth() .heightIn(min = EnclyTheme.spacing.rowHeight) + .enclyInteractive(MaterialTheme.shapes.medium, enabled) .clickable(enabled = enabled, role = Role.Button) { if (confirmFirst && !armed) { armed = true @@ -171,7 +177,7 @@ fun EnclySheetRow( } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap)) { Text( - text = if (armed) stringResource(R.string.tap_again_to_delete) else title, + text = if (armed) stringResource(Res.string.tap_again_to_delete) else title, style = MaterialTheme.typography.labelLarge, color = if (muted) colors.onSurfaceVariant else titleColor, maxLines = TITLE_MAX_LINES, diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt similarity index 98% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt index 95a1eb2e..1c819235 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt @@ -153,7 +153,9 @@ fun EnclySubtaskRow( .heightIn(min = spacing.minTouchTarget) .then( if (onClick != null) { - Modifier.clickable(onClickLabel = onClickLabel, onClick = onClick) + Modifier + .enclyInteractive(MaterialTheme.shapes.small) + .clickable(onClickLabel = onClickLabel, onClick = onClick) } else { Modifier }, @@ -217,6 +219,7 @@ fun EnclyAddSubtaskButton( modifier = Modifier .weight(1f) .heightIn(min = EnclyTheme.spacing.minTouchTarget) + .enclyInteractiveRound(enabled) .clickable(enabled = enabled, role = Role.Button, onClick = onClick) .semantics(mergeDescendants = true) { contentDescription = description }, ) { diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Surfaces.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Surfaces.kt similarity index 98% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Surfaces.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Surfaces.kt index 4016866f..fc102b6a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Surfaces.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Surfaces.kt @@ -58,7 +58,7 @@ fun EnclyCard( color = container, contentColor = colors.onSurface, border = border, - modifier = modifier.fillMaxWidth(), + modifier = modifier.enclyInteractive(MaterialTheme.shapes.medium).fillMaxWidth(), content = inner, ) } else { @@ -144,6 +144,7 @@ fun EnclyChip( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier + .enclyInteractive(CircleShape) .toggleable(value = selected, role = Role.Checkbox, onValueChange = { onClick() }) .heightIn(min = 36.dp) .padding(horizontal = 14.dp, vertical = EnclyTheme.spacing.xs), diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Tasks.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Tasks.kt index 2a730e89..8279612d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Tasks.kt @@ -98,6 +98,7 @@ fun EnclyCheckbox( } else { Modifier .minimumInteractiveComponentSize() + .enclyInteractive(shape, enabled) .toggleable(value = checked, enabled = enabled, role = Role.Checkbox, onValueChange = onCheckedChange) } Box(contentAlignment = Alignment.Center, modifier = modifier.then(toggle)) { @@ -171,7 +172,9 @@ fun EnclyTaskRow( ) .then( if (onClick != null) { - Modifier.clickable(enabled = enabled, onClickLabel = onClickLabel, onClick = onClick) + Modifier + .enclyInteractive(MaterialTheme.shapes.small, enabled) + .clickable(enabled = enabled, onClickLabel = onClickLabel, onClick = onClick) } else { Modifier }, @@ -264,7 +267,7 @@ fun EnclyExpandButton( ) val tint = MaterialTheme.colorScheme.onSurfaceVariant EnclyTooltip(contentDescription) { - IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + IconButton(onClick = onClick, enabled = enabled, modifier = modifier.enclyInteractiveRound(enabled)) { Icon( EnclyIcons.ChevronDown, contentDescription = contentDescription, @@ -288,7 +291,7 @@ fun EnclyRowIconButton( ) { val tint = MaterialTheme.colorScheme.onSurfaceVariant EnclyTooltip(contentDescription) { - IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + IconButton(onClick = onClick, enabled = enabled, modifier = modifier.enclyInteractiveRound(enabled)) { Icon( icon, contentDescription = contentDescription, diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt index bf019ff3..8d45d17c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt @@ -27,24 +27,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.field_correct +import com.pasich.encly.core.resources.search_notes import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource /** Placeholder text is the field's text colour at this opacity. */ const val PLACEHOLDER_ALPHA = 0.72f -/** - * The hint in an empty editor block ("Text", "List item", ...): much lighter than a field - * placeholder, so it never reads as typed text. It shows on the focused empty block only. - */ -const val EDITOR_PLACEHOLDER_ALPHA = 0.38f - /** Validation state of a [EnclyTextField]. */ sealed interface FieldState { data object Default : FieldState @@ -137,7 +133,7 @@ private fun ValidMark() { modifier = Modifier.size(18.dp), ) Text( - text = stringResource(R.string.field_correct), + text = stringResource(Res.string.field_correct), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) @@ -161,7 +157,7 @@ fun EnclySearchField( ) { val colors = MaterialTheme.colorScheme val keyboard = LocalSoftwareKeyboardController.current - val searchDescription = stringResource(R.string.search_notes) + val searchDescription = stringResource(Res.string.search_notes) Surface( shape = CircleShape, color = colors.surfaceContainer, diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Tooltip.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Tooltip.kt similarity index 98% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/Tooltip.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Tooltip.kt index def0b8be..90e78a71 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Tooltip.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Tooltip.kt @@ -56,7 +56,7 @@ fun IconWithTooltip( iconSize: Dp = Dp.Unspecified, ) { EnclyTooltip(label) { - IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + IconButton(onClick = onClick, enabled = enabled, modifier = modifier.enclyInteractiveRound(enabled)) { val iconModifier = if (iconSize.isSpecified) Modifier.size(iconSize) else Modifier if (tint == null) { Icon(icon, contentDescription = label, modifier = iconModifier) diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/TopBars.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TopBars.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/TopBars.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TopBars.kt index b9c1c422..8ae0a293 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/TopBars.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TopBars.kt @@ -27,21 +27,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.back +import com.pasich.encly.core.resources.close +import com.pasich.encly.core.resources.more_options +import com.pasich.encly.core.resources.onboarding_step_of import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource private const val PROGRESS_ANIMATION_MS = 250 /** The back icon button every bar uses. */ @Composable fun EnclyBackButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - val label = stringResource(R.string.back) + val label = stringResource(Res.string.back) EnclyTooltip(label) { - IconButton(onClick = onClick, modifier = modifier) { + IconButton(onClick = onClick, modifier = modifier.enclyInteractiveRound()) { Icon(EnclyIcons.Back, contentDescription = label, tint = MaterialTheme.colorScheme.onSurface) } } @@ -50,9 +54,9 @@ fun EnclyBackButton(onClick: () -> Unit, modifier: Modifier = Modifier) { /** A close (×) icon button: leaves a mode (a selection) rather than the screen. */ @Composable fun EnclyCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - val label = stringResource(R.string.close) + val label = stringResource(Res.string.close) EnclyTooltip(label) { - IconButton(onClick = onClick, modifier = modifier) { + IconButton(onClick = onClick, modifier = modifier.enclyInteractiveRound()) { Icon(EnclyIcons.Close, contentDescription = label, tint = MaterialTheme.colorScheme.onSurface) } } @@ -70,9 +74,9 @@ fun EnclyOverflowMenu(items: List, modifier: Modifier = Modifier if (items.isEmpty()) return var expanded by remember { mutableStateOf(false) } Box(modifier = modifier) { - val label = stringResource(R.string.more_options) + val label = stringResource(Res.string.more_options) EnclyTooltip(label) { - IconButton(onClick = { expanded = true }) { + IconButton(onClick = { expanded = true }, modifier = Modifier.enclyInteractiveRound()) { Icon(EnclyIcons.More, contentDescription = label, tint = MaterialTheme.colorScheme.onSurface) } } @@ -100,6 +104,7 @@ fun EnclyOverflowMenu(items: List, modifier: Modifier = Modifier expanded = false item.onClick() }, + modifier = Modifier.enclyInteractive(), ) } } @@ -174,7 +179,7 @@ fun EnclyProgressHeader(step: Int, totalSteps: Int, modifier: Modifier = Modifie } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( - text = stringResource(R.string.onboarding_step_of, step, totalSteps).uppercase(), + text = stringResource(Res.string.onboarding_step_of, step, totalSteps).uppercase(), style = EnclyTheme.typography.stepLabel, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/WordGrid.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/WordGrid.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt index 54439a35..1ba5edd6 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/WordGrid.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.pasich.encly.ui.theme.EnclyTheme @@ -34,6 +35,7 @@ private const val HIDDEN_WORD = "••••••" */ @Composable fun WordGrid(words: List, hidden: Boolean, modifier: Modifier = Modifier) { + ProtectFromCapture() val style = EnclyTheme.typography.dataLarge // No layout cache: the measured strings are recovery words. val measurer = rememberTextMeasurer(cacheSize = 0) @@ -53,6 +55,19 @@ fun WordGrid(words: List, hidden: Boolean, modifier: Modifier = Modifier @Composable internal fun WordGridLayout(longestWordPx: Int, modifier: Modifier = Modifier, content: @Composable () -> Unit) { val gap = EnclyTheme.spacing.xs + // The words are English (BIP-39): the grid and the numbers stay left to right in any layout. + LtrContent { + WordGridLayoutBody(longestWordPx, gap, modifier, content) + } +} + +@Composable +private fun WordGridLayoutBody( + longestWordPx: Int, + gap: Dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { Layout(modifier = modifier, content = content) { measurables, constraints -> val gapPx = gap.roundToPx() val chromePx = WordCellChrome.roundToPx() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/di/EditorModule.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/di/EditorModule.kt new file mode 100644 index 00000000..1565a406 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/di/EditorModule.kt @@ -0,0 +1,21 @@ +package com.pasich.encly.presentation.di + +import com.pasich.encly.core.common.AppStrings +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.note_copy_title +import com.pasich.encly.core.resources.untitled +import com.pasich.encly.presentation.editor.persistence.NoteCopyTitle +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides + +@ContributesTo(AppScope::class) +@BindingContainer +object EditorModule { + + @Provides + fun provideNoteCopyTitle(strings: AppStrings): NoteCopyTitle = NoteCopyTitle { title -> + strings.get(Res.string.note_copy_title, title.ifBlank { strings.get(Res.string.untitled) }) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/AdaptiveSheet.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/AdaptiveSheet.kt new file mode 100644 index 00000000..1abe1a01 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/AdaptiveSheet.kt @@ -0,0 +1,137 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.pasich.encly.presentation.designsystem.EnclyBottomSheet +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.ui.theme.LocalWidthClass +import kotlinx.coroutines.launch + +/** The test tag of the dialog that stands in for a bottom sheet in a wide window. */ +internal const val SHEET_DIALOG_TAG = "adaptive-sheet-dialog" + +/** The share of the window's height a dialog may take before its content scrolls. */ +private const val DIALOG_MAX_HEIGHT_FRACTION = 0.85f + +/** + * What the design system calls a bottom sheet, placed the way Material guides each window size: a bottom + * sheet in a compact window (exactly [EnclyBottomSheet]), a centred dialog of the same content in a medium or + * expanded one, where a sheet would stretch across the whole width. [sheetState], [showHandle] and + * [scrollable] only matter to the sheet; the dialog always scrolls what does not fit. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdaptiveSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + title: String? = null, + showHandle: Boolean = true, + scrollable: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + if (LocalWidthClass.current.isWide) { + SheetDialog(onDismissRequest = onDismissRequest, modifier = modifier, title = title, content = content) + } else { + EnclyBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + sheetState = sheetState, + title = title, + showHandle = showHandle, + scrollable = scrollable, + content = content, + ) + } +} + +@Composable +private fun SheetDialog( + onDismissRequest: () -> Unit, + title: String?, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest, properties = DialogProperties(usePlatformDefaultWidth = false)) { + BoxWithConstraints( + modifier = modifier.fillMaxWidth().padding(EnclyTheme.spacing.m), + contentAlignment = Alignment.Center, + ) { + val maxContentHeight = maxHeight * DIALOG_MAX_HEIGHT_FRACTION + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + modifier = Modifier + .testTag(SHEET_DIALOG_TAG) + .widthIn(max = EnclyTheme.spacing.dialogWidth) + .fillMaxWidth() + .heightIn(max = maxContentHeight), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(EnclyTheme.spacing.gutter), + ) { + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = EnclyTheme.spacing.xs), + ) + } + content() + } + } + } + } +} + +/** + * Closes what [AdaptiveSheet] shows, then runs [onDismiss]: a bottom sheet slides away first, a dialog has + * nothing to slide, so it goes at once. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun rememberSheetDismisser(sheetState: SheetState, onDismiss: () -> Unit): () -> Unit { + val wide = LocalWidthClass.current.isWide + val scope = rememberCoroutineScope() + val currentOnDismissed by rememberUpdatedState(onDismiss) + return remember(sheetState, wide, scope) { + { + if (wide) { + currentOnDismissed() + } else { + scope.launch { sheetState.hide() }.invokeOnCompletion { currentOnDismissed() } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/ConfirmDialog.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/ConfirmDialog.kt new file mode 100644 index 00000000..8d98ac34 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/ConfirmDialog.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.runtime.Composable +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.dialog_confirm +import com.pasich.encly.core.resources.dialog_dismiss +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import org.jetbrains.compose.resources.stringResource + +/** A yes/no confirmation; [destructive] shows the confirm action in `error`. */ +@Composable +fun ConfirmDialog( + isVisible: Boolean, + titleText: String, + messageText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + destructive: Boolean = false, +) { + if (isVisible) { + EnclyDialog( + title = titleText, + text = messageText, + onDismissRequest = onDismiss, + confirm = DialogAction(stringResource(Res.string.dialog_confirm), onConfirm, destructive = destructive), + dismiss = DialogAction(stringResource(Res.string.dialog_dismiss), onDismiss), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt new file mode 100644 index 00000000..e899f7f5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt @@ -0,0 +1,188 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.delete +import com.pasich.encly.core.resources.duplicate +import com.pasich.encly.core.resources.find_in_note +import com.pasich.encly.core.resources.markdown_copy +import com.pasich.encly.core.resources.markdown_export +import com.pasich.encly.core.resources.note_archive +import com.pasich.encly.core.resources.note_color_title +import com.pasich.encly.core.resources.note_discard_all_changes +import com.pasich.encly.core.resources.note_export_pdf +import com.pasich.encly.core.resources.note_print +import com.pasich.encly.core.resources.note_text_use_default +import com.pasich.encly.core.resources.note_unarchive +import com.pasich.encly.core.resources.select_all +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.model.NoteColor +import com.pasich.encly.presentation.components.FontSizeSlider +import com.pasich.encly.presentation.components.NoteLineSpacingSlider +import com.pasich.encly.presentation.designsystem.EnclyDesktopIcons +import com.pasich.encly.presentation.designsystem.EnclyGroupDivider +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyNoteColorPicker +import com.pasich.encly.presentation.designsystem.EnclySheetRow +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.designsystem.bleed +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +enum class EditNoteBottomSheetAction { + CLOSE_NO_SAVE, + DUPLICATE, + FIND, + SELECT_ALL, + TRASH, + EXPORT_MARKDOWN, + EXPORT_PDF, + PRINT, + COPY_MARKDOWN, + ARCHIVE, + UNARCHIVE, +} + +/** + * The editor's "More actions" sheet: text size, then archive, find, duplicate, discard changes and delete. The + * font itself is chosen app-wide in Settings, Appearance. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditNoteBottomSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + viewModel: EditNoteViewModel = metroViewModel(), + documents: Boolean = false, + onAction: (EditNoteBottomSheetAction) -> Unit = {}, +) { + val fontSize by viewModel.fontSize.collectAsStateWithLifecycle() + val lineSpacing by viewModel.noteLineSpacing.collectAsStateWithLifecycle() + val note by viewModel.state.collectAsStateWithLifecycle() + val override by viewModel.textOverride.collectAsStateWithLifecycle() + val loadFailed by viewModel.contentLoadFailed.collectAsStateWithLifecycle() + val simpleEdit by viewModel.simpleEdit.collectAsStateWithLifecycle() + + if (isVisible) { + AdaptiveSheet(onDismissRequest = onDismiss, modifier = modifier, scrollable = true) { + FontSizeSlider( + currentSize = fontSize, + onSizeChange = { newSize -> viewModel.updateFontSize(newSize) }, + ) + NoteLineSpacingSlider(selected = lineSpacing, onSelect = viewModel::updateNoteLineSpacing) + UseDefaultRow(visible = override.isSet, onClick = viewModel::useDefaultText) + EnclyGroupDivider() + SectionOverline(stringResource(Res.string.note_color_title)) + EnclyNoteColorPicker( + selected = NoteColor.fromKey(note.note.color), + onSelect = viewModel::updateColor, + modifier = Modifier.bleed(EnclyTheme.spacing.gutter), + contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter), + ) + ArchiveRow(note = note.note, onAction = onAction, onDismiss = onDismiss) + EditorActionRows( + rows = EditorRows(canCopy = !loadFailed, canSelect = !loadFailed && !simpleEdit, documents = documents), + onAction = onAction, + onDismiss = onDismiss, + ) + } + } +} + +/** Which of the optional rows of the sheet are offered. */ +private class EditorRows(val canCopy: Boolean, val canSelect: Boolean, val documents: Boolean) + +/** + * Find, copy, export, duplicate, discard and delete: each runs its action and closes the sheet. + * Copy as Markdown is hidden for a note whose content could not be read, like the export's no-op. + */ +@Composable +private fun EditorActionRows(rows: EditorRows, onAction: (EditNoteBottomSheetAction) -> Unit, onDismiss: () -> Unit) { + fun run(action: EditNoteBottomSheetAction) { + onAction(action) + onDismiss() + } + EnclySheetRow( + title = stringResource(Res.string.find_in_note), + icon = EnclyIcons.Search, + onClick = { run(EditNoteBottomSheetAction.FIND) }, + ) + if (rows.canSelect) { + EnclySheetRow( + title = stringResource(Res.string.select_all), + icon = EnclyIcons.Checklist, + onClick = { run(EditNoteBottomSheetAction.SELECT_ALL) }, + ) + } + EnclySheetRow( + title = stringResource(Res.string.markdown_export), + icon = EnclyIcons.Upload, + onClick = { run(EditNoteBottomSheetAction.EXPORT_MARKDOWN) }, + ) + if (rows.documents) { + EnclySheetRow( + title = stringResource(Res.string.note_export_pdf), + icon = EnclyIcons.Paper, + onClick = { run(EditNoteBottomSheetAction.EXPORT_PDF) }, + ) + EnclySheetRow( + title = stringResource(Res.string.note_print), + icon = EnclyDesktopIcons.Print, + onClick = { run(EditNoteBottomSheetAction.PRINT) }, + ) + } + if (rows.canCopy) { + EnclySheetRow( + title = stringResource(Res.string.markdown_copy), + icon = EnclyIcons.Copy, + onClick = { run(EditNoteBottomSheetAction.COPY_MARKDOWN) }, + ) + } + EnclySheetRow( + title = stringResource(Res.string.duplicate), + icon = EnclyIcons.Duplicate, + onClick = { run(EditNoteBottomSheetAction.DUPLICATE) }, + ) + EnclySheetRow( + title = stringResource(Res.string.note_discard_all_changes), + icon = EnclyIcons.Undo, + onClick = { run(EditNoteBottomSheetAction.CLOSE_NO_SAVE) }, + ) + EnclySheetRow( + title = stringResource(Res.string.delete), + icon = EnclyIcons.Trash, + destructive = true, + confirmFirst = true, + onClick = { run(EditNoteBottomSheetAction.TRASH) }, + ) +} + +/** Archive, or unarchive. Pinning is a list matter: it lives in the notes list's sheet only. */ +@Composable +private fun ArchiveRow(note: Note, onAction: (EditNoteBottomSheetAction) -> Unit, onDismiss: () -> Unit) { + EnclySheetRow( + title = stringResource(if (note.isArchived) Res.string.note_unarchive else Res.string.note_archive), + icon = if (note.isArchived) EnclyIcons.Unarchive else EnclyIcons.Archive, + onClick = { + onAction( + if (note.isArchived) EditNoteBottomSheetAction.UNARCHIVE else EditNoteBottomSheetAction.ARCHIVE, + ) + onDismiss() + }, + ) +} + +/** "Use default": shown while the note sets its own text size or line spacing. */ +@Composable +private fun UseDefaultRow(visible: Boolean, onClick: () -> Unit) { + if (!visible) return + EnclySheetRow(title = stringResource(Res.string.note_text_use_default), icon = EnclyIcons.Undo, onClick = onClick) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/LanguageDialog.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/LanguageDialog.kt new file mode 100644 index 00000000..2cbacf5e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/LanguageDialog.kt @@ -0,0 +1,59 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.done +import com.pasich.encly.core.resources.language_dialog_title +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import com.pasich.encly.presentation.designsystem.EnclyRadioRow +import com.pasich.encly.presentation.designsystem.EnclyScrollbar +import org.jetbrains.compose.resources.stringResource + +/** + * Picks the in-app language. Every language is listed under its own name; only + * "System default" is shown in the current UI language. + */ +@Composable +fun LanguageDialog( + current: AppLanguage, + onDismiss: () -> Unit, + onConfirm: (AppLanguage) -> Unit, + modifier: Modifier = Modifier, +) { + var selected by rememberSaveable { mutableStateOf(current) } + + EnclyDialog( + title = stringResource(Res.string.language_dialog_title), + onDismissRequest = onDismiss, + modifier = modifier, + confirm = DialogAction(stringResource(Res.string.done), { onConfirm(selected) }), + dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), + ) { + val listState = rememberLazyListState() + Box { + LazyColumn(state = listState, modifier = Modifier.selectableGroup()) { + items(AppLanguage.entries) { language -> + EnclyRadioRow( + title = stringResource(language.nativeName), + selected = language == selected, + onClick = { selected = language }, + ) + } + } + EnclyScrollbar(listState) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt new file mode 100644 index 00000000..65cab704 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt @@ -0,0 +1,287 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.animation.AnimatedVisibility +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.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.layout.layout +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.delete +import com.pasich.encly.core.resources.duplicate +import com.pasich.encly.core.resources.edit +import com.pasich.encly.core.resources.no_tag +import com.pasich.encly.core.resources.note_archive +import com.pasich.encly.core.resources.note_color_title +import com.pasich.encly.core.resources.note_description_placeholder +import com.pasich.encly.core.resources.note_pin +import com.pasich.encly.core.resources.note_unarchive +import com.pasich.encly.core.resources.note_unpin +import com.pasich.encly.core.resources.save +import com.pasich.encly.core.resources.untitled +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.domain.model.NoteColor +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyChip +import com.pasich.encly.presentation.designsystem.EnclyGroupDivider +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyNoteColorPicker +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.designsystem.SectionOverline +import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.utils.formatNoteDate +import com.pasich.encly.utils.noteInstant +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +sealed class NoteAction { + object Edit : NoteAction() + object Duplicate : NoteAction() + object Delete : NoteAction() + data class Pin(val pinned: Boolean) : NoteAction() + data class Archive(val archived: Boolean) : NoteAction() + data class ChangeColor(val color: NoteColor) : NoteAction() + + /** Puts the tag [tagId] on the note or takes it off ([tagged]); 0 takes every tag off. */ + data class ChangeTag(val tagId: Long, val tagged: Boolean = true) : NoteAction() + data class ChangeDescription(val description: String) : NoteAction() +} + +/** + * Long-press actions for a note. Every block sits on the sheet's gutter, so the title, the tag chips, + * the description and the rows share one left edge. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoteCardBottomSheet( + isVisible: Boolean, + item: NoteWithTags, + onAction: (NoteAction) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), +) { + // Per note and per opening: a dismissed sheet never reopens mid-edit. + var isEditingDescription by remember(item.note.id, isVisible) { mutableStateOf(false) } + + if (isVisible) { + AdaptiveSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + scrollable = true, + ) { + NoteActionHeader(item) + TagChips( + selectedTagIds = item.tags.mapTo(HashSet()) { it.id }, + onToggle = { tagId, tagged -> onAction(NoteAction.ChangeTag(tagId, tagged)) }, + ) + ColorChoices( + selected = NoteColor.fromKey(item.note.color), + onSelect = { onAction(NoteAction.ChangeColor(it)) }, + ) + EnclyGroupDivider() + DescriptionEditor( + initial = item.note.description, + editing = isEditingDescription, + onEditingChange = { isEditingDescription = it }, + onSave = { onAction(NoteAction.ChangeDescription(it)) }, + ) + AnimatedVisibility(!isEditingDescription) { NoteActionRows(item.note, onAction) } + } + } +} + +/** Edit, pin, archive, duplicate and delete; a pinned note offers Unpin, an archived one Unarchive and no pin. */ +@Composable +private fun NoteActionRows(note: Note, onAction: (NoteAction) -> Unit) { + Column { + EnclySheetRow( + title = stringResource(Res.string.edit), + icon = EnclyIcons.Edit, + onClick = { onAction(NoteAction.Edit) }, + ) + if (!note.isArchived) { + EnclySheetRow( + title = stringResource(if (note.isPinned) Res.string.note_unpin else Res.string.note_pin), + icon = if (note.isPinned) EnclyIcons.PinOff else EnclyIcons.Pin, + onClick = { onAction(NoteAction.Pin(!note.isPinned)) }, + ) + } + EnclySheetRow( + title = stringResource( + if (note.isArchived) Res.string.note_unarchive else Res.string.note_archive, + ), + icon = if (note.isArchived) EnclyIcons.Unarchive else EnclyIcons.Archive, + onClick = { onAction(NoteAction.Archive(!note.isArchived)) }, + ) + EnclySheetRow( + title = stringResource(Res.string.duplicate), + icon = EnclyIcons.Duplicate, + onClick = { onAction(NoteAction.Duplicate) }, + ) + EnclySheetRow( + title = stringResource(Res.string.delete), + icon = EnclyIcons.Trash, + destructive = true, + confirmFirst = true, + onClick = { onAction(NoteAction.Delete) }, + ) + } +} + +/** The note's colour: a heading and a row of swatches, bleeding into the sheet's gutters like the tag chips. */ +@Composable +private fun ColorChoices(selected: NoteColor, onSelect: (NoteColor) -> Unit) { + SectionOverline(stringResource(Res.string.note_color_title)) + EnclyNoteColorPicker( + selected = selected, + onSelect = onSelect, + modifier = Modifier.bleedHorizontally(EnclyTheme.spacing.gutter), + contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter), + ) +} + +/** Title and last edit: the sheet's heading. */ +@Composable +private fun NoteActionHeader(item: NoteWithTags) { + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = item.note.title.ifEmpty { stringResource(Res.string.untitled) }, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = formatNoteDate(noteInstant(item.note.date)), + style = EnclyTheme.typography.meta, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * "No tag" and every tag as chips that toggle: the selected ones are the note's tags, a tap puts a tag on or takes it + * off, and "No tag" takes every tag off. + */ +@Composable +private fun TagChips( + selectedTagIds: Set, + onToggle: (tagId: Long, tagged: Boolean) -> Unit, + tagListViewModel: TagListViewModel = metroViewModel(), +) { + val state by tagListViewModel.state.collectAsState() + val gutter = EnclyTheme.spacing.gutter + // The row bleeds into the sheet's gutters so chips scroll off the edge instead of being cut at + // the gutter; the content padding puts the first chip back on the shared left edge. + LazyRow( + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + contentPadding = PaddingValues(horizontal = gutter), + modifier = Modifier.bleedHorizontally(gutter), + ) { + item { + EnclyChip( + label = stringResource(Res.string.no_tag), + selected = selectedTagIds.isEmpty(), + onClick = { if (selectedTagIds.isNotEmpty()) onToggle(0L, false) }, + ) + } + items(state.listTags, key = { it.id }) { tag -> + val tagged = tag.id in selectedTagIds + EnclyChip(label = tag.nameTag, selected = tagged, onClick = { onToggle(tag.id, !tagged) }) + } + } +} + +/** The description as a row; tapping it turns it into a field with Cancel and Save. */ +@Composable +private fun DescriptionEditor( + initial: String, + editing: Boolean, + onEditingChange: (Boolean) -> Unit, + onSave: (String) -> Unit, +) { + if (!editing) { + EnclySheetRow( + title = initial.ifBlank { stringResource(Res.string.note_description_placeholder) }, + muted = initial.isBlank(), + icon = EnclyIcons.Comment, + onClick = { onEditingChange(true) }, + ) + return + } + var text by remember(initial) { mutableStateOf(initial) } + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + EnclyTextField( + value = text, + onValueChange = { text = it }, + label = stringResource(Res.string.note_description_placeholder), + fieldModifier = Modifier.focusRequester(focus), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = false, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + EnclyTextButton(text = stringResource(Res.string.cancel), onClick = { + onEditingChange(false) + }, muted = true) + EnclyButton( + text = stringResource(Res.string.save), + onClick = { + onSave(text.trim()) + onEditingChange(false) + }, + ) + } + } +} + +/** Widens the element by [bleed] on both sides, past its parent's padding. */ +private fun Modifier.bleedHorizontally(bleed: Dp) = layout { measurable, constraints -> + val extra = bleed.roundToPx() * 2 + val placeable = measurable.measure( + constraints.copy( + maxWidth = constraints.maxWidth + extra, + minWidth = + constraints.maxWidth + extra, + ), + ) + layout(constraints.maxWidth, placeable.height) { placeable.place(-extra / 2, 0) } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt similarity index 77% rename from app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt index 135d7fdd..8b1c232b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt @@ -5,17 +5,19 @@ import androidx.compose.material3.SheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.sort_by +import com.pasich.encly.core.resources.view_switch_to_grid +import com.pasich.encly.core.resources.view_switch_to_list import com.pasich.encly.domain.enums.NoteSortOption -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.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 +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource /** View options of the notes list: the sort order, and the switch between list and grid. */ @OptIn(ExperimentalMaterial3Api::class) @@ -26,15 +28,15 @@ fun NotesSortBottomSheet( isGrid: Boolean, onToggleView: () -> Unit, isBottomSheetVisible: Boolean = false, - noteListViewModel: NoteListViewModel = enclyViewModel(), + noteListViewModel: NoteListViewModel = metroViewModel(), ) { val state by noteListViewModel.state.collectAsState() if (isBottomSheetVisible) { - EnclyBottomSheet( + AdaptiveSheet( onDismissRequest = onDismiss, sheetState = sheetState, - title = stringResource(R.string.sort_by), + title = stringResource(Res.string.sort_by), ) { NoteSortOption.entries.forEach { option -> EnclySheetRow( @@ -48,7 +50,7 @@ fun NotesSortBottomSheet( } EnclyGroupDivider() EnclySheetRow( - title = stringResource(if (isGrid) R.string.view_switch_to_list else R.string.view_switch_to_grid), + title = stringResource(if (isGrid) Res.string.view_switch_to_list else Res.string.view_switch_to_grid), icon = if (isGrid) EnclyIcons.ListView else EnclyIcons.Grid, onClick = { onToggleView() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/RequestCleanCompleteTaskDialog.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/RequestCleanCompleteTaskDialog.kt new file mode 100644 index 00000000..57ec1684 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/RequestCleanCompleteTaskDialog.kt @@ -0,0 +1,31 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.runtime.Composable +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.clear +import com.pasich.encly.core.resources.task_clear_completed_message +import com.pasich.encly.core.resources.task_clear_completed_title +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import org.jetbrains.compose.resources.stringResource + +@Composable +fun RequestCleanCompleteTaskDialog(showDialog: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit) { + if (showDialog) { + EnclyDialog( + title = stringResource(Res.string.task_clear_completed_title), + text = stringResource(Res.string.task_clear_completed_message), + onDismissRequest = onDismiss, + confirm = DialogAction( + text = stringResource(Res.string.clear), + onClick = { + onConfirm() + onDismiss() + }, + destructive = true, + ), + dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/RequestDeleteTagsDIalog.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/RequestDeleteTagsDIalog.kt new file mode 100644 index 00000000..afbc605c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/RequestDeleteTagsDIalog.kt @@ -0,0 +1,31 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.runtime.Composable +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.delete +import com.pasich.encly.core.resources.delete_tag +import com.pasich.encly.core.resources.delete_tag_desrpt +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import org.jetbrains.compose.resources.stringResource + +@Composable +fun RequestDeleteTagsDialog(showDialog: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit) { + if (showDialog) { + EnclyDialog( + title = stringResource(Res.string.delete_tag), + text = stringResource(Res.string.delete_tag_desrpt), + onDismissRequest = onDismiss, + confirm = DialogAction( + text = stringResource(Res.string.delete), + onClick = { + onConfirm() + onDismiss() + }, + destructive = true, + ), + dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/SwipeActionDialog.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/SwipeActionDialog.kt new file mode 100644 index 00000000..d0806af9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/SwipeActionDialog.kt @@ -0,0 +1,60 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.done +import com.pasich.encly.core.resources.settings_swipe_left +import com.pasich.encly.core.resources.settings_swipe_right +import com.pasich.encly.domain.model.SwipeAction +import com.pasich.encly.domain.model.SwipeSide +import com.pasich.encly.presentation.components.swipe.choiceLabel +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import com.pasich.encly.presentation.designsystem.EnclyRadioRow +import org.jetbrains.compose.resources.stringResource + +/** Picks what swiping a note card to [side] does. */ +@Composable +fun SwipeActionDialog( + side: SwipeSide, + current: SwipeAction, + onDismiss: () -> Unit, + onConfirm: (SwipeAction) -> Unit, + modifier: Modifier = Modifier, +) { + var selected by rememberSaveable { mutableStateOf(current) } + + EnclyDialog( + title = stringResource( + if (side == + SwipeSide.RIGHT + ) { + Res.string.settings_swipe_right + } else { + Res.string.settings_swipe_left + }, + ), + onDismissRequest = onDismiss, + modifier = modifier, + confirm = DialogAction(stringResource(Res.string.done), { onConfirm(selected) }), + dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), + ) { + Column(modifier = Modifier.selectableGroup()) { + SwipeAction.entries.forEach { action -> + EnclyRadioRow( + title = stringResource(action.choiceLabel()), + selected = action == selected, + onClick = { selected = action }, + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/WhatsNewSheet.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/WhatsNewSheet.kt new file mode 100644 index 00000000..0f81e513 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/WhatsNewSheet.kt @@ -0,0 +1,52 @@ +package com.pasich.encly.presentation.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.whats_new_dismiss +import com.pasich.encly.core.resources.whats_new_title +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.whatsnew.ReleaseNotes +import org.jetbrains.compose.resources.stringResource + +/** + * The notes of a release ("What's new"): the version as the heading, the changes under it, and a button that + * closes the sheet. Shown once after an update, and again from Settings → About. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WhatsNewSheet(notes: ReleaseNotes, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + AdaptiveSheet( + onDismissRequest = onDismiss, + modifier = modifier, + title = stringResource(Res.string.whats_new_title), + scrollable = true, + ) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + Text( + text = notes.heading, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (notes.body.isNotEmpty()) { + Text( + text = notes.body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + EnclyButton( + text = stringResource(Res.string.whats_new_dismiss), + onClick = onDismiss, + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt new file mode 100644 index 00000000..d022415a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt @@ -0,0 +1,246 @@ +package com.pasich.encly.presentation.dialogs.tasks + +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.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +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.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.add +import com.pasich.encly.core.resources.priority_select_title +import com.pasich.encly.core.resources.save +import com.pasich.encly.core.resources.task_delete +import com.pasich.encly.core.resources.task_description_placeholder +import com.pasich.encly.core.resources.task_edit_placeholder +import com.pasich.encly.core.resources.task_new_placeholder +import com.pasich.encly.data.model.Task +import com.pasich.encly.presentation.components.tasks.PriorityValues +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyChip +import com.pasich.encly.presentation.designsystem.EnclyScrollbar +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTextField +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.dialogs.AdaptiveSheet +import com.pasich.encly.presentation.viewmodel.SubtaskDraft +import com.pasich.encly.presentation.viewmodel.TaskDraft +import com.pasich.encly.ui.theme.EnclyTheme +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource + +private const val TASK_TITLE_MAX_LENGTH = 100 +private const val TASK_DESCRIPTION_MAX_LENGTH = 150 +private const val INITIAL_FOCUS_DELAY_MS = 300L + +/** [subtasks] is the checklist to save, null while an edited task's one has not loaded. */ +private data class TaskEditorState( + val title: String, + val description: String, + val priority: Int, + val editTaskId: Long?, + val subtasks: List?, +) { + val isEditMode: Boolean get() = editTaskId != null +} + +private data class TaskEditorActions( + val onTitleChange: (String) -> Unit, + val onDescriptionChange: (String) -> Unit, + val onPrioritySelect: (Int) -> Unit, + val onSubmit: () -> Unit, + /** Null when there is nothing to delete (a new task). */ + val onDelete: (() -> Unit)? = null, +) + +/** Saves the sheet: title, description, priority and the checklist (null: leave it as stored). */ +private fun interface TaskSubmit { + fun submit(title: String, description: String?, priority: Int, subtasks: List?) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongParameterList") // Compose sheet API: independent state + callbacks from TasksScreen. +@Composable +fun AddTaskDialog( + onDismiss: () -> Unit, + sheetState: SheetState, + onAddTask: (title: String, description: String?, priority: Int, subtasks: List) -> Unit, + editTask: Task? = null, + onEditTask: ( + ( + taskId: Long, + title: String, + description: String?, + priority: Int, + subtasks: List?, + ) -> Unit + )? = null, + onBackgroundSave: (TaskDraft) -> Unit = {}, + onDeleteTask: ((Task) -> Unit)? = null, + editSubtasks: List? = emptyList(), + subtasks: SubtaskListState? = null, +) { + var title by remember { mutableStateOf(editTask?.title.orEmpty()) } + var description by remember { mutableStateOf(editTask?.description.orEmpty()) } + var selectedPriority by remember { mutableIntStateOf(editTask?.priority ?: 0) } + // The caller's checklist (TasksViewModel's, which outlives a rotation), or one of its own. + val ownChecklist = rememberSubtaskListState(editSubtasks) + val checklist = subtasks ?: ownChecklist + val titleFocusRequester = remember { FocusRequester() } + + val state = TaskEditorState( + title = title, + description = description, + priority = selectedPriority, + editTaskId = editTask?.id, + subtasks = checklist.toSave(), + ) + val submit = TaskSubmit { taskTitle, taskDescription, priority, checklist -> + val taskId = editTask?.id + if (taskId != null && onEditTask != null) { + onEditTask(taskId, taskTitle, taskDescription, priority, checklist) + } else { + onAddTask(taskTitle, taskDescription, priority, checklist.orEmpty()) + } + } + val actions = TaskEditorActions( + onTitleChange = { title = it.take(TASK_TITLE_MAX_LENGTH) }, + onDescriptionChange = { description = it.take(TASK_DESCRIPTION_MAX_LENGTH) }, + onPrioritySelect = { selectedPriority = it }, + onSubmit = { submitTask(state, submit) }, + onDelete = editTask?.let { task -> onDeleteTask?.let { delete -> { delete(task) } } }, + ) + + // Backgrounding re-locks the vault and drops this sheet; flush the draft first, like + // EditNote does. ON_PAUSE fires well before ProcessLifecycleOwner's delayed ON_STOP. + val currentDraft by rememberUpdatedState( + TaskDraft(title, description, selectedPriority, state.subtasks), + ) + val currentOnBackgroundSave by rememberUpdatedState(onBackgroundSave) + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE) currentOnBackgroundSave(currentDraft) + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + AdaptiveSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + TaskEditorContent(state, titleFocusRequester, actions, checklist) + } + + LaunchedEffect(Unit) { + delay(INITIAL_FOCUS_DELAY_MS) + titleFocusRequester.requestFocus() + } +} + +private fun submitTask(state: TaskEditorState, submit: TaskSubmit) { + if (state.title.isBlank()) return + submit.submit(state.title, state.description.ifBlank { null }, state.priority, state.subtasks) +} + +@Composable +private fun TaskEditorContent( + state: TaskEditorState, + titleFocusRequester: FocusRequester, + actions: TaskEditorActions, + checklist: SubtaskListState, +) { + val descriptionFocus = remember { FocusRequester() } + val scroll = rememberScrollState() + Box { + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.fieldGap), + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scroll), + ) { + EnclyTextField( + value = state.title, + onValueChange = actions.onTitleChange, + label = stringResource( + if (state.isEditMode) Res.string.task_edit_placeholder else Res.string.task_new_placeholder, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { descriptionFocus.requestFocus() }), + fieldModifier = Modifier.focusRequester(titleFocusRequester), + ) + EnclyTextField( + value = state.description, + onValueChange = actions.onDescriptionChange, + label = stringResource(Res.string.task_description_placeholder), + textStyle = MaterialTheme.typography.bodyMedium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { actions.onSubmit() }), + fieldModifier = Modifier.focusRequester(descriptionFocus), + ) + // Right under the description, so it stays in view above the keyboard. + SubtaskEditor(checklist) + PriorityChips(selected = state.priority, onSelect = actions.onPrioritySelect) + TaskEditorFooter(state, actions) + } + EnclyScrollbar(scroll) + } +} + +/** The priority as three chips, highest first. */ +@Composable +private fun PriorityChips(selected: Int, onSelect: (Int) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + SectionOverline(stringResource(Res.string.priority_select_title)) + Row(horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + PriorityValues.priorities.forEach { priority -> + EnclyChip( + label = stringResource(priority.label), + selected = priority.id == selected, + onClick = { onSelect(priority.id) }, + ) + } + } + } +} + +@Composable +private fun TaskEditorFooter(state: TaskEditorState, actions: TaskEditorActions) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + actions.onDelete?.let { onDelete -> + EnclyTextButton(text = stringResource(Res.string.task_delete), onClick = onDelete, destructive = true) + } + EnclyButton( + text = stringResource(if (state.isEditMode) Res.string.save else Res.string.add), + onClick = actions.onSubmit, + enabled = state.title.isNotBlank(), + modifier = Modifier.weight(1f), + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt index 11addb92..a0b239cc 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions @@ -33,7 +32,14 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextDecoration -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.subtask_add +import com.pasich.encly.core.resources.subtask_delete +import com.pasich.encly.core.resources.subtask_placeholder +import com.pasich.encly.core.resources.subtasks_title +import com.pasich.encly.core.resources.tag_drag_handle +import com.pasich.encly.core.resources.tag_move_down +import com.pasich.encly.core.resources.tag_move_up import com.pasich.encly.data.model.Subtask import com.pasich.encly.presentation.designsystem.CheckboxSize import com.pasich.encly.presentation.designsystem.EnclyCheckbox @@ -43,6 +49,7 @@ import com.pasich.encly.presentation.designsystem.SectionOverline import com.pasich.encly.presentation.viewmodel.SubtaskDraft import com.pasich.encly.presentation.viewmodel.SubtaskDrafts import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableColumn import sh.calvin.reorderable.ReorderableListItemScope @@ -130,7 +137,7 @@ internal fun SubtaskEditor(state: SubtaskListState) { val subtasks = state.subtasks ?: return val haptics = LocalHapticFeedback.current Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs)) { - SectionOverline(stringResource(R.string.subtasks_title)) + SectionOverline(stringResource(Res.string.subtasks_title)) ReorderableColumn( list = subtasks, onSettle = state::move, @@ -158,8 +165,8 @@ private fun ReorderableListItemScope.SubtaskRow( moveUp: (() -> Unit)?, moveDown: (() -> Unit)?, ) { - val moveUpLabel = stringResource(R.string.tag_move_up) - val moveDownLabel = stringResource(R.string.tag_move_down) + val moveUpLabel = stringResource(Res.string.tag_move_up) + val moveDownLabel = stringResource(Res.string.tag_move_down) // On the drag handle, a node TalkBack focuses (the row itself is not one). val moves = listOfNotNull( moveUp?.let { up -> @@ -187,13 +194,13 @@ private fun ReorderableListItemScope.SubtaskRow( SubtaskTextField( value = subtask.title, onValueChange = { state.setTitle(subtask.key, it) }, - placeholder = stringResource(R.string.subtask_placeholder), + placeholder = stringResource(Res.string.subtask_placeholder), done = subtask.isCompleted, modifier = Modifier.weight(1f), ) IconWithTooltip( EnclyIcons.Close, - stringResource(R.string.subtask_delete), + stringResource(Res.string.subtask_delete), onClick = { state.remove(subtask.key) }, tint = MaterialTheme.colorScheme.onSurfaceVariant, iconSize = EnclyTheme.spacing.iconSmall, @@ -208,7 +215,7 @@ private fun ReorderableListItemScope.SubtaskRow( */ @Composable private fun DragHandle(moves: List, modifier: Modifier = Modifier) { - val description = stringResource(R.string.tag_drag_handle) + val description = stringResource(Res.string.tag_drag_handle) Box( contentAlignment = Alignment.Center, modifier = modifier @@ -233,7 +240,7 @@ private fun NewSubtaskRow(title: String, onTitleChange: (String) -> Unit, onAdd: Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { IconWithTooltip( EnclyIcons.Plus, - stringResource(R.string.subtask_add), + stringResource(Res.string.subtask_add), onClick = onAdd, enabled = title.isNotBlank(), tint = MaterialTheme.colorScheme.primary, @@ -242,7 +249,7 @@ private fun NewSubtaskRow(title: String, onTitleChange: (String) -> Unit, onAdd: SubtaskTextField( value = title, onValueChange = onTitleChange, - placeholder = stringResource(R.string.subtask_add), + placeholder = stringResource(Res.string.subtask_add), onImeAction = onAdd, modifier = Modifier.weight(1f), ) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/drawer/AdaptiveNavigation.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/drawer/AdaptiveNavigation.kt new file mode 100644 index 00000000..716bedb7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/drawer/AdaptiveNavigation.kt @@ -0,0 +1,113 @@ +package com.pasich.encly.presentation.drawer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pasich.encly.platform.LocalAppInfo +import com.pasich.encly.presentation.designsystem.EnclyScrollColumn +import com.pasich.encly.presentation.designsystem.EnclyWordmark +import com.pasich.encly.presentation.navigation.DrawerNavItem +import com.pasich.encly.presentation.navigation.drawerNavItems +import com.pasich.encly.presentation.viewmodel.StatisticViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +/** The destinations of the drawer and of the rail, with the counts the statistics report now. */ +@Composable +internal fun rememberNavItems(statisticViewModel: StatisticViewModel = metroViewModel()): List { + val totalTags by statisticViewModel.totalTagsCreated.collectAsStateWithLifecycle() + val openTasks by statisticViewModel.openTasksCount.collectAsStateWithLifecycle() + return drawerNavItems( + tagCount = totalTags, + openTasks = openTasks, + donations = LocalAppInfo.current.donationsEnabled, + ) +} + +/** + * The navigation drawer of the expanded layout: [MainDrawer]'s wordmark and destinations, always there + * beside the page instead of sliding over it. [selectedRoute] is the destination whose page is shown. + */ +@Composable +fun MainPermanentDrawer(selectedRoute: String, onItemClick: (DrawerNavItem) -> Unit, modifier: Modifier = Modifier) { + val items = rememberNavItems() + val appInfo = LocalAppInfo.current + EnclyScrollColumn( + modifier = modifier + .width(EnclyTheme.spacing.drawerWidth) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .statusBarsPadding(), + ) { + EnclyWordmark( + appName = appInfo.appName, + modifier = Modifier.padding( + start = EnclyTheme.spacing.gutter, + top = EnclyTheme.spacing.m, + bottom = EnclyTheme.spacing.m, + ), + ) + items.forEach { item -> + DrawerEntry(item = item, isHome = item.route == selectedRoute, onClick = { onItemClick(item) }) + } + } +} + +/** + * The navigation rail of the medium layout: the drawer's destinations as icons (a badge for a count, the + * name as the description), in place of the menu button that opens the drawer on a phone. + */ +@Composable +fun MainRail(selectedRoute: String, onItemClick: (DrawerNavItem) -> Unit, modifier: Modifier = Modifier) { + val items = rememberNavItems() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .width(EnclyTheme.spacing.railWidth) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .statusBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(vertical = EnclyTheme.spacing.xs), + ) { + items.forEach { item -> + val label = stringResource(item.titleRes) + NavigationRailItem( + selected = item.route == selectedRoute, + onClick = { onItemClick(item) }, + icon = { RailIcon(item, label) }, + label = { Text(text = label, maxLines = 1, style = MaterialTheme.typography.labelSmall) }, + alwaysShowLabel = false, + ) + } + } +} + +@Composable +private fun RailIcon(item: DrawerNavItem, label: String) { + val count = item.badgeCount?.takeIf { it > 0 } + if (count == null) { + Icon(imageVector = item.icon, contentDescription = label) + } else { + BadgedBox(badge = { Badge { Text(text = count.toString()) } }) { + Icon(imageVector = item.icon, contentDescription = label) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/drawer/MainDrawer.kt similarity index 78% rename from app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/drawer/MainDrawer.kt index e926e8ec..2203da2c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/drawer/MainDrawer.kt @@ -1,13 +1,9 @@ package com.pasich.encly.presentation.drawer -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.MaterialTheme @@ -17,25 +13,25 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape -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.lifecycle.compose.collectAsStateWithLifecycle -import com.pasich.encly.BuildConfig -import com.pasich.encly.R +import com.pasich.encly.platform.LocalAppInfo import com.pasich.encly.presentation.designsystem.EnclyDrawerHighlight import com.pasich.encly.presentation.designsystem.EnclyDrawerItem import com.pasich.encly.presentation.designsystem.EnclyGroupDivider +import com.pasich.encly.presentation.designsystem.EnclyScrollColumn import com.pasich.encly.presentation.designsystem.EnclyWordmark +import com.pasich.encly.presentation.effects.windowWidth 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource private const val TABLET_MIN_WIDTH_DP = 600 private const val PHONE_DRAWER_FRACTION = 0.8f @@ -47,15 +43,16 @@ fun MainDrawer( onItemClick: (DrawerNavItem) -> Unit, modifier: Modifier = Modifier, topInset: Dp = 0.dp, - statisticViewModel: StatisticViewModel = enclyViewModel(), + statisticViewModel: StatisticViewModel = metroViewModel(), ) { val totalTags by statisticViewModel.totalTagsCreated.collectAsStateWithLifecycle() val openTasks by statisticViewModel.openTasksCount.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + val appInfo = LocalAppInfo.current val drawerItems = drawerNavItems( tagCount = totalTags, openTasks = openTasks, - donations = BuildConfig.DONATIONS_ENABLED, + donations = appInfo.donationsEnabled, ) ModalDrawerSheet( @@ -68,14 +65,13 @@ fun MainDrawer( windowInsets = WindowInsets(0), modifier = modifier.width(getDrawerWidth()), ) { - Column( + EnclyScrollColumn( modifier = Modifier .fillMaxHeight() - .padding(top = topInset) - .verticalScroll(rememberScrollState()), + .padding(top = topInset), ) { EnclyWordmark( - appName = stringResource(R.string.app_name), + appName = appInfo.appName, modifier = Modifier.padding( start = EnclyTheme.spacing.gutter, top = EnclyTheme.spacing.m, @@ -106,9 +102,9 @@ fun MainDrawer( } } -/** One destination: a row (Notes is the current one), or the highlighted Support button; a hairline may follow. */ +/** One destination: a row ([isHome]: the current one), or the highlighted Support button; a hairline may follow. */ @Composable -private fun DrawerEntry(item: DrawerNavItem, isHome: Boolean, onClick: () -> Unit) { +internal fun DrawerEntry(item: DrawerNavItem, isHome: Boolean, onClick: () -> Unit) { if (item.highlighted) { EnclyDrawerHighlight(label = stringResource(item.titleRes), icon = item.icon, onClick = onClick) } else { @@ -117,7 +113,7 @@ private fun DrawerEntry(item: DrawerNavItem, isHome: Boolean, onClick: () -> Uni icon = item.icon, // Zero reads as noise next to a destination; the badge shows only a real count. badge = item.badgeCount?.takeIf { it > 0 }, - // The drawer opens over the notes, so Notes is the current destination. + // The modal drawer opens over the notes, so Notes is its current destination. selected = isHome, onClick = onClick, ) @@ -128,10 +124,9 @@ private fun DrawerEntry(item: DrawerNavItem, isHome: Boolean, onClick: () -> Uni /** Longer than the page transition, so the drawer never visibly snaps shut. */ private const val DRAWER_SNAP_DELAY_MS = 400L -@SuppressLint("ConfigurationScreenWidthHeight") @Composable fun getDrawerWidth(): Dp { - val configuration = LocalConfiguration.current - val isTablet = configuration.screenWidthDp >= TABLET_MIN_WIDTH_DP - return if (isTablet) EnclyTheme.spacing.drawerWidth else configuration.screenWidthDp.dp * PHONE_DRAWER_FRACTION + val widthDp = windowWidth() + val isTablet = widthDp >= TABLET_MIN_WIDTH_DP.dp + return if (isTablet) EnclyTheme.spacing.drawerWidth else widthDp * PHONE_DRAWER_FRACTION } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/persistence/NoteCopyTitle.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/editor/persistence/NoteCopyTitle.kt similarity index 76% rename from app/src/main/java/com/pasich/encly/presentation/editor/persistence/NoteCopyTitle.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/editor/persistence/NoteCopyTitle.kt index 7002c1c3..22d49f84 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/persistence/NoteCopyTitle.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/editor/persistence/NoteCopyTitle.kt @@ -2,5 +2,5 @@ package com.pasich.encly.presentation.editor.persistence /** The title of a copy of a note titled `title`, in the user's language ("Title (Copy)"). */ fun interface NoteCopyTitle { - operator fun invoke(title: String): String + suspend operator fun invoke(title: String): String } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt new file mode 100644 index 00000000..a695e76f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt @@ -0,0 +1,525 @@ +package com.pasich.encly.presentation.editor.persistence + +import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.common.suspendRunCatching +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.repository.NotesRepository +import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.hasContent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.concurrent.Volatile +import kotlin.time.Clock + +enum class SaveStatusNote { + OLD, + SAVING, + SAVED, + LOADING, + + /** The last save failed: the editor still holds changes the database does not. */ + FAILED, +} + +data class LoadNoteState(val note: Note = Note(id = -1), val backupNote: Note = Note(id = -1)) + +/** Quiet time after the last edit before the note is autosaved. */ +const val AUTOSAVE_DEBOUNCE_MS = 2000L + +private const val TAG = "NotePersistence" + +/** + * Reads and writes the note an editor works on: load, the debounced autosave, explicit saves, + * discard, trash/restore, duplicate and delete. [blocks] is the editor's current content. + * + * Every write runs under one mutex, so autosave, an explicit exit-save and the last-chance save + * of a closing screen never interleave. Once the editor is [close]d (after trash, restore, + * delete or discard, or an explicit exit), no queued or later save writes the note again, so it + * cannot bring a trashed note back or write edits over a discard. + */ +@Suppress("TooManyFunctions", "LongParameterList") // Every write of the note shares one mutex and its use cases. +class NotePersistence( + private val notesRepository: NotesRepository, + private val updateNoteTrashStatusUseCase: UpdateNoteTrashStatusUseCase, + private val updateNoteArchiveStatusUseCase: UpdateNoteArchiveStatusUseCase, + private val ioDispatcher: CoroutineDispatcher, + private val readOnly: Boolean, + private val blocks: () -> List, + private val copyTitle: NoteCopyTitle, + loading: Boolean = false, +) { + private val _state = MutableStateFlow(LoadNoteState()) + val state: StateFlow = _state.asStateFlow() + + private val _status = MutableStateFlow(SaveStatusNote.OLD) + val status: StateFlow = _status.asStateFlow() + + // True when a note had stored content that could not be read. Nothing may then be saved, + // so an edit cannot overwrite the unreadable original. + private val _contentLoadFailed = MutableStateFlow(false) + val contentLoadFailed: StateFlow = _contentLoadFailed.asStateFlow() + + private val saveMutex = Mutex() + + @Volatile + private var closed = false + + /** + * Set while another window edits this note: nothing of this editor may be written, so the other window's + * work is not overwritten with what this one loaded earlier. Unlike [closed], it can be lifted. + */ + @Volatile + var suspended = false + + // False while a stored note (or the source of a copy) is still to be read ([loading]). Until + // then the editor does not hold that note yet, so nothing may be saved: a save would see + // id = -1 and insert a duplicate. + @Volatile + private var loaded = !loading + + // The title, tag and content last written to (or read from) the database. The autosave + // only writes when the editor differs from it. + private var persistedSnapshot: String = snapshotOf(_state.value.note, "[]") + + // The row as this editor last read or wrote it: a save writes only the columns that differ from it, so it never + // puts back a column that another window changed meanwhile (trash, archive, pin, colour). + private var persistedNote: Note = _state.value.note + + // The note as the editor opened it, for [hasChanges]. + private var openedSnapshot: String = persistedSnapshot + + private var autosaveJob: Job? = null + + /** + * The editor's content as stored JSON. The blocks' values are copied where this is called + * (cheap), and serialized on [ioDispatcher], so a long note never serializes on the main thread. + */ + private suspend fun contentJson(): String { + val content = blocks().frozen() + return withContext(ioDispatcher) { BlockConverter.blocksToJson(content) } + } + + /** + * What a write that is not an edit (archive, duplicate) stores as the content: the editor's blocks, or the stored + * text as it is when it could not be read, so that a note this version cannot read is never written back empty. + */ + private suspend fun contentToKeep(note: Note): String = if (_contentLoadFailed.value) note.value else contentJson() + + /** False when nothing may be saved: closed, still loading, read-only or unreadable. */ + private val canPersist: Boolean + get() = !closed && loaded && !readOnly && !suspended && !_contentLoadFailed.value + + /** + * Reads note [noteId] (as a new, unsaved copy with [isCopy]) and hands its blocks to + * [showBlocks]. Returns false when the note does not exist or could not be read; the + * editor must then stay read-only. + */ + suspend fun load(noteId: Long, isCopy: Boolean, showBlocks: (List) -> Unit): Boolean { + _status.value = SaveStatusNote.LOADING + val note = suspendRunCatching { notesRepository.getNoteById(noteId) } + .onFailure { AppLogger.e(TAG, "Error loading note", it) } + .getOrNull() + if (note == null) { + _contentLoadFailed.value = true + _status.value = SaveStatusNote.OLD + return false + } + + // A valid title-only note ("[]", or no content) keeps the editor's blank block. + val parsed = if (note.value.isEmpty()) { + emptyList() + } else { + withContext(ioDispatcher) { BlockConverter.jsonToBlocksOrNull(note.value) } + } + if (parsed == null) { + _contentLoadFailed.value = true + AppLogger.e(TAG, "Stored note content could not be parsed") + } + // The blocks and the note (its id) are published together, so no save can ever see the + // note's content without its id. + parsed?.takeIf { it.isNotEmpty() }?.let(showBlocks) + val shown = if (isCopy) note.asCopy(copyTitle(note.title)) else note + _state.value = LoadNoteState(note = shown, backupNote = shown) + persistedSnapshot = snapshotOf(shown, contentJson()) + persistedNote = shown + openedSnapshot = persistedSnapshot + loaded = true + _status.value = SaveStatusNote.OLD + // Unreadable content: the note (its title) is shown, but the editor stays read-only. + return parsed != null + } + + /** + * Saves the note [AUTOSAVE_DEBOUNCE_MS] after the last change of [contentChanges], the + * title, tag, pin or colour, if the editor then differs from what is stored. Opening a note saves nothing, and + * the first edit is saved like any other. + */ + @OptIn(FlowPreview::class) + fun startAutosave(scope: CoroutineScope, contentChanges: Flow) { + autosaveJob?.cancel() + autosaveJob = scope.launch { + combine(contentChanges, _state.map { it.note.metaKey() }.distinctUntilChanged()) { _, _ -> } + .debounce(AUTOSAVE_DEBOUNCE_MS) + .collect { save(onlyIfChanged = true) } + } + } + + /** Changes the note's metadata (title, tag, pin, colour) in the editor; it is stored with the next save. */ + fun updateNote(transform: (Note) -> Note) { + _state.value = _state.value.copy(note = transform(_state.value.note)) + } + + /** + * Stores the note and returns once the database reports the result. A blank new note is + * not stored; a save that is not allowed (see [canPersist]) is a successful no-op. With + * [onlyIfChanged] (the autosave), nothing is written when the editor matches what is stored. + */ + suspend fun save(onlyIfChanged: Boolean = false): Boolean = saveMutex.withLock { saveLocked(onlyIfChanged) } + + /** [save] for a caller that holds [saveMutex] already. */ + private suspend fun saveLocked(onlyIfChanged: Boolean): Boolean = when { + !canPersist -> { + if (_status.value != SaveStatusNote.LOADING && _status.value != SaveStatusNote.FAILED) { + _status.value = SaveStatusNote.OLD + } + true + } + + // A blank new note is not stored. + isBlankDraft -> true + + else -> writeNote(onlyIfChanged) + } + + private suspend fun writeNote(onlyIfChanged: Boolean): Boolean { + val currentNote = _state.value.note + val blocksJson = contentJson() + val snapshot = snapshotOf(currentNote, blocksJson) + if (onlyIfChanged && snapshot == persistedSnapshot) return true + + _status.value = SaveStatusNote.SAVING + val saved = suspendRunCatching { + if (currentNote.id != -1L) { + val stored = withContext(ioDispatcher) { + notesRepository.updateStored(currentNote, persistedNote, _state.value.backupNote, blocksJson) + } + // The editor shows when the note was last edited. + stored?.let { note -> + updateNote { it.copy(date = note.date, description = note.description) } + persistedNote = note + } + stored != null + } else { + val draft = newDraft(currentNote, blocksJson) + val insertedId = withContext(ioDispatcher) { notesRepository.insertDraft(draft) } + // Merged into the latest state: a title or tag changed while the insert ran is + // kept, and the next autosave stores it. The stored creation time comes along: + // every later save writes the whole row, so a note that still said 0 here would + // wipe it on the first autosave. + insertedId?.let { id -> + updateNote { + it.copy(id = id, value = blocksJson, date = draft.date, dateCreate = draft.dateCreate) + } + persistedNote = draft.copy(id = id) + } + insertedId != null + } + }.onFailure { AppLogger.e(TAG, "Note persistence failed", it) }.getOrDefault(false) + + if (saved) persistedSnapshot = snapshot + _status.value = if (saved) SaveStatusNote.SAVED else SaveStatusNote.FAILED + return saved + } + + /** + * Ends this editor's hold on the note (its window gives the claim back): the changes not yet stored are saved + * first, then nothing is written until [resume]. Both happen under the save mutex, so no autosave slips in + * between, and the claim is released only after this returns: another window that claims the note next loads + * what was saved here. + */ + suspend fun release(): Boolean = saveMutex.withLock { + val saved = saveLocked(onlyIfChanged = true) + suspended = true + saved + } + + /** + * Starts editing again after [release], or after another window had the note. If the stored note changed + * meanwhile it is read again and shown by [showBlocks]; otherwise the editor keeps what it holds (and its undo + * history). + */ + suspend fun resume(showBlocks: (List) -> Unit) = saveMutex.withLock { + val id = _state.value.note.id + if (id != -1L && loaded) { + val stored = suspendRunCatching { notesRepository.getNoteById(id) }.getOrNull() + if (stored != null) showIfChanged(stored, showBlocks) + } + suspended = false + } + + /** The stored row of the note, following every change to it; empty for a note that is not stored yet. */ + fun storedNote(): Flow { + val id = _state.value.note.id + // The flow fails once the vault closes; the view then simply stops following. + return if (id == -1L) emptyFlow() else notesRepository.observeNote(id).map { it?.note }.catch { } + } + + /** A read-only view of a note edited elsewhere: shows [stored] when it differs from what is on screen. */ + suspend fun showStored(stored: Note?, showBlocks: (List) -> Unit) { + if (stored == null) return + saveMutex.withLock { if (suspended) showIfChanged(stored, showBlocks) } + } + + private suspend fun showIfChanged(stored: Note, showBlocks: (List) -> Unit) { + if (closed || stored.sameAs(persistedNote)) return + val parsed = if (stored.value.isEmpty()) { + emptyList() + } else { + withContext(ioDispatcher) { BlockConverter.jsonToBlocksOrNull(stored.value) } + } + // Unreadable content stays as it is on screen; the original is never replaced. + if (parsed == null) return + showBlocks(parsed) + _state.value = LoadNoteState(note = stored, backupNote = stored) + persistedSnapshot = snapshotOf(stored, contentJson()) + persistedNote = stored + openedSnapshot = persistedSnapshot + } + + /** + * Puts the stored note back as it was when the editor opened it. A new note (or copy) that + * was already autosaved is deleted instead of leaving a ghost note behind. + */ + suspend fun discard(): Boolean = saveMutex.withLock { + if (!loaded || suspended) return@withLock false + val currentNote = _state.value.note + val backupNote = _state.value.backupNote + + val discarded = when { + // Only the columns this editor changed go back; the trash and archive state never. + backupNote.id != -1L -> withContext(ioDispatcher) { + notesRepository.updateEdited(backupNote, persistedNote) + } + + currentNote.id != -1L -> withContext(ioDispatcher) { notesRepository.deleteNoteById(currentNote.id) } + + else -> Result.success(Unit) + }.isSuccess + + if (discarded) close() + _status.value = SaveStatusNote.OLD + discarded + } + + /** Takes the note out of the trash, with the editor's content. */ + suspend fun restoreFromTrash(): Boolean = saveMutex.withLock { + val currentNote = _state.value.note + if (!loaded || suspended || currentNote.id == -1L) return@withLock false + + if (!saveLocked(onlyIfChanged = true)) return@withLock false + val restored = _state.value.note.copy(isTrash = false) + val ok = updateNoteTrashStatusUseCase.invoke(restored, false).isSuccess + if (ok) { + _state.value = _state.value.copy(note = restored) + close() + } + ok + } + + /** Moves the note to the trash (a soft delete), with the editor's content. */ + suspend fun moveToTrash(): Boolean = saveMutex.withLock { + val currentNote = _state.value.note + if (!loaded || suspended) return@withLock false + if (currentNote.id == -1L) { + close() + return@withLock true + } + + // What was typed goes first, then the note moves; the trash write touches its own two columns only. + if (!saveLocked(onlyIfChanged = true)) return@withLock false + val trashed = _state.value.note.copy(isTrash = true) + val ok = updateNoteTrashStatusUseCase.invoke(trashed, true).isSuccess + if (ok) { + // Keep the in-memory note in sync with the row: nothing may write isTrash=false back. + _state.value = _state.value.copy(note = trashed) + close() + } + ok + } + + /** + * Moves the note to the archive (unpinning it) or back to the list, with the editor's + * content. The editor is closed afterwards, like [moveToTrash]. + */ + suspend fun setArchived(archived: Boolean): Boolean = saveMutex.withLock { + val currentNote = _state.value.note + if (!loaded || suspended || currentNote.id == -1L) return@withLock false + + val updated = currentNote.copy( + value = contentToKeep(currentNote), + isArchived = archived, + isPinned = currentNote.isPinned && !archived, + ) + val ok = updateNoteArchiveStatusUseCase.invoke(updated, archived).isSuccess + if (ok) { + _state.value = _state.value.copy(note = updated) + close() + } + ok + } + + /** Whether the editor holds nothing to keep: a new note without a title or content. */ + val isBlankDraft: Boolean + get() = _state.value.note.let { it.id == -1L && it.title.isBlank() } && + blocks().none { it.hasContent() || it is Block.CodeBlock || it is Block.TableBlock } + + /** + * Whether the note differs from how the editor opened it (its title, tag or content), or a + * new note was already stored: what "discard" would undo. + */ + suspend fun hasChanges(): Boolean { + val (note, backup) = _state.value.let { it.note to it.backupNote } + if (note.id != backup.id) return true + return note.title != backup.title || note.tagIds != backup.tagIds || + note.isPinned != backup.isPinned || note.color != backup.color || + note.textSize != backup.textSize || note.lineSpacing != backup.lineSpacing || + snapshotOf(note, contentJson()) != openedSnapshot + } + + /** Stores a copy of the note, titled by [copyTitle]. Returns its id, or -1 on failure. */ + suspend fun duplicate(): Long = saveMutex.withLock { + val currentNote = _state.value.note + val content = contentToKeep(currentNote) + withContext(ioDispatcher) { + notesRepository.insertNote( + Note.new(title = copyTitle(currentNote.title), value = content, tagIds = currentNote.tagIds) + .copy( + color = currentNote.color, + textSize = currentNote.textSize, + lineSpacing = currentNote.lineSpacing, + ), + ) + }.getOrNull()?.takeIf { it > 0L } ?: -1L + } + + /** Deletes the note from the database for good. */ + suspend fun delete(): Boolean = saveMutex.withLock { + val currentNoteId = _state.value.note.id + val deleted = when { + !loaded || suspended -> false + currentNoteId == -1L -> true + else -> notesRepository.deleteNoteById(currentNoteId).isSuccess + } + if (deleted) close() + deleted + } + + /** Ends editing: no queued or later save may write this note again. */ + fun close() { + closed = true + autosaveJob?.cancel() + } +} + +private fun snapshotOf(note: Note, blocksJson: String): String = + "${note.title}\u0000${note.tagIds.sorted()}\u0000${note.isPinned}\u0000${note.color}\u0000" + + "${note.textSize}\u0000${note.lineSpacing}\u0000$blocksJson" + +/** What besides the content makes the editor differ from the stored note; the autosave watches it. */ +private data class NoteMeta( + val title: String, + val tagIds: Set, + val isPinned: Boolean, + val color: String, + val textSize: Int?, + val lineSpacing: Int?, +) + +/** Whether [other] holds the same title, content and organizing columns (the editor's, not the trash state). */ +private fun Note.sameAs(other: Note): Boolean = id == other.id && title == other.title && value == other.value && + tagIds == other.tagIds && isPinned == other.isPinned && color == other.color && + textSize == other.textSize && lineSpacing == other.lineSpacing && date == other.date + +private fun Note.metaKey() = NoteMeta(title, tagIds, isPinned, color, textSize, lineSpacing) + +/** + * Stores [note] with [blocksJson] as its content, writing only what differs from [base] (the row as the editor last + * saw it); returns the stored note, or null. Its date + * moves forward only when the title or content differ from [backup], the note as the editor + * opened it, and then an imported summary ([Note.description]) no longer describes it. + */ +private suspend fun NotesRepository.updateStored(note: Note, base: Note, backup: Note, blocksJson: String): Note? { + val changed = backup.hasContentChanged(blocksJson, note.title) + val stored = if (changed) { + note.copy(value = blocksJson, date = Clock.System.now().toEpochMilliseconds(), description = "") + } else { + note.copy(value = blocksJson) + } + return stored.takeIf { updateEdited(it, base).isSuccess } +} + +/** A copy of the blocks holding their current values, to serialize away from the editor. */ +private fun List.frozen(): List = map { block -> + when (block) { + is Block.TextBlock -> block.copy(text = MutableStateFlow(block.text.value)) + + is Block.HBlock -> block.copy(text = MutableStateFlow(block.text.value)) + + is Block.QuoteBlock -> block.copy(text = MutableStateFlow(block.text.value)) + + is Block.LinkBlock -> block.copy(block = MutableStateFlow(block.block.value)) + + is Block.ListBlock -> block.copy( + items = MutableStateFlow(block.items.value), + locked = MutableStateFlow(block.locked.value), + ) + + is Block.CodeBlock -> block.copy(text = MutableStateFlow(block.text.value)) + + is Block.TableBlock -> block.copy(table = MutableStateFlow(block.table.value)) + + is Block.SeparatorBlock -> block + } +} + +/** The row a new note is first stored as: stamped with its creation time, which is never set again. */ +private fun newDraft(note: Note, blocksJson: String): Note = + Note.new(title = note.title, value = blocksJson, tagIds = note.tagIds) + .copy(isPinned = note.isPinned, color = note.color, textSize = note.textSize, lineSpacing = note.lineSpacing) + +/** Inserts [draft] as a new row; returns its id, or null. */ +private suspend fun NotesRepository.insertDraft(draft: Note): Long? = insertNote(draft) + .getOrNull() + ?.takeIf { it > 0L } + +/** + * [this] note as a new, unsaved copy (id = -1) titled [copyTitle]. The copy keeps the tags and the colour but + * starts unpinned and unarchived. + */ +private fun Note.asCopy(copyTitle: String): Note = copy( + id = -1, + title = copyTitle, + date = Clock.System.now().toEpochMilliseconds(), + uid = "", + isPinned = false, + isArchived = false, +) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt new file mode 100644 index 00000000..e4bcd7db --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt @@ -0,0 +1,167 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.IntOffset + +private const val AXIS_DURATION = 300 +private const val AXIS_FADE_IN = 210 +private const val AXIS_FADE_IN_DELAY = 60 +private const val AXIS_FADE_OUT = 120 + +/** How far a screen travels on the shared X axis: an eighth of the width. */ +private const val AXIS_SHIFT_FRACTION = 8 + +/** Material's emphasized decelerate: screens arrive fast and settle gently. */ +private val AxisEasing = CubicBezierEasing(a = 0.05f, b = 0.7f, c = 0.1f, d = 1f) + +/** + * Shared X axis (Material motion): going forward, the new screen slides in from the end side as it + * fades in and the old one drifts toward the start as it fades out; [forward] false mirrors it for Back. + * Start and end follow the layout direction, so a right-to-left layout moves the other way. + * + * The fade is not part of these transitions: `fadeIn`/`fadeOut` put a whole window of content in an + * offscreen layer (about 10 MB at 1080x2400) for as long as they run. [screenFade] draws it instead, + * per draw call, on the destination's own transition clock. + */ +fun AnimatedContentTransitionScope<*>.axisEnter(forward: Boolean): EnterTransition = slideIntoContainer( + towards = if (forward) SlideDirection.Start else SlideDirection.End, + animationSpec = tween(AXIS_DURATION, easing = AxisEasing), + initialOffset = { fullSlide -> fullSlide / AXIS_SHIFT_FRACTION }, +) + +fun AnimatedContentTransitionScope<*>.axisExit(forward: Boolean): ExitTransition = slideOutOfContainer( + towards = if (forward) SlideDirection.Start else SlideDirection.End, + animationSpec = tween(AXIS_DURATION, easing = AxisEasing), + targetOffset = { fullSlide -> fullSlide / AXIS_SHIFT_FRACTION }, +) + +/** + * Duration of the card growing into the editor. A little longer than a screen slide: the card travels + * and grows across the whole window, and at 300 ms that read as a jump rather than a growth. + */ +const val CONTAINER_DURATION = 340 + +/** Closing is quicker than opening: the user is leaving, and the shrink felt slow at the opening's pace. */ +const val CONTAINER_CLOSE_DURATION = 220 + +/** The editor fades in over this part of the transform, starting [CONTAINER_FADE_IN_DELAY] in. */ +const val CONTAINER_FADE_IN = 220 + +/** The card grows a little before the editor shows in it, so the motion leads and the content follows. */ +const val CONTAINER_FADE_IN_DELAY = 60 + +/** Closing, the editor fades out quickly so the shrinking card is what the eye follows. */ +const val CONTAINER_FADE_OUT = 120 + +/** + * What keeps a screen composed for the length of a fade. A screen that only fades (the note list and + * the editor) has no animation of its own left once the fade moved to [screenFade], and a transition + * with none ends at once, taking the leaving screen with it. This moves it one pixel, an animation of + * [durationMillis] that no layer and no eye can tell. + */ +fun holdEnter(durationMillis: Int): EnterTransition = + slideIn(tween(durationMillis, easing = LinearEasing)) { IntOffset(-1, 0) } + +fun holdExit(durationMillis: Int): ExitTransition = + slideOut(tween(durationMillis, easing = LinearEasing)) { IntOffset(1, 0) } + +/** How long a screen's fade runs, from [delayMillis] into its transition. */ +class FadeTiming(val delayMillis: Int, val durationMillis: Int, val easing: Easing) { + val endMillis: Int get() = delayMillis + durationMillis +} + +private val AxisIn = FadeTiming(AXIS_FADE_IN_DELAY, AXIS_FADE_IN, FastOutSlowInEasing) +private val AxisOut = FadeTiming(0, AXIS_FADE_OUT, FastOutSlowInEasing) + +/** + * The cross-fade of the screens behind a container transform, paced with it: the screen being left + * fades over the card's growth instead of blinking out under it, and the new one comes in a beat late. + */ +private val ContainerIn = FadeTiming(CONTAINER_FADE_IN_DELAY, CONTAINER_FADE_IN, LinearOutSlowInEasing) +private val ContainerOut = FadeTiming(0, CONTAINER_FADE_IN, FastOutLinearInEasing) + +/** The fade a screen enters with under [motion], or null when it does not fade. */ +internal fun ScreenMotion.enterFade(): FadeTiming? = when (this) { + ScreenMotion.AXIS -> AxisIn + ScreenMotion.FADE -> ContainerIn + ScreenMotion.NONE -> null +} + +/** The fade a screen leaves with under [motion], or null when it does not fade. */ +internal fun ScreenMotion.exitFade(): FadeTiming? = when (this) { + ScreenMotion.AXIS -> AxisOut + ScreenMotion.FADE -> ContainerOut + ScreenMotion.NONE -> null +} + +/** + * The alpha at [playTimeMillis] into a change of a transition from [current] to [target]: entering + * waits [FadeTiming.delayMillis] and then eases in over [FadeTiming.durationMillis]; leaving eases out. + * At rest it is fully there or fully gone. A null timing is no fade: fully there. + */ +internal fun fadeAlpha( + enter: FadeTiming?, + exit: FadeTiming?, + current: EnterExitState, + target: EnterExitState, + playTimeMillis: Float, +): Float = when { + current == target -> if (target == EnterExitState.Visible) 1f else 0f + + target == EnterExitState.Visible -> enter?.let { + it.easing.transform(((playTimeMillis - it.delayMillis) / it.durationMillis).coerceIn(0f, 1f)) + } ?: 1f + + else -> exit?.let { + 1f - it.easing.transform(((playTimeMillis - it.delayMillis) / it.durationMillis).coerceIn(0f, 1f)) + } ?: 1f +} + +/** + * Fades the content with the destination's transition, with no `fadeIn`/`fadeOut` and so no offscreen + * layer: the alpha is applied per draw call ([CompositingStrategy.ModulateAlpha]), read when the layer + * is drawn (a frame redraws it, nothing recomposes), and follows a seek of the transition (the + * predictive back gesture). What differs from a group fade is overlapping content showing through + * itself for the length of the fade. The timings are read at draw time too, so a screen's motion can + * be decided when its transition starts. + */ +fun Modifier.screenFade( + visibility: AnimatedVisibilityScope, + enter: () -> FadeTiming?, + exit: () -> FadeTiming?, +): Modifier = graphicsLayer { + val transition = visibility.transition + alpha = fadeAlpha( + enter = enter(), + exit = exit(), + current = transition.currentState, + target = transition.targetState, + playTimeMillis = transition.playTimeNanos / NANOS_PER_MILLI_F, + ) + compositingStrategy = CompositingStrategy.ModulateAlpha +} + +private const val NANOS_PER_MILLI_F = 1_000_000f + +/** + * The easing of the container transform's bounds and shape: Material's emphasized curve, which + * starts gently and settles slowly, unlike the axis curve's sharp start. + */ +val ContainerEasing = CubicBezierEasing(a = 0.2f, b = 0f, c = 0f, d = 1f) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationScreens.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationScreens.kt new file mode 100644 index 00000000..98460e8f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationScreens.kt @@ -0,0 +1,77 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable + +private fun AnimatedContentTransitionScope.motion(): ScreenMotion { + val motion = screenMotion(initialState.destination.route, targetState.destination.route) + ScreenMotions.record(initialState.id, motion) + ScreenMotions.record(targetState.id, motion) + return motion +} + +/** The enter transition for a swap of the destinations: the move only, the fade is [screenFade]'s. */ +fun AnimatedContentTransitionScope.enterFor(forward: Boolean): EnterTransition = when (motion()) { + ScreenMotion.NONE -> EnterTransition.None + ScreenMotion.FADE -> holdEnter(ScreenMotion.FADE.enterFade()?.endMillis ?: 0) + ScreenMotion.AXIS -> axisEnter(forward) +} + +/** The exit transition for a swap of the destinations: the move only, the fade is [screenFade]'s. */ +fun AnimatedContentTransitionScope.exitFor(forward: Boolean): ExitTransition = when (motion()) { + ScreenMotion.NONE -> ExitTransition.None + ScreenMotion.FADE -> holdExit(ScreenMotion.FADE.exitFade()?.endMillis ?: 0) + ScreenMotion.AXIS -> axisExit(forward) +} + +/** + * A destination with the app's screen motion (see [screenMotion]). Its [content] also gets the + * destination's [AnimatedVisibilityScope] through [LocalNavAnimatedVisibilityScope], for shared + * elements. + */ +fun NavGraphBuilder.animationScreens( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, +) = composable( + route = route, + arguments = arguments, + deepLinks = deepLinks, + // Re-lock swaps straight to the lock screen: no cross-fade that would keep the previous, + // decrypted screen on display (EnclyApp's shield covers it until then). Unlock swaps + // straight away too: it happens under the reveal overlay (see UnlockRevealOverlay). + enterTransition = { enterFor(forward = true) }, + exitTransition = { exitFor(forward = true) }, + popEnterTransition = { enterFor(forward = false) }, + popExitTransition = { exitFor(forward = false) }, + content = { entry -> + val visibility = this + val animationsOff = rememberAnimationsOff() + CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides visibility) { + Box( + modifier = if (animationsOff) { + Modifier + } else { + Modifier.screenFade( + visibility = visibility, + enter = { ScreenMotions.of(entry.id).enterFade() }, + exit = { ScreenMotions.of(entry.id).exitFade() }, + ) + }, + propagateMinConstraints = true, + ) { visibility.content(entry) } + } + }, +) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.kt new file mode 100644 index 00000000..f445f669 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.kt @@ -0,0 +1,10 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.runtime.Composable + +/** + * Whether the system asks for no animation at all ("remove animations" / animator duration scale 0 on + * Android). Read once when it enters the composition; false where the platform has no such setting. + */ +@Composable +internal expect fun rememberAnimationsOff(): Boolean diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ContainerTransform.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ContainerTransform.kt new file mode 100644 index 00000000..07f16dff --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ContainerTransform.kt @@ -0,0 +1,184 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.presentation.components.swipe.rememberReducedMotion + +/** + * The scope the navigation host's screens share elements in, provided once by AppNavHost. Null + * outside it (previews, tests): nothing is shared there. + */ +val LocalSharedTransitionScope = staticCompositionLocalOf { null } + +/** The visibility scope of the destination being composed, provided by [animationScreens]. */ +val LocalNavAnimatedVisibilityScope = staticCompositionLocalOf { null } + +/** Which end of the card-to-editor container transform the modifier goes on. */ +enum class ContainerEnd { CARD, EDITOR } + +/** + * The container transform between a note's card and its editor: both carry a shared-bounds + * modifier keyed by the note, so the card grows into the full-screen editor (and shrinks back), + * its corner radius easing between the card's and 0 (see [cornerClip]) while the colours cross over. + * + * - The card re-measures to the animated bounds (`RemeasureToBounds`), so its surface fills the + * growing container instead of leaving a scaled card in the corner. The editor is scaled + * (`ScaleToBounds`) from its own full-size layout: re-measuring a whole text editor on every + * frame is wasteful, and its width is close to the card's, so the scale stays near 1. + * - The editor is always drawn above the card in the overlay and is the one that fades; the card + * stays opaque beneath it, so no frame lets the screen behind show through a cross-fade. + * - Nothing is shared (an empty modifier) without both scopes, with the system's "remove + * animations", or without a [noteId] (a new note has no card to grow from). When the other end + * is not composed (the card scrolled off, moved, archived or deleted from the editor), no match + * is found: the content stays in its place and the editor just fades. + */ +@Composable +fun rememberNoteContainerModifier(noteId: Long, end: ContainerEnd): Modifier { + val sharedScope = LocalSharedTransitionScope.current + val visibility = LocalNavAnimatedVisibilityScope.current + val plain = rememberReducedMotion() || noteId <= 0L + return if (sharedScope != null && visibility != null && !plain) { + rememberContainerBounds(sharedScope, visibility, noteId, end) + } else { + Modifier + } +} + +@Composable +private fun rememberContainerBounds( + sharedScope: SharedTransitionScope, + visibility: AnimatedVisibilityScope, + noteId: Long, + end: ContainerEnd, +): Modifier { + val corner = MaterialTheme.shapes.medium.topStart + val windowSize = LocalWindowInfo.current.containerSize + val clip = remember(corner, windowSize) { cornerClip(corner, windowSize) } + val state = with(sharedScope) { rememberSharedContentState(key = "note-$noteId") } + val bounds = with(sharedScope) { + Modifier.sharedBounds( + sharedContentState = state, + animatedVisibilityScope = visibility, + // No enter/exit here: fadeIn/fadeOut put the content in an offscreen layer for the whole + // fade, and in the overlay that layer is a new surface for Skia to keep on every frame the + // bounds change size. The editor's fade is drawn by [containerFade] without a layer. + enter = EnterTransition.None, + exit = ExitTransition.None, + boundsTransform = containerBounds(visibility, end), + resizeMode = if (end == ContainerEnd.CARD) { + SharedTransitionScope.ResizeMode.RemeasureToBounds + } else { + SharedTransitionScope.ResizeMode.scaleToBounds(ContentScale.FillWidth, Alignment.TopStart) + }, + zIndexInOverlay = if (end == ContainerEnd.EDITOR) 1f else 0f, + clipInOverlayDuringTransition = clip, + ) + } + return if (end == ContainerEnd.EDITOR) bounds.then(containerFade(visibility)) else bounds +} + +/** + * The editor's fade as it enters and leaves, on the destination's own transition clock: its + * `playTimeNanos` is read only when the layer is drawn (so a frame redraws the layer, nothing + * recomposes), it follows a seek of the transition (the predictive back gesture), and it adds no + * animation to the transition, so a leaving screen is never kept composed for it. + * + * The alpha is applied per draw call ([FADE_COMPOSITING]), not to the editor as one group: a group + * alpha needs an offscreen surface, and while the card grows its bounds (and so the overlay's + * layers) change size every frame. What differs from a group fade is only text over the editor's + * own background, which is drawn at the same alpha beside it instead of inside it, for 120-220 ms + * of a move that is already covered by the card's colour underneath. + */ +private fun containerFade(visibility: AnimatedVisibilityScope): Modifier = Modifier.graphicsLayer { + val transition = visibility.transition + alpha = containerFadeAlpha( + current = transition.currentState, + target = transition.targetState, + playTimeMillis = transition.playTimeNanos / NANOS_PER_MILLI, + ) + compositingStrategy = FADE_COMPOSITING +} + +/** How the fade is composited: per draw call, never as an offscreen group (see [containerFade]). */ +internal val FADE_COMPOSITING = CompositingStrategy.ModulateAlpha + +private const val NANOS_PER_MILLI = 1_000_000f + +/** + * The editor's alpha at [playTimeMillis] into a change of the transition from [current] to [target]. + * Entering it waits [CONTAINER_FADE_IN_DELAY] ms and then eases in over [CONTAINER_FADE_IN] ms; leaving + * it eases out over [CONTAINER_FADE_OUT] ms. At rest it is fully there or fully gone. + */ +internal fun containerFadeAlpha(current: EnterExitState, target: EnterExitState, playTimeMillis: Float): Float = when { + current == target -> if (target == EnterExitState.Visible) 1f else 0f + + target == EnterExitState.Visible -> { + val progress = ((playTimeMillis - CONTAINER_FADE_IN_DELAY) / CONTAINER_FADE_IN).coerceIn(0f, 1f) + LinearOutSlowInEasing.transform(progress) + } + + else -> { + val progress = (playTimeMillis / CONTAINER_FADE_OUT).coerceIn(0f, 1f) + 1f - FastOutLinearInEasing.transform(progress) + } +} + +/** + * The bounds of the card growing into the editor, and of the editor shrinking back (quicker). The + * editor is on its way out when it is leaving; the card, when it is coming back. + */ +private fun containerBounds(visibility: AnimatedVisibilityScope, end: ContainerEnd) = BoundsTransform { _, _ -> + val visible = visibility.transition.targetState == EnterExitState.Visible + val closing = if (end == ContainerEnd.EDITOR) !visible else visible + tween(if (closing) CONTAINER_CLOSE_DURATION else CONTAINER_DURATION, easing = ContainerEasing) +} + +/** Below this share of the window a container still has the card's full corner radius. */ +private const val ROUND_UNTIL_COVERAGE = 0.35f + +/** + * A rounded-rectangle clip of the animated bounds. The radius follows how much of the window the + * bounds cover: the card's own while they are card-sized, easing to 0 as they fill the window. + * It is computed here, while the content is lifted into the overlay, rather than by animating + * a value on the destination's transition: any animation added to that transition would keep a + * leaving screen composed until it ends, which a re-lock must never wait for. + */ +private fun cornerClip(corner: CornerSize, window: IntSize) = object : SharedTransitionScope.OverlayClip { + override fun getClipPath( + sharedContentState: SharedTransitionScope.SharedContentState, + bounds: Rect, + layoutDirection: LayoutDirection, + density: Density, + ): Path { + val windowArea = (window.width.toFloat() * window.height).coerceAtLeast(1f) + val coverage = bounds.width * bounds.height / windowArea + val roundness = 1f - ((coverage - ROUND_UNTIL_COVERAGE) / (1f - ROUND_UNTIL_COVERAGE)).coerceIn(0f, 1f) + val radius = corner.toPx(bounds.size, density) * roundness + return Path().apply { addRoundRect(RoundRect(bounds, CornerRadius(radius))) } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/effects/ScreenMotion.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ScreenMotion.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/effects/ScreenMotion.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ScreenMotion.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ScreenMotions.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ScreenMotions.kt new file mode 100644 index 00000000..7a4bdaa3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ScreenMotions.kt @@ -0,0 +1,20 @@ +package com.pasich.encly.presentation.effects + +/** + * The motion of each destination's current transition, decided when the transition starts (by + * [enterFor] and [exitFor]) and read when a frame is drawn (by [screenFade]). Destinations not in it + * do not fade. + */ +internal object ScreenMotions { + private const val KEPT = 16 + private val byEntry = HashMap() + private val order = ArrayDeque() + + fun record(entryId: String, motion: ScreenMotion) { + if (byEntry.put(entryId, motion) != null) order.remove(entryId) + order.addLast(entryId) + while (order.size > KEPT) byEntry.remove(order.removeFirst()) + } + + fun of(entryId: String): ScreenMotion = byEntry[entryId] ?: ScreenMotion.NONE +} diff --git a/app/src/main/java/com/pasich/encly/presentation/effects/Shimmer.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Shimmer.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/effects/Shimmer.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Shimmer.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/effects/ShimmerBrush.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ShimmerBrush.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/effects/ShimmerBrush.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ShimmerBrush.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/effects/UnlockReveal.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt similarity index 81% rename from app/src/main/java/com/pasich/encly/presentation/effects/UnlockReveal.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt index feb6a208..2ee531bf 100644 --- a/app/src/main/java/com/pasich/encly/presentation/effects/UnlockReveal.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt @@ -24,21 +24,23 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.lerp -import com.pasich.encly.R -import kotlinx.coroutines.delay +import com.pasich.encly.shared.resources.Res +import com.pasich.encly.shared.resources.ic_launcher_foreground +import org.jetbrains.compose.resources.painterResource import kotlin.math.hypot import kotlin.math.max -/** The tile grows until the window is the logo's colour... */ -private const val UNLOCK_GROW_MS = 300 - -/** ...then the notes list slides in from the right and pushes that colour out the left. */ -private const val UNLOCK_PUSH_MS = 520 +/** + * The tile grows until the window is the logo's colour... Grow, the frames that follow and the push add up to + * about 0.5 s, so the colour never reads as a green screen the user waits behind. There is no fixed pause + * under the cover: the list is rendered by its own skeleton while its notes load, so it can already move. + */ +internal const val UNLOCK_GROW_MS = 200 -/** How long the list rests under the cover first, so its data and first layout land before it moves. */ -private const val SETTLE_MS = 120L +/** ...then the notes list slides in from the end side (the right, left to right) and pushes that colour out. */ +internal const val UNLOCK_PUSH_MS = 250 /** The glyph is gone by this share of the growth. */ private const val GLYPH_OUT = 0.25f @@ -53,11 +55,11 @@ private val UnlockEasing = CubicBezierEasing(a = 0.3f, b = 0f, c = 0.2f, d = 1f) private val PushEasing = CubicBezierEasing(a = 0.05f, b = 0.7f, c = 0.1f, d = 1f) /** The short reveal: the lock screen fades out, then the app fades in. */ -private const val QUICK_OUT_MS = 120 -private const val QUICK_IN_MS = 200 +internal const val QUICK_OUT_MS = 100 +internal const val QUICK_IN_MS = 160 /** Frames the first screen gets to compose and draw under the cover before it starts to rise. */ -private const val SETTLE_FRAMES = 3 +internal const val SETTLE_FRAMES = 3 /** The lock screen has faded under the backdrop by this share of the growth. */ private const val LOCK_OUT = 0.4f @@ -139,27 +141,42 @@ fun UnlockRevealState?.revealThen(leave: () -> Unit) { * The unlock reveal, drawn above the NavHost as one continuous move. The logo tile grows from its * place while the lock screen fades under a `surface` backdrop, until the window is `primary`. * Only then does the app leave the lock screen ([UnlockRevealState.start]), so the notes list - * composes behind the colour; after a short rest it slides in from the right + * composes behind the colour; after a few frames it slides in from the right * ([UnlockRevealState.contentOffset], applied by App) and pushes the colour out the left. It * swallows touches while it plays, so the lock screen's keypad can't be tapped on the way out. */ @Composable fun UnlockRevealOverlay(state: UnlockRevealState, modifier: Modifier = Modifier) { val bounds = state.logoBounds - if (!state.running || bounds == null) return + val animationsOff = rememberAnimationsOff() + when { + !state.running || bounds == null -> Unit + animationsOff -> LeaveAtOnce(state) + !state.full -> QuickUnlockFade(state, modifier) + else -> FullUnlockReveal(state, bounds, modifier) + } +} + +/** The system asks for no animation: leave the lock screen at once, no colour, no slide. */ +@Composable +private fun LeaveAtOnce(state: UnlockRevealState) { + LaunchedEffect(Unit) { + state.cover() + state.push(1f) + state.finish() + } +} + +@Composable +private fun FullUnlockReveal(state: UnlockRevealState, bounds: Rect, modifier: Modifier = Modifier) { val colors = MaterialTheme.colorScheme - val glyph = painterResource(R.drawable.ic_launcher_foreground) + val glyph = painterResource(Res.drawable.ic_launcher_foreground) val grow = remember { Animatable(0f) } val push = remember { Animatable(0f) } - if (!state.full) { - QuickUnlockFade(state, modifier) - return - } LaunchedEffect(Unit) { grow.animateTo(1f, tween(UNLOCK_GROW_MS, easing = UnlockEasing)) state.cover() repeat(SETTLE_FRAMES) { withFrameNanos { } } - delay(SETTLE_MS) push.animateTo(1f, tween(UNLOCK_PUSH_MS, easing = PushEasing)) { state.push(value) } state.finish() } @@ -197,7 +214,8 @@ fun UnlockRevealOverlay(state: UnlockRevealState, modifier: Modifier = Modifier) } } else { // A flat sheet, so the edge the list pushes against is a straight line. - drawRect(color = colors.primary, topLeft = Offset(-push.value * size.width, 0f)) + val towardsStart = if (layoutDirection == LayoutDirection.Rtl) 1f else -1f + drawRect(color = colors.primary, topLeft = Offset(towardsStart * push.value * size.width, 0f)) } } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.kt new file mode 100644 index 00000000..aae0b6e5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.kt @@ -0,0 +1,12 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp + +/** Whether the on-screen keyboard is showing (its inset is not zero), as the platform reports it. */ +@Composable +internal expect fun rememberImeVisible(): Boolean + +/** The width of the window the UI is laid out in, as the platform's configuration reports it. */ +@Composable +internal expect fun windowWidth(): Dp diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouter.kt new file mode 100644 index 00000000..07f08aa5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouter.kt @@ -0,0 +1,147 @@ +package com.pasich.encly.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.pane.NoteTabs +import com.pasich.encly.presentation.pane.cycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * What the editor on screen does for a command. Each returns whether it did something, so a key that + * changed nothing is left to whoever else wants it. + */ +class EditorCommands( + val find: () -> Boolean, + val moveToTrash: () -> Boolean, + val dismiss: () -> Boolean, + val print: () -> Boolean = { false }, + val exportPdf: () -> Boolean = { false }, +) + +/** + * Runs the app's [AppCommand]s, whether a keyboard shortcut or a menu item asked: the screens that own the + * behaviour register it while they are on screen ([attachNewNote], [attachTabs], [attachEditor]) and the router + * calls the one that is there. So a command works the way the screen's own button does (a new note from the + * home screen is the FAB's, opened as a tab where there are tabs), and does nothing where no screen has it. + * The callers decide whether commands are allowed at all (never while the vault is locked). + */ +@Stable +class AppCommandRouter( + private val scope: CoroutineScope, + private val lockApp: () -> Unit, + private val newNoteFallback: () -> Boolean, +) { + private var newNote: (() -> Unit)? = null + private var tabs: NoteTabs? = null + private var editor: EditorCommands? by mutableStateOf(null) + + /** Whether an editor is on screen (a note to print or save as PDF); observable by the host's menu. */ + val hasEditor: Boolean get() = editor != null + + /** The home screen's way of starting a note (the FAB's); a newer one replaces it. */ + fun attachNewNote(action: () -> Unit) { + newNote = action + } + + /** The home screen left; a newer one that already took its place stays. */ + fun detachNewNote(action: () -> Unit) { + if (newNote === action) newNote = null + } + + /** The tabs of the expanded layout's detail pane. */ + fun attachTabs(tabs: NoteTabs) { + this.tabs = tabs + } + + /** The pane left; newer tabs that already took its place stay. */ + fun detachTabs(tabs: NoteTabs) { + if (this.tabs === tabs) this.tabs = null + } + + /** The editor that is on screen (in the pane: the one of the shown tab). */ + fun attachEditor(editor: EditorCommands) { + this.editor = editor + } + + /** The editor left; a newer one that already took its place stays. */ + fun detachEditor(editor: EditorCommands) { + if (this.editor === editor) this.editor = null + } + + /** Runs [command]; true when something was done, so the key that asked for it is used up. */ + fun run(command: AppCommand): Boolean = when (command) { + AppCommand.NewNote -> startNote() + AppCommand.FindInNote -> editor?.find?.invoke() == true + AppCommand.LockApp -> true.also { lockApp() } + AppCommand.CloseTab -> closeActiveTab() + AppCommand.NextTab -> switchTab(forward = true) + AppCommand.PreviousTab -> switchTab(forward = false) + AppCommand.DeleteNote -> editor?.moveToTrash?.invoke() == true + AppCommand.Dismiss -> editor?.dismiss?.invoke() == true + AppCommand.PrintNote -> editor?.print?.invoke() == true + AppCommand.ExportPdf -> editor?.exportPdf?.invoke() == true + } + + private fun startNote(): Boolean { + val start = newNote ?: return newNoteFallback() + start() + return true + } + + private fun closeActiveTab(): Boolean { + val open = tabs + val key = open?.activeKey + if (open == null || key == null) return false + open.close(key) + return true + } + + private fun switchTab(forward: Boolean): Boolean { + val open = tabs?.takeIf { it.tabs.size > 1 } ?: return false + scope.launch { open.cycle(forward) } + return true + } +} + +/** The running app's command router, or null where no host provides one (a screen drawn on its own). */ +val LocalAppCommands = staticCompositionLocalOf { null } + +/** Registers [action] as the home screen's way of starting a note while this is on screen. */ +@Composable +internal fun NewNoteCommand(action: () -> Unit) { + val router = LocalAppCommands.current ?: return + val current by rememberUpdatedState(action) + DisposableEffect(router) { + val registered = { current() } + router.attachNewNote(registered) + onDispose { router.detachNewNote(registered) } + } +} + +/** Registers [tabs] as the tabs the tab commands act on while this is on screen. */ +@Composable +internal fun TabCommands(tabs: NoteTabs) { + val router = LocalAppCommands.current ?: return + DisposableEffect(router, tabs) { + router.attachTabs(tabs) + onDispose { router.detachTabs(tabs) } + } +} + +/** Registers [editor] as the editor the note commands act on while this is on screen. */ +@Composable +internal fun EditorCommandsBinding(editor: EditorCommands) { + val router = LocalAppCommands.current ?: return + DisposableEffect(router, editor) { + router.attachEditor(editor) + onDispose { router.detachEditor(editor) } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppLayoutDirection.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppLayoutDirection.kt new file mode 100644 index 00000000..97282de2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppLayoutDirection.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.utils.currentLanguageTag + +/** + * The layout direction of the app's language, on every host: Android, desktop and iOS do not all derive it + * from the locale the same way. A language the app has no translation for keeps the host's own direction. + */ +@Composable +internal fun appLayoutDirection(): LayoutDirection { + val host = LocalLayoutDirection.current + val language = AppLanguage.fromTag(currentLanguageTag()) + return when { + language == AppLanguage.SYSTEM -> host + language.rightToLeft -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/DrawerNavItem.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/DrawerNavItem.kt new file mode 100644 index 00000000..7c6c630c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/DrawerNavItem.kt @@ -0,0 +1,65 @@ +package com.pasich.encly.presentation.navigation + +import androidx.compose.ui.graphics.vector.ImageVector +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.about +import com.pasich.encly.core.resources.import_title +import com.pasich.encly.core.resources.main_drawer_archive +import com.pasich.encly.core.resources.main_drawer_faq +import com.pasich.encly.core.resources.main_drawer_settings +import com.pasich.encly.core.resources.main_drawer_support +import com.pasich.encly.core.resources.main_drawer_tags +import com.pasich.encly.core.resources.main_drawer_tasks +import com.pasich.encly.core.resources.main_drawer_trash +import com.pasich.encly.core.resources.notes_title +import com.pasich.encly.presentation.designsystem.EnclyIcons +import org.jetbrains.compose.resources.StringResource + +/** + * A drawer destination. [groupEnd] draws a hairline after it. A [highlighted] one is drawn as a + * call-to-action button instead of a plain row (Support). + */ +data class DrawerNavItem( + val titleRes: StringResource, + val icon: ImageVector, + val route: String, + val badgeCount: Int? = null, + val groupEnd: Boolean = false, + val highlighted: Boolean = false, +) + +/** + * The drawer's destinations, in order: Notes (always first), Tasks, Tags, Archive and Trash, a + * hairline, then Import notes directly above Settings, a hairline, Support (highlighted), FAQ + * and About. Tasks is always listed (the home card hides itself with nothing open, and the drawer + * is then the only way in); its badge counts open tasks. [donations] lists Support, which only + * the builds that allow external donations carry; the Play build has none. + */ +fun drawerNavItems(tagCount: Int, openTasks: Int, donations: Boolean): List = buildList { + add(DrawerNavItem(Res.string.notes_title, EnclyIcons.Paper, NavRoutes.HomeRoute.name)) + add(DrawerNavItem(Res.string.main_drawer_tasks, EnclyIcons.Checklist, NavRoutes.TasksRoute.name, openTasks)) + add(DrawerNavItem(Res.string.main_drawer_tags, EnclyIcons.Tag, NavRoutes.EditTagRoute.name, tagCount)) + add(DrawerNavItem(Res.string.main_drawer_archive, EnclyIcons.Archive, NavRoutes.ArchiveRoute.name)) + add(DrawerNavItem(Res.string.main_drawer_trash, EnclyIcons.Trash, NavRoutes.TrashRoute.name, groupEnd = true)) + add(DrawerNavItem(Res.string.import_title, EnclyIcons.Download, NavRoutes.ImportNotesRoute.name)) + add( + DrawerNavItem( + Res.string.main_drawer_settings, + EnclyIcons.Sliders, + NavRoutes.SettingsRoute.name, + groupEnd = true, + ), + ) + if (donations) { + add( + DrawerNavItem( + Res.string.main_drawer_support, + EnclyIcons.Coffee, + NavRoutes.SupportRoute.name, + highlighted = true, + ), + ) + } + add(DrawerNavItem(Res.string.main_drawer_faq, EnclyIcons.Help, NavRoutes.FaqRoute.name)) + add(DrawerNavItem(Res.string.about, EnclyIcons.Info, NavRoutes.AboutRoute.name)) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt new file mode 100644 index 00000000..c4c0c9a3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt @@ -0,0 +1,375 @@ +package com.pasich.encly.presentation.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import androidx.savedstate.SavedState +import androidx.savedstate.read +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.platform.HostNavigationRequests +import com.pasich.encly.presentation.components.editNote.LocalEditorToolbar +import com.pasich.encly.presentation.components.tiles.LocalShowNotePreviews +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.designsystem.EnclyShortcutHost +import com.pasich.encly.presentation.designsystem.EnclyShortcuts +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.viewmodel.EditorToolbarViewModel +import com.pasich.encly.presentation.viewmodel.NotePreviewsViewModel +import com.pasich.encly.presentation.viewmodel.StartupViewModel +import com.pasich.encly.ui.theme.AppTheme +import com.pasich.encly.ui.theme.DensityProfile +import com.pasich.encly.ui.theme.LocalDensityProfile +import com.pasich.encly.ui.theme.LocalPointerFirst +import com.pasich.encly.ui.theme.LocalWidthClass +import com.pasich.encly.ui.theme.WidthClass +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield + +/** True while the vault is locked, or a re-lock is still being covered: nothing may be drawn over the lock screen. */ +val LocalVaultLocked = staticCompositionLocalOf { false } + +/** + * The whole app UI: the theme, the navigation host and the guards that keep protected screens away from + * a locked vault. The host (an Activity on Android) resolves the vault's [startStatus] first and draws + * nothing, or its own splash, while it is null; it also wraps this in whatever the platform needs around + * every text field (the keyboard and clipboard policy) and provides the `Local...` services. + * + * The keyboard shortcuts ([EnclyShortcuts]) are handled around the whole app and run by an [AppCommandRouter], + * which the host's menu bar reaches through `NavigationRequests` too. A host whose menu registers the + * accelerator of a command itself passes it in [declinedCommands], so the chord has one owner. [shortcuts] is + * the one key map of the window; commands are ignored while the vault is locked. A host that rebuilds this under + * a new key keeps the user's place by passing the replaced controller's `saveState()` as [restoredNavState] and + * learning the new controller from [onNavController]. + */ +@Composable +fun EnclyApp( + startStatus: InitialStatus?, + securityManager: SecurityManager, + sessionLockManager: SessionLockManager, + modifier: Modifier = Modifier, + shortcuts: EnclyShortcuts = remember { EnclyShortcuts() }, + declinedCommands: Set = emptySet(), + restoredNavState: SavedState? = null, + onNavController: (NavHostController) -> Unit = {}, +) { + val destination = startStatus?.let(::startRoute) ?: return + val navController = rememberNavController() + // A host that rebuilds the app under a new key (the desktop, on a language change) hands the back stack of the + // controller it replaces to the new one, before its graph is set: the screens, and the entries' ViewModels, stay. + remember(navController) { + if (restoredNavState != null) navController.restoreState(restoredNavState) + Unit + } + SideEffect { onNavController(navController) } + // A start with no vault yet is a fresh install: it has no earlier version whose news it missed. + val startup = metroViewModel() + val freshInstall = startStatus == InitialStatus.ONBOARDING || startStatus == InitialStatus.SETUP_AUTH + LaunchedEffect(startup) { startup.onAppStart(freshInstall) } + + // Auto re-lock. Collected without a lifecycle gate so the value is already true in + // the first frame after returning from the background. That frame still composes + // the pre-lock destination (e.g. an open note), so the shield below covers the + // NavHost until the lock screen is the only visible entry. Navigation itself runs + // after the frame, from this effect. + val locked by sessionLockManager.locked.collectAsState() + val scope = rememberCoroutineScope() + val currentLocked by rememberUpdatedState(locked) + val router = remember(navController, scope, sessionLockManager) { + AppCommandRouter(scope, sessionLockManager::lockNow) { navController.startNewNote() } + } + // A command is run only with the vault open: never from the lock screen, never in the shielded frame. + val commandsAllowed = remember(securityManager) { { !currentLocked && securityManager.isDatabaseUnlocked() } } + LockRouteGuard(navController, securityManager::isDatabaseUnlocked) + HostNavigationRequests(navController, router, commandsAllowed) + SessionResumeGuard(navController, sessionLockManager) + val visibleEntries by navController.visibleEntries.collectAsState() + val shielded = locked && visibleEntries.any { + it.destination.route != NavRoutes.LockRoute.name + } + LaunchedEffect(locked) { + if (locked) navController.showLockScreen() + } + + CompositionLocalProvider(LocalAppCommands provides router) { + EnclyShortcutHost( + onCommand = { command -> command !in declinedCommands && commandsAllowed() && router.run(command) }, + modifier = modifier.fillMaxSize(), + shortcuts = shortcuts, + ) { + App( + navController = navController, + startDestination = destination.name, + eraseEpoch = securityManager::eraseEpoch, + shielded = shielded, + locked = locked, + ) + } + } +} + +/** + * A new note from where no screen has its own way of starting one (a page of the drawer): the editor, over + * the page. Not from inside an editor, which would swap the note being written. + */ +private fun NavHostController.startNewNote(): Boolean { + if (currentDestination?.route?.startsWith(NavRoutes.EditNoteRoute.name) == true) return false + navigate("${NavRoutes.EditNoteRoute.name}/-1") { launchSingleTop = true } + return true +} + +/** Where the app starts for [status]; null while there is nothing to show yet. */ +internal fun startRoute(status: InitialStatus): NavRoutes? = when (status) { + InitialStatus.MAIN -> NavRoutes.HomeRoute + + // PIN setup is part of onboarding, which restarts until the vault is committed. + InitialStatus.ONBOARDING, InitialStatus.SETUP_AUTH -> NavRoutes.OnboardingRoute + + InitialStatus.LOSS_DATABASE, InitialStatus.LOSS_CRYPTO -> NavRoutes.LossDataRoute + + InitialStatus.AUTH -> NavRoutes.LockRoute + + InitialStatus.LEGACY_VAULT -> NavRoutes.LegacyVaultRoute + + InitialStatus.STORAGE_UNREADABLE -> NavRoutes.StorageUnreadableRoute + + InitialStatus.NO -> null +} + +/** + * Central route guard: whatever tries to show a vault screen while the database is closed (a + * back stack restored after process death, a stale navigation) is sent to the lock screen. + * Navigating from inside the listener is deferred to the next main-thread turn. + */ +@Composable +private fun LockRouteGuard(navController: NavHostController, isDatabaseUnlocked: () -> Boolean) { + val scope = rememberCoroutineScope() + val currentIsDatabaseUnlocked by rememberUpdatedState(isDatabaseUnlocked) + DisposableEffect(navController) { + val guard = NavController.OnDestinationChangedListener { _, destination, _ -> + if (isProtectedRoute(destination.route) && !currentIsDatabaseUnlocked()) { + scope.launch { + // The next turn of the main thread, whatever dispatcher runs the composition's scope. + yield() + navController.showLockScreen() + } + } + } + navController.addOnDestinationChangedListener(guard) + onDispose { navController.removeOnDestinationChangedListener(guard) } + } +} + +/** + * Coming back to the foreground after the vault was unlocked on another lock screen (see + * [SessionResume.afterStop]) rebuilds the back stack instead of showing screens of a session + * that ended. Runs on ON_START itself, before the first frame and the re-lock effect. + */ +@Composable +private fun SessionResumeGuard(navController: NavHostController, sessionLockManager: SessionLockManager) { + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, navController) { + var stoppedAt: Long? = null + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> stoppedAt = sessionLockManager.sessionGeneration + + Lifecycle.Event.ON_START -> { + val resume = SessionResume.afterStop( + stoppedAt = stoppedAt, + generation = sessionLockManager.sessionGeneration, + locked = sessionLockManager.locked.value, + ) + stoppedAt = null + when (resume) { + SessionResume.KEEP -> Unit + + SessionResume.LOCK_SCREEN -> navController.showLockScreen(keepNote = false) + + SessionResume.HOME -> navController.navigate(NavRoutes.HomeRoute.name) { + popUpTo(navController.graph.id) { inclusive = true } + } + } + } + + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} + +/** Screens that are reachable without an open vault; every other route shows vault content. */ +private val PUBLIC_ROUTES = setOf( + NavRoutes.LockRoute.name, + NavRoutes.OnboardingRoute.name, + NavRoutes.LossDataRoute.name, + NavRoutes.LegacyVaultRoute.name, + NavRoutes.StorageUnreadableRoute.name, +) + +private fun isProtectedRoute(route: String?): Boolean = route != null && route !in PUBLIC_ROUTES + +/** The editor's "opened from the trash, read-only" navigation argument (see AppNavHost). */ +private const val EDIT_NOTE_READ_ONLY_ARG = "isReadTrashOnly" + +/** + * Replaces every screen with the lock screen after a background re-lock, remembering the note + * that was open (see [RelockReturn]) with the erase epoch of the vault it was opened in, so the + * unlock returns to it only in that vault. Without [keepNote], or from any other screen, + * whatever was remembered before is forgotten. + */ +private fun NavHostController.showLockScreen(keepNote: Boolean = true) { + val editor = currentBackStackEntry?.takeIf { keepNote } + val returnRoute = editor?.let { entry -> + val openNoteId = entry.savedStateHandle.get(RelockReturn.OPEN_NOTE_ID) + RelockReturn.routeFor( + destinationRoute = entry.destination.route, + openNoteId = openNoteId, + readOnly = entry.arguments?.read { getBooleanOrNull(EDIT_NOTE_READ_ONLY_ARG) } == true, + ) ?: RelockReturn.paneRouteFor(entry.destination.route, openNoteId) + } + val returnEpoch = editor?.savedStateHandle?.get(RelockReturn.OPEN_NOTE_EPOCH) + navigate(NavRoutes.LockRoute.name) { + // Drop every screen/ViewModel backed by the now-closed Room instance. + // Unlock starts a fresh Home graph with fresh DAO flows. + popUpTo(graph.id) { + inclusive = false + } + launchSingleTop = true + } + val lockState = currentBackStackEntry?.savedStateHandle ?: return + if (returnRoute != null && returnEpoch != null) { + lockState[RelockReturn.RETURN_ROUTE] = returnRoute + lockState[RelockReturn.RETURN_EPOCH] = returnEpoch + } else { + lockState.remove(RelockReturn.RETURN_ROUTE) + lockState.remove(RelockReturn.RETURN_EPOCH) + } +} + +/** + * The window's content. The size the host gives it decides the layout ([WidthClass]) and the density of the + * spacing; both are provided around the theme, which turns the density into its spacing tokens. + */ +@Composable +fun App( + navController: NavHostController, + startDestination: String, + eraseEpoch: () -> Long, + modifier: Modifier = Modifier, + shielded: Boolean = false, + locked: Boolean = false, +) { + CompositionLocalProvider(LocalLayoutDirection provides appLayoutDirection()) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val widthClass = WidthClass.of(maxWidth, maxHeight) + val density = DensityProfile.of(widthClass, LocalPointerFirst.current) + CompositionLocalProvider(LocalWidthClass provides widthClass, LocalDensityProfile provides density) { + AppContent(navController, startDestination, eraseEpoch, shielded, locked) + } + } + } +} + +@Composable +private fun AppContent( + navController: NavHostController, + startDestination: String, + eraseEpoch: () -> Long, + shielded: Boolean, + locked: Boolean, +) { + AppTheme { + // Edge-to-edge is forced on Android 15+/targetSdk 36. The window is painted with + // the same Compose background the screens use; every screen draws under the + // transparent status bar and pads its own top inset (its top bar does), so it + // paints that band itself and the band moves with the screen in a transition. + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + val showPreviews by metroViewModel().show.collectAsState() + val toolbar by metroViewModel().layout.collectAsState() + val unlockReveal = remember { UnlockRevealState() } + // A re-lock during the reveal ends it: no deferred navigation into the vault, + // no reveal left covering the lock screen. + LaunchedEffect(locked) { if (locked) unlockReveal.cancel() } + CompositionLocalProvider( + LocalUnlockReveal provides unlockReveal, + LocalShowNotePreviews provides showPreviews, + LocalEditorToolbar provides toolbar, + LocalVaultLocked provides (locked || shielded), + ) { + // The whole app layer moves with the unlock reveal: it slides in from the + // end side and pushes the logo colour out (see UnlockRevealOverlay). + val towardsEnd = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1f else 1f + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { translationX = towardsEnd * unlockReveal.contentOffset * size.width } + .background(MaterialTheme.colorScheme.background), + ) { + CompositionLocalProvider(LocalEraseEpoch provides eraseEpoch) { + AppNavHost( + navController = navController, + startDestination = startDestination, + ) + } + } + } + UnlockRevealOverlay(unlockReveal) + if (shielded) LockShield() + } + } +} + +/** + * Opaque, input-swallowing cover drawn above the NavHost while a re-locked session is still + * composing protected screens. Plaintext from before the lock is never drawn or tappable. + */ +@Composable +private fun LockShield() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { it.consume() } + } + } + }, + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt new file mode 100644 index 00000000..f47c953c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt @@ -0,0 +1,348 @@ +package com.pasich.encly.presentation.navigation + +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.navArgument +import androidx.savedstate.read +import com.pasich.encly.platform.LocalAppInfo +import com.pasich.encly.presentation.effects.LocalSharedTransitionScope +import com.pasich.encly.presentation.effects.animationScreens +import com.pasich.encly.presentation.effects.enterFor +import com.pasich.encly.presentation.effects.exitFor +import com.pasich.encly.presentation.pane.CenteredContent +import com.pasich.encly.presentation.pane.NavigationPage +import com.pasich.encly.presentation.screen.AboutScreen +import com.pasich.encly.presentation.screen.EditTagScreen +import com.pasich.encly.presentation.screen.FaqScreen +import com.pasich.encly.presentation.screen.LicensesScreen +import com.pasich.encly.presentation.screen.LockScreen +import com.pasich.encly.presentation.screen.LossReason +import com.pasich.encly.presentation.screen.LossRecoveryScreen +import com.pasich.encly.presentation.screen.MainRootScreen +import com.pasich.encly.presentation.screen.PinCodeConfigScreen +import com.pasich.encly.presentation.screen.RecoveryChoiceScreen +import com.pasich.encly.presentation.screen.StorageUnreadableScreen +import com.pasich.encly.presentation.screen.SupportScreen +import com.pasich.encly.presentation.screen.TasksScreen +import com.pasich.encly.presentation.screen.archive.ArchiveScreen +import com.pasich.encly.presentation.screen.backup.BackupScreen +import com.pasich.encly.presentation.screen.editnote.EditNoteScreen +import com.pasich.encly.presentation.screen.importnotes.ImportNotesScreen +import com.pasich.encly.presentation.screen.onboarding.OnboardingScreen +import com.pasich.encly.presentation.screen.settings.AppearanceScreen +import com.pasich.encly.presentation.screen.settings.DuressPinScreen +import com.pasich.encly.presentation.screen.settings.EditorToolbarScreen +import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen +import com.pasich.encly.presentation.screen.settings.SettingsScreen +import com.pasich.encly.presentation.screen.settings.WipePinScreen +import com.pasich.encly.presentation.screen.trash.TrashScreen +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.LossRecoveryViewModel +import com.pasich.encly.presentation.viewmodel.NoteTabsViewModel +import com.pasich.encly.presentation.viewmodel.StorageUnreadableViewModel +import com.pasich.encly.ui.theme.LocalWidthClass +import com.pasich.encly.ui.theme.WidthClass +import dev.zacsweers.metrox.viewmodel.metroViewModel + +@Composable +fun AppNavHost(navController: NavHostController, startDestination: String = NavRoutes.HomeRoute.name) { + val donationsEnabled = LocalAppInfo.current.donationsEnabled + // The scope lets a note card and the editor share their bounds (see rememberNoteContainerModifier). + SharedTransitionLayout { + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + NavHost( + navController, + startDestination = startDestination, + // The back gesture plays the same motion as a tap on Back (shared axis, a fade for the + // editor, none for the lock screen). Navigation's own default for the gesture (since 2.10) + // shrinks the leaving screen into the middle of the window. + predictivePopEnterTransition = { + enterFor(forward = false) + }, + predictivePopExitTransition = { + exitFor(forward = false) + }, + ) { + animationScreens(NavRoutes.HomeRoute.name) { + MainRootScreen( + navController, + ) + } + animationScreens(NavRoutes.ArchiveRoute.name) { + NavigationPage(navController, NavRoutes.ArchiveRoute) { ArchiveScreen(navController) } + } + animationScreens(NavRoutes.TrashRoute.name) { + NavigationPage(navController, NavRoutes.TrashRoute) { + TrashScreen( + navController, + ) + } + } + + animationScreens(NavRoutes.SettingsRoute.name) { + NavigationPage(navController, NavRoutes.SettingsRoute) { + SettingsScreen( + navController, + ) + } + } + animationScreens(NavRoutes.EditTagRoute.name) { + NavigationPage(navController, NavRoutes.EditTagRoute) { + EditTagScreen( + navController, + ) + } + } + + // Donations are an F-Droid build feature; the Play build has no route to them. + if (donationsEnabled) { + animationScreens(NavRoutes.SupportRoute.name) { + NavigationPage(navController, NavRoutes.SupportRoute) { SupportScreen(navController) } + } + } + + animationScreens( + route = "${NavRoutes.TasksRoute.name}?$TASKS_ADD_ARG={$TASKS_ADD_ARG}", + arguments = listOf( + navArgument(TASKS_ADD_ARG) { + type = NavType.BoolType + defaultValue = false + }, + ), + ) { entry -> + NavigationPage(navController, NavRoutes.TasksRoute) { + TasksScreen( + navController, + openNewTask = + entry.arguments?.read { getBooleanOrNull(TASKS_ADD_ARG) } == true, + ) + } + } + + animationScreens(NavRoutes.AboutRoute.name) { + NavigationPage(navController, NavRoutes.AboutRoute) { + AboutScreen( + navController, + ) + } + } + + animationScreens(NavRoutes.LicensesRoute.name) { + NavigationPage(navController, NavRoutes.AboutRoute) { LicensesScreen(navController) } + } + + animationScreens(NavRoutes.FaqRoute.name) { + NavigationPage(navController, NavRoutes.FaqRoute) { + FaqScreen( + navController, + ) + } + } + + animationScreens(NavRoutes.SecuritySettingsRoute.name) { + NavigationPage(navController, NavRoutes.SettingsRoute) { + SecuritySettingsScreen( + navController, + ) + } + } + + animationScreens(NavRoutes.AppearanceRoute.name) { + NavigationPage(navController, NavRoutes.SettingsRoute) { AppearanceScreen(navController) } + } + + animationScreens(NavRoutes.EditorToolbarRoute.name) { + NavigationPage(navController, NavRoutes.AppearanceRoute) { EditorToolbarScreen(navController) } + } + + animationScreens(NavRoutes.BackupRoute.name) { + NavigationPage(navController, NavRoutes.SettingsRoute) { BackupScreen(navController) } + } + + animationScreens(NavRoutes.ImportNotesRoute.name) { + NavigationPage(navController, NavRoutes.ImportNotesRoute) { ImportNotesScreen(navController) } + } + + animationScreens(NavRoutes.OnboardingRoute.name) { + // Onboarding includes the mandatory PIN setup; it completes only once the vault + // is committed and open. + CenteredContent { + OnboardingScreen(onComplete = { + navController.navigate(NavRoutes.HomeRoute.name) { + popUpTo(NavRoutes.OnboardingRoute.name) { inclusive = true } + } + }) + } + } + + animationScreens(NavRoutes.LossDataRoute.name) { + CenteredContent { LossRoute(navController, NavRoutes.LossDataRoute, LossReason.DAMAGED) } + } + animationScreens(NavRoutes.LegacyVaultRoute.name) { + CenteredContent { LossRoute(navController, NavRoutes.LegacyVaultRoute, LossReason.OLDER_VERSION) } + } + animationScreens(NavRoutes.StorageUnreadableRoute.name) { + CenteredContent { StorageUnreadableRoute(navController) } + } + animationScreens(NavRoutes.PinCodeConfig.name) { + NavigationPage(navController, NavRoutes.SettingsRoute) { PinCodeConfigScreen(navController) } + } + + animationScreens(NavRoutes.RecoveryChoiceRoute.name) { + CenteredContent { RecoveryChoiceScreen(navController) } + } + + animationScreens(NavRoutes.WipePinRoute.name) { + NavigationPage(navController, NavRoutes.SettingsRoute) { WipePinScreen(navController) } + } + + animationScreens(NavRoutes.DuressPinRoute.name) { + NavigationPage(navController, NavRoutes.SettingsRoute) { DuressPinScreen(navController) } + } + + animationScreens(NavRoutes.LockRoute.name) { + CenteredContent { LockScreen(navController) } + } + + animationScreens( + route = "${NavRoutes.EditNoteRoute.name}/{idNote}" + + "?isReadTrashOnly={isReadTrashOnly}©Source={copySource}&addTag={addTag}", + arguments = listOf( + navArgument("idNote") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("isReadTrashOnly") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("copySource") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("addTag") { + type = NavType.LongType + defaultValue = 0L + }, + ), + ) { entry -> + if (opensInPane(navController, entry)) { + OpenInPane(navController, entry) + } else { + EditNoteDestination(navController, entry) + } + } + } + } + } +} + +/** + * Whether the editor route [entry] belongs in the home screen's detail pane instead of the full screen: the + * window is expanded, the note is editable and home is right under it (the notes list opens notes in the pane + * itself; this catches the one that comes from the lock screen, which reopens the note that was open). + */ +@Composable +private fun opensInPane(navController: NavHostController, entry: NavBackStackEntry): Boolean { + if (LocalWidthClass.current != WidthClass.Expanded) return false + val readOnly = entry.arguments?.read { getBooleanOrNull("isReadTrashOnly") } == true + return !readOnly && navController.previousBackStackEntry?.destination?.route == NavRoutes.HomeRoute.name +} + +/** Hands the note of [entry] to the detail pane's tabs and takes the editor route off the back stack again. */ +@Composable +private fun OpenInPane(navController: NavHostController, entry: NavBackStackEntry) { + val eraseEpoch = LocalEraseEpoch.current + // The tabs live in the home entry's ViewModel store, which is the entry right under the editor's. + val home = remember(entry) { checkNotNull(navController.previousBackStackEntry) } + val tabsViewModel: NoteTabsViewModel = metroViewModel(home) + LaunchedEffect(entry) { + val arguments = entry.arguments + val noteId = arguments?.read { getLongOrNull("idNote") } ?: -1L + val copySource = arguments?.read { getLongOrNull("copySource") } ?: -1L + val addTag = arguments?.read { getLongOrNull("addTag") } ?: 0L + val params = when { + copySource > 0L -> "?copySource=$copySource" + addTag > 0L -> "?addTag=$addTag" + else -> "" + } + navController.popBackStack() + tabsViewModel.tabs.open(noteId, params, eraseEpoch?.invoke()) + } +} + +/** + * The note editor. Records the id of its note on [entry] (also the id a new note gets on its + * first save), so a background re-lock can return to it (see [RelockReturn]), and the vault's + * erase epoch ([LocalEraseEpoch]) when it first showed it: the note is reopened only in that + * vault. + */ +@Composable +private fun EditNoteDestination( + navController: NavHostController, + entry: NavBackStackEntry, + viewModel: EditNoteViewModel = metroViewModel(), +) { + val noteState by viewModel.state.collectAsState() + val eraseEpoch = LocalEraseEpoch.current + LaunchedEffect(entry, noteState.note.id, eraseEpoch) { + val state = entry.savedStateHandle + state[RelockReturn.OPEN_NOTE_ID] = noteState.note.id + // Kept from the first time: an erase while the editor stays open does not adopt it. + if (eraseEpoch != null && !state.contains(RelockReturn.OPEN_NOTE_EPOCH)) { + state[RelockReturn.OPEN_NOTE_EPOCH] = eraseEpoch() + } + } + // The screen resolves the same ViewModel instance: it is scoped to [entry]. + EditNoteScreen(navController) +} + +/** The vault's state file could not be read: trying again resolves the start anew, and leaves for wherever it leads. */ +@Composable +private fun StorageUnreadableRoute( + navController: NavHostController, + viewModel: StorageUnreadableViewModel = metroViewModel(), +) { + val stillUnreadable by viewModel.stillUnreadable.collectAsState() + StorageUnreadableScreen( + stillUnreadable = stillUnreadable, + onRetry = { + viewModel.retry { status -> + val route = startRoute(status) + if (route != null && route != NavRoutes.StorageUnreadableRoute) { + navController.navigate(route.name) { + popUpTo(NavRoutes.StorageUnreadableRoute.name) { inclusive = true } + } + } + } + }, + ) +} + +/** The vault cannot be opened ([reason]): wiping it, once confirmed, starts onboarding afresh. */ +@Composable +private fun LossRoute( + navController: NavHostController, + route: NavRoutes, + reason: LossReason, + recoveryViewModel: LossRecoveryViewModel = metroViewModel(), +) { + LossRecoveryScreen( + reason = reason, + onRecoveryConfirm = { + recoveryViewModel.wipeAllData() + navController.navigate(NavRoutes.OnboardingRoute.name) { + popUpTo(route.name) { inclusive = true } + } + }, + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt index b018affd..a994247c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt @@ -15,7 +15,9 @@ enum class NavRoutes { OnboardingRoute, LossDataRoute, LegacyVaultRoute, + StorageUnreadableRoute, PinCodeConfig, + RecoveryChoiceRoute, WipePinRoute, DuressPinRoute, LockRoute, @@ -23,6 +25,7 @@ enum class NavRoutes { AppearanceRoute, LicensesRoute, ImportNotesRoute, + EditorToolbarRoute, } /** Query argument of [NavRoutes.TasksRoute]: open the new-task sheet on arrival. */ diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/RelockReturn.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/RelockReturn.kt similarity index 80% rename from app/src/main/java/com/pasich/encly/presentation/navigation/RelockReturn.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/RelockReturn.kt index c289bd10..117a3ef2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/RelockReturn.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/RelockReturn.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.staticCompositionLocalOf /** * SecurityManager.eraseEpoch, for the editor to record with its note (see [RelockReturn]); - * MainActivity provides it. Without it nothing is recorded, so no note is reopened. + * EnclyApp provides it. Without it nothing is recorded, so no note is reopened. */ val LocalEraseEpoch = staticCompositionLocalOf<(() -> Long)?> { null } @@ -13,7 +13,7 @@ val LocalEraseEpoch = staticCompositionLocalOf<(() -> Long)?> { null } * * The editor records its note's id on its own back-stack entry ([OPEN_NOTE_ID]), including the * id a new note gets on its first save, and the erase epoch of the vault it showed it in - * ([OPEN_NOTE_EPOCH], see SecurityManager.eraseEpoch). When the session re-locks, MainActivity + * ([OPEN_NOTE_EPOCH], see SecurityManager.eraseEpoch). When the session re-locks, EnclyApp * turns that into a route ([routeFor]) and leaves it on the lock screen's entry * ([RETURN_ROUTE], [RETURN_EPOCH]); a PIN or fingerprint unlock opens it again over Home, unless * a wipe-PIN erase changed the epoch since (or the process was restarted, which starts another @@ -25,6 +25,20 @@ object RelockReturn { const val RETURN_ROUTE = "relock_return_route" const val RETURN_EPOCH = "relock_return_epoch" + /** + * The route that reopens the note shown in the home screen's detail pane (the expanded layout), which + * records its id on the home entry the way a full-screen editor records it on its own. Null when the + * destination is not home or no stored note is shown. + */ + fun paneRouteFor(destinationRoute: String?, openNoteId: Long?): String? { + val id = openNoteId?.takeIf { it > 0L } + return if (destinationRoute == NavRoutes.HomeRoute.name && id != null) { + "${NavRoutes.EditNoteRoute.name}/$id" + } else { + null + } + } + /** Set on the reopened editor's entry: it opens over a Home that has no cards yet, so it has none to grow from. */ const val PLAIN_ENTRY = "relock_plain_entry" @@ -40,7 +54,7 @@ object RelockReturn { } } -/** What MainActivity does when it comes back to the foreground (see [afterStop]). */ +/** What EnclyApp does when it comes back to the foreground (see [afterStop]). */ enum class SessionResume { /** Nothing changed that it did not see: its screens are current. */ KEEP, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/AdaptiveHome.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/AdaptiveHome.kt new file mode 100644 index 00000000..05d16205 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/AdaptiveHome.kt @@ -0,0 +1,120 @@ +package com.pasich.encly.presentation.pane + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import com.pasich.encly.data.model.Note +import com.pasich.encly.presentation.drawer.MainPermanentDrawer +import com.pasich.encly.presentation.navigation.LocalEraseEpoch +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.navigation.RelockReturn +import com.pasich.encly.presentation.navigation.TabCommands +import com.pasich.encly.presentation.screen.MainScreen +import com.pasich.encly.presentation.screen.NoteOpener +import com.pasich.encly.presentation.viewmodel.NoteTabsViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.ui.theme.WidthClass +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** The notes in a medium window: a navigation rail instead of the drawer button, the list centred. */ +@Composable +fun MediumMainRoot(navController: NavHostController, modifier: Modifier = Modifier) { + Row(modifier = modifier.fillMaxSize()) { + PageNavigation(WidthClass.Medium, NavRoutes.HomeRoute, navController) + CenteredContent(modifier = Modifier.weight(1f)) { + MainScreen(navController = navController, drawerState = null) + } + } +} + +/** + * The notes in an expanded window: the permanent drawer, the list of notes and, beside it, the open notes + * (see [NoteDetailPane]). A note picked in the list opens in the pane instead of taking the window. + */ +@Composable +fun ExpandedMainRoot( + navController: NavHostController, + modifier: Modifier = Modifier, + tabsViewModel: NoteTabsViewModel = metroViewModel(), +) { + val tabs = tabsViewModel.tabs + val scope = rememberCoroutineScope() + val eraseEpoch = LocalEraseEpoch.current + val opener = remember(tabs, scope, eraseEpoch) { PaneNoteOpener(tabs, scope) { eraseEpoch?.invoke() } } + RememberOpenNoteForRelock(tabs) + TabCommands(tabs) + + Row(modifier = modifier.fillMaxSize()) { + MainPermanentDrawer( + selectedRoute = NavRoutes.HomeRoute.name, + onItemClick = { item -> navController.openDestination(item, from = NavRoutes.HomeRoute) }, + ) + Box(modifier = Modifier.width(EnclyTheme.spacing.listPaneWidth).fillMaxHeight()) { + MainScreen(navController = navController, drawerState = null, noteOpener = opener) + } + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + NoteDetailPane(tabs = tabs, navController = navController, modifier = Modifier.weight(1f)) + } +} + +/** Opens the notes of the list as tabs of the detail pane. */ +private class PaneNoteOpener( + private val tabs: NoteTabs, + private val scope: CoroutineScope, + private val eraseEpoch: () -> Long?, +) : NoteOpener { + override fun editNote(noteId: Long, params: String) { + scope.launch { tabs.open(noteId, params, eraseEpoch()) } + } + + override val supportsTabs: Boolean get() = true + + override val activeNoteId: Long? get() = tabs.active?.noteId?.takeIf { it > 0L } + + override fun openInNewTab(note: Note) { + scope.launch { tabs.openInBackground(note.id, note.title, eraseEpoch()) } + } +} + +/** + * Keeps the id and the vault epoch of the note shown in the pane on the home entry, where the phone's + * editor keeps them on its own: a background re-lock then returns to that note after the unlock (see + * [RelockReturn]). Only the shown note travels, never its content, and only once it is stored. + */ +@Composable +private fun RememberOpenNoteForRelock(tabs: NoteTabs) { + val entry = LocalLifecycleOwner.current as? NavBackStackEntry + val active = tabs.active + val noteId = active?.noteId?.takeIf { it > 0L } + val epoch = active?.epoch + LaunchedEffect(entry, noteId, epoch) { + val state = entry?.savedStateHandle ?: return@LaunchedEffect + if (noteId == null) { + state.remove(RelockReturn.OPEN_NOTE_ID) + state.remove(RelockReturn.OPEN_NOTE_EPOCH) + } else { + state[RelockReturn.OPEN_NOTE_ID] = noteId + if (epoch != + null + ) { + state[RelockReturn.OPEN_NOTE_EPOCH] = epoch + } else { + state.remove(RelockReturn.OPEN_NOTE_EPOCH) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/AdaptivePage.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/AdaptivePage.kt new file mode 100644 index 00000000..67bee8e7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/AdaptivePage.kt @@ -0,0 +1,96 @@ +package com.pasich.encly.presentation.pane + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import com.pasich.encly.presentation.drawer.MainPermanentDrawer +import com.pasich.encly.presentation.drawer.MainRail +import com.pasich.encly.presentation.navigation.DrawerNavItem +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.ui.theme.LocalWidthClass +import com.pasich.encly.ui.theme.WidthClass + +/** + * [content] as a page of the app: in a compact window it fills the window as it always did; in a wider one + * it is held to [com.pasich.encly.ui.theme.EnclySpacing.contentMaxWidth] and centred, so lines of text and + * forms do not stretch across a desktop screen. + */ +@Composable +fun CenteredContent(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val wide = LocalWidthClass.current.isWide + Box( + modifier = if (wide) modifier.fillMaxSize() else modifier, + contentAlignment = Alignment.TopCenter, + ) { + Box( + modifier = if (wide) { + Modifier.widthIn(max = EnclyTheme.spacing.contentMaxWidth).fillMaxHeight() + } else { + Modifier + }, + ) { content() } + } +} + +/** + * A page that belongs to one of the drawer's destinations ([selected]), with the navigation the window size + * calls for: nothing around [content] on a phone (it is reached from the drawer and goes back to it), a + * navigation rail beside it in a medium window, the permanent drawer in an expanded one. + */ +@Composable +fun NavigationPage( + navController: NavHostController, + selected: NavRoutes, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val widthClass = LocalWidthClass.current + Row(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + if (widthClass.isWide) PageNavigation(widthClass, selected, navController) + CenteredContent(modifier = Modifier.weight(1f)) { content() } + } +} + +/** The rail or the permanent drawer for a [widthClass] that is not compact. */ +@Composable +internal fun PageNavigation(widthClass: WidthClass, selected: NavRoutes, navController: NavHostController) { + val onItemClick: (DrawerNavItem) -> Unit = { item -> navController.openDestination(item, from = selected) } + if (widthClass == WidthClass.Expanded) { + MainPermanentDrawer(selectedRoute = selected.name, onItemClick = onItemClick) + } else { + MainRail(selectedRoute = selected.name, onItemClick = onItemClick) + } +} + +/** + * Goes to the destination [item] from the page [from], one of the drawer's. Notes is the bottom of the back + * stack; any other destination replaces the page above it, so the stack never grows with every click on the + * navigation. + */ +internal fun NavHostController.openDestination(item: DrawerNavItem, from: NavRoutes) { + when { + item.route == from.name -> Unit + + item.route == NavRoutes.HomeRoute.name -> popBackStack(NavRoutes.HomeRoute.name, inclusive = false) + + from == NavRoutes.HomeRoute -> navigate( + item.route, + NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true).build(), + ) + + else -> navigate(item.route) { + popUpTo(NavRoutes.HomeRoute.name) { inclusive = false } + launchSingleTop = true + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/NoteDetailPane.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/NoteDetailPane.kt new file mode 100644 index 00000000..d65f1aa7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/NoteDetailPane.kt @@ -0,0 +1,290 @@ +package com.pasich.encly.presentation.pane + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.savedstate.read +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.close +import com.pasich.encly.core.resources.pane_select_note +import com.pasich.encly.core.resources.untitled +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.designsystem.EnclyHorizontalScrollbar +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyTooltip +import com.pasich.encly.presentation.designsystem.LocalEnclyShortcuts +import com.pasich.encly.presentation.designsystem.enclyInteractive +import com.pasich.encly.presentation.designsystem.enclyInteractiveRound +import com.pasich.encly.presentation.designsystem.enclyMiddleClick +import com.pasich.encly.presentation.screen.editnote.EditNoteScreen +import com.pasich.encly.ui.theme.EnclyTheme +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +/** The detail pane's route for the editor of one tab (see [paneEditorRoute]). */ +private const val PANE_EDITOR = "PaneEditor" + +/** The detail pane's route while no note is open. */ +private const val PANE_EMPTY = "PaneEmpty" + +private const val TAB_ARG = "tab" + +/** The test tag of a tab of the strip. */ +internal const val NOTE_TAB_TAG = "note-tab" + +/** + * What the editor of the detail pane needs from its host: the [tabs] and the tab it is the editor of + * ([tabKey]), which it uses to leave (Back closes the tab after saving) and to say what its note is called. + */ +class EditorPane(val tabs: NoteTabs, val tabKey: Long) + +/** The route that opens [tab]'s note in the pane's own navigation host. */ +internal fun paneEditorRoute(tab: NoteTab): String { + val query = if (tab.params.isEmpty()) "?" else "${tab.params}&" + return "$PANE_EDITOR/${tab.noteId}${query}$TAB_ARG=${tab.key}" +} + +/** + * The expanded layout's detail pane: the strip of open notes, and the editor of the one shown (or a prompt + * to pick a note). The editor is the same screen the phone shows full-screen, so its saving and leaving rules + * are unchanged; it runs in a navigation host of its own so that it has the ViewModel, saved state and + * lifecycle of a destination, and leaving a tab pops that destination, which drops the note from memory. + * [navController] is the app's, for what the editor opens beyond the pane (the tag manager). + */ +@Composable +fun NoteDetailPane(tabs: NoteTabs, navController: NavHostController, modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + if (tabs.tabs.isNotEmpty()) { + NoteTabStrip( + tabs = tabs.tabs, + activeKey = tabs.activeKey, + onSelect = { key -> scope.launch { tabs.activate(key) } }, + onClose = tabs::close, + ) + } + // The strip (when there is one) pads the status bar; the editor below must not pad it again. + Box(modifier = Modifier.weight(1f).fillMaxWidth().consumeWindowInsets(WindowInsets.statusBars)) { + PaneEditorHost(tabs = tabs, appNavController = navController) + } + } +} + +/** + * The pane's navigation host: a single destination at a time, the editor of the shown tab or the empty + * prompt. A change of tab replaces the whole back stack, so the editor that was shown is popped and its + * ViewModel cleared. + */ +@Composable +private fun PaneEditorHost(tabs: NoteTabs, appNavController: NavHostController) { + val controller = rememberNavController() + val start = remember { tabs.active?.let(::paneEditorRoute) ?: PANE_EMPTY } + var shownKey by remember { mutableStateOf(tabs.activeKey) } + + NavHost( + navController = controller, + startDestination = start, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + composable(PANE_EMPTY) { EmptyPane() } + composable( + route = "$PANE_EDITOR/{idNote}?copySource={copySource}&addTag={addTag}&$TAB_ARG={$TAB_ARG}", + arguments = listOf( + navArgument("idNote") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("copySource") { + type = NavType.LongType + defaultValue = -1L + }, + navArgument("addTag") { + type = NavType.LongType + defaultValue = 0L + }, + navArgument(TAB_ARG) { + type = NavType.LongType + defaultValue = 0L + }, + ), + ) { entry -> PaneEditor(entry, tabs, appNavController) } + } + + LaunchedEffect(tabs.activeKey) { + if (tabs.activeKey == shownKey) return@LaunchedEffect + shownKey = tabs.activeKey + val route = tabs.active?.let(::paneEditorRoute) ?: PANE_EMPTY + controller.navigate(route) { popUpTo(controller.graph.id) { inclusive = true } } + } +} + +@Composable +private fun PaneEditor(entry: NavBackStackEntry, tabs: NoteTabs, appNavController: NavHostController) { + val tabKey = entry.arguments?.read { getLongOrNull(TAB_ARG) } ?: 0L + EditNoteScreen(navController = appNavController, pane = EditorPane(tabs, tabKey)) +} + +/** What the pane shows while no note is open. */ +@Composable +private fun EmptyPane(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(Res.string.pane_select_note), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * The strip of open notes: one tab each, the shown one marked, a close button on every tab. A tab takes the + * pointer like any control (a hand cursor, a focus ring), closes on a middle click, and the strip shows a + * scrollbar while the tabs do not fit. + */ +@Composable +internal fun NoteTabStrip( + tabs: List, + activeKey: Long?, + onSelect: (Long) -> Unit, + onClose: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val scroll = rememberScrollState() + Box( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .statusBarsPadding() + .height(EnclyTheme.spacing.tabHeight), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + verticalAlignment = Alignment.Bottom, + modifier = Modifier + .fillMaxSize() + .horizontalScroll(scroll) + .padding(horizontal = EnclyTheme.spacing.xs), + ) { + tabs.forEach { tab -> + NoteTabItem( + tab = tab, + selected = tab.key == activeKey, + onSelect = { onSelect(tab.key) }, + onClose = { onClose(tab.key) }, + ) + } + } + EnclyHorizontalScrollbar(scroll) + } +} + +@Composable +private fun NoteTabItem(tab: NoteTab, selected: Boolean, onSelect: () -> Unit, onClose: () -> Unit) { + val colors = MaterialTheme.colorScheme + val title = tab.title.ifBlank { stringResource(Res.string.untitled) } + val closeLabel = "${stringResource(Res.string.close)} $title" + // The shown tab is the one Ctrl+W / ⌘W closes, so its hint names the key. + val closeKey = LocalEnclyShortcuts.current.label(AppCommand.CloseTab) + val closeHint = if (selected) "$closeLabel ($closeKey)" else closeLabel + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + modifier = Modifier + .widthIn(max = EnclyTheme.spacing.tabMaxWidth) + .height(EnclyTheme.spacing.tabHeight) + .background( + color = if (selected) colors.surface else colors.surfaceContainerLow, + shape = MaterialTheme.shapes.small, + ) + .testTag(NOTE_TAB_TAG) + .enclyInteractive(MaterialTheme.shapes.small) + .enclyMiddleClick(onClose) + .clickable(onClick = onSelect) + .semantics(mergeDescendants = true) { + role = Role.Tab + this.selected = selected + } + .padding(start = EnclyTheme.spacing.s, end = EnclyTheme.spacing.xxs), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = if (selected) colors.onSurface else colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // Leaves room for the close button; a weighted Text would collapse in a Row that wraps its content. + modifier = Modifier.widthIn( + max = EnclyTheme.spacing.tabMaxWidth - EnclyTheme.spacing.icon - EnclyTheme.spacing.xs - + EnclyTheme.spacing.s - EnclyTheme.spacing.xxs * 2, + ), + ) + TabCloseButton(label = closeLabel, hint = closeHint, onClose = onClose) + } +} + +/** The close button of a tab: its [label] for assistive technology, its [hint] (with the key) on hover. */ +@Composable +private fun TabCloseButton(label: String, hint: String, onClose: () -> Unit) { + EnclyTooltip(label = hint) { + Icon( + imageVector = EnclyIcons.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(EnclyTheme.spacing.icon + EnclyTheme.spacing.xs) + .enclyInteractiveRound() + .clickable(onClick = onClose) + .padding(EnclyTheme.spacing.xs) + .semantics { + contentDescription = label + role = Role.Button + }, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/NoteTabs.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/NoteTabs.kt new file mode 100644 index 00000000..7be3958c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/pane/NoteTabs.kt @@ -0,0 +1,175 @@ +package com.pasich.encly.presentation.pane + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * One open note of the detail pane. [key] identifies the tab for as long as it exists (a new note has no + * id yet, and two blank notes can be open at once); [noteId] is the stored note's id, or -1 for a note not + * saved yet; [params] are the editor route's query arguments (a copy source, a tag to start with); [title] + * is what the tab shows; [epoch] is the vault's erase epoch when the tab was opened (see RelockReturn). + */ +data class NoteTab( + val key: Long, + val noteId: Long, + val params: String = "", + val title: String = "", + val epoch: Long? = null, +) + +/** + * What the editor shown in the active tab lets the tabs do: save it ([save], true when it is stored or + * nothing needed storing) and close it the way its Back does ([requestClose]: save, then leave). + */ +class ActiveEditor(val save: suspend () -> Boolean, val requestClose: () -> Unit) + +/** + * The notes open in the detail pane of the expanded layout, and which one is shown. Only the active tab has + * an editor (and so its note in memory): leaving a tab saves it, and coming back loads it afresh, so a + * background tab holds no plaintext beyond its title. Everything here lives in the home screen's ViewModel + * and goes with it when the vault locks. + */ +@Stable +class NoteTabs { + private val openTabs = mutableStateListOf() + private var nextKey = 1L + private var editor: ActiveEditor? = null + private var switching = false + + /** The open tabs, in the order they were opened. */ + val tabs: List get() = openTabs + + /** The key of the tab shown, null when no note is open. */ + var activeKey: Long? by mutableStateOf(null) + private set + + /** The tab shown. */ + val active: NoteTab? get() = openTabs.firstOrNull { it.key == activeKey } + + /** Registered by the editor of the active tab while it is composed. */ + fun attach(editor: ActiveEditor) { + this.editor = editor + } + + /** The [editor] left the composition; a newer one that already took its place stays. */ + fun detach(editor: ActiveEditor) { + if (this.editor === editor) this.editor = null + } + + /** + * Shows note [noteId] (-1: a new note, with [params] such as a tag to start with): the tab it already + * has, or a new one. The tab shown until now is saved first; false when that failed, in which case + * nothing changed. With [background] the tab is only added, called [title] until its editor reports its + * own name, and the one shown stays (a browser's background tab); with nothing shown there is nothing to + * stay with, so it is shown. + */ + suspend fun open( + noteId: Long, + params: String = "", + epoch: Long? = null, + title: String = "", + background: Boolean = false, + ): Boolean { + val existing = if (noteId > 0L) openTabs.firstOrNull { it.noteId == noteId } else null + return when { + existing != null -> background || activate(existing.key) + + background && activeKey != null -> { + openTabs.add(NoteTab(key = nextKey++, noteId = noteId, params = params, title = title, epoch = epoch)) + true + } + + else -> switchWith { + val tab = NoteTab(key = nextKey++, noteId = noteId, params = params, title = title, epoch = epoch) + openTabs.add(tab) + activeKey = tab.key + } + } + } + + /** Shows the tab [key], saving the one shown; false when that failed or there is no such tab. */ + suspend fun activate(key: Long): Boolean = when { + key == activeKey -> true + openTabs.none { it.key == key } -> false + else -> switchWith { activeKey = key } + } + + /** + * Closes the tab [key]. The shown tab is closed the way the editor's Back closes it (it saves, then + * calls [remove]); a background tab was saved when it was left, so it just goes. + */ + fun close(key: Long) { + val shown = editor + if (key == activeKey && shown != null) shown.requestClose() else remove(key) + } + + /** Drops the tab [key] without saving (its editor did, or it never had one); the neighbour is shown. */ + fun remove(key: Long) { + val index = openTabs.indexOfFirst { it.key == key } + if (index < 0) return + openTabs.removeAt(index) + if (key == activeKey) activeKey = openTabs.getOrNull(index - 1)?.key ?: openTabs.firstOrNull()?.key + } + + /** Replaces the tab [key] by a tab of note [noteId] (a copy made from it), in the same place and shown. */ + fun replace(key: Long, noteId: Long) { + val index = openTabs.indexOfFirst { it.key == key } + if (index < 0) return + val tab = NoteTab(key = nextKey++, noteId = noteId, epoch = openTabs[index].epoch) + openTabs[index] = tab + if (key == activeKey) activeKey = tab.key + } + + /** The editor of the tab [key] says which stored note it holds and what to call it. */ + fun report(key: Long, noteId: Long, title: String) { + val index = openTabs.indexOfFirst { it.key == key } + if (index < 0) return + val tab = openTabs[index] + // An editor that has not loaded its stored note yet shows a blank new one: that is not what the tab holds. + if (noteId <= 0L && tab.noteId > 0L) return + if (tab.noteId != noteId || tab.title != title) openTabs[index] = tab.copy(noteId = noteId, title = title) + } + + /** Closes every tab without saving: the vault locked and the editors dropped their content. */ + fun clear() { + openTabs.clear() + activeKey = null + editor = null + } + + // Saves the tab shown and runs [change]; a note that was never stored (a blank new one) leaves no tab. + private suspend fun switchWith(change: () -> Unit): Boolean { + if (switching) return false + switching = true + try { + val leaving = active + val saved = leaving == null || editor?.save?.invoke() != false + if (saved) { + // The editor reported the stored id while saving; a note that is still unsaved was blank. + openTabs.firstOrNull { it.key == leaving?.key && it.noteId <= 0L }?.let(openTabs::remove) + change() + } + return saved + } finally { + switching = false + } + } +} + +/** + * Adds a tab for the stored note [noteId] without leaving the tab shown; a note that has a tab, or is not + * stored yet, gets none. See [NoteTabs.open]. + */ +suspend fun NoteTabs.openInBackground(noteId: Long, title: String, epoch: Long? = null): Boolean = + noteId <= 0L || open(noteId, epoch = epoch, title = title, background = true) + +/** Shows the next tab ([forward]) or the previous one, wrapping round; false with fewer than two tabs. */ +suspend fun NoteTabs.cycle(forward: Boolean): Boolean { + val index = tabs.indexOfFirst { it.key == activeKey } + if (tabs.size < 2 || index < 0) return false + val step = if (forward) 1 else -1 + return activate(tabs[(index + step + tabs.size) % tabs.size].key) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/AboutScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/AboutScreen.kt new file mode 100644 index 00000000..91683758 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/AboutScreen.kt @@ -0,0 +1,226 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import com.pasich.encly.core.LINK_FEEDBACK +import com.pasich.encly.core.LINK_PRIVACY_POLICY +import com.pasich.encly.core.LINK_REPOSITORY +import com.pasich.encly.core.MAIL_DEVELOPMENT +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.about +import com.pasich.encly.core.resources.about_app_description +import com.pasich.encly.core.resources.about_app_title +import com.pasich.encly.core.resources.about_data_protection +import com.pasich.encly.core.resources.about_encryption_note +import com.pasich.encly.core.resources.about_feedback +import com.pasich.encly.core.resources.about_feedback_desc +import com.pasich.encly.core.resources.about_no_browser +import com.pasich.encly.core.resources.about_no_mail_client +import com.pasich.encly.core.resources.about_privacy_policy +import com.pasich.encly.core.resources.about_privacy_policy_desc +import com.pasich.encly.core.resources.about_rate_app +import com.pasich.encly.core.resources.about_rate_app_desc +import com.pasich.encly.core.resources.about_source_code +import com.pasich.encly.core.resources.about_source_code_desc +import com.pasich.encly.core.resources.about_support_dev_desc +import com.pasich.encly.core.resources.about_support_title +import com.pasich.encly.core.resources.about_write_developer +import com.pasich.encly.core.resources.about_write_developer_desc +import com.pasich.encly.core.resources.licenses_row_desc +import com.pasich.encly.core.resources.licenses_title +import com.pasich.encly.core.resources.main_drawer_support +import com.pasich.encly.core.resources.whats_new_row_desc +import com.pasich.encly.core.resources.whats_new_title +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.ExternalLinks +import com.pasich.encly.platform.LocalAppInfo +import com.pasich.encly.platform.LocalExternalLinks +import com.pasich.encly.platform.LocalToaster +import com.pasich.encly.platform.ToastDuration +import com.pasich.encly.platform.Toaster +import com.pasich.encly.presentation.designsystem.EnclyCallout +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.EnclyNavigationRow +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.EnclyWordmark +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.dialogs.WhatsNewSheet +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.utils.currentLanguageTag +import com.pasich.encly.whatsnew.Changelogs +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AboutScreen(navController: NavHostController, modifier: Modifier = Modifier) { + val links = LocalExternalLinks.current + val toaster = LocalToaster.current + // The notes of this build, in the app's language; a build with no changelog has no entry. + val languageTag = currentLanguageTag() + val whatsNew = remember(languageTag) { Changelogs.current(languageTag) } + var whatsNewOpen by remember { mutableStateOf(false) } + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { EnclyTopBar(title = stringResource(Res.string.about), onBack = { navController.popBackStack() }) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), + ) { + HeroSection() + EnclyCallout( + title = stringResource(Res.string.about_data_protection), + text = stringResource(Res.string.about_encryption_note), + icon = EnclyIcons.Lock, + ) + DeveloperActionsSection(links, toaster, onSupport = { navController.navigate(NavRoutes.SupportRoute.name) }) + EnclyGroup { + if (whatsNew != null) { + EnclyNavigationRow( + title = stringResource(Res.string.whats_new_title), + supporting = stringResource(Res.string.whats_new_row_desc), + icon = EnclyIcons.Info, + onClick = { whatsNewOpen = true }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + EnclyGroupDivider() + } + EnclyNavigationRow( + title = stringResource(Res.string.licenses_title), + supporting = stringResource(Res.string.licenses_row_desc), + icon = EnclyIcons.File, + onClick = { navController.navigate(NavRoutes.LicensesRoute.name) }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + } + if (whatsNewOpen && whatsNew != null) WhatsNewSheet(whatsNew, onDismiss = { whatsNewOpen = false }) + } + } +} + +@Composable +private fun HeroSection() { + val versionName = LocalAppInfo.current.versionName + + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + Row(verticalAlignment = Alignment.CenterVertically) { + EnclyWordmark(appName = stringResource(Res.string.about_app_title), modifier = Modifier.weight(1f)) + Text( + text = "v$versionName", + style = EnclyTheme.typography.dataSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = stringResource(Res.string.about_app_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun DeveloperActionsSection(links: ExternalLinks, toaster: Toaster, onSupport: () -> Unit) { + val info = LocalAppInfo.current + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(stringResource(Res.string.about_support_title)) + EnclyGroup { + val rowModifier = Modifier.padding(horizontal = EnclyTheme.spacing.s) + EnclyNavigationRow( + title = stringResource(Res.string.about_privacy_policy), + supporting = stringResource(Res.string.about_privacy_policy_desc), + icon = EnclyIcons.Privacy, + onClick = { openExternalLink(links, toaster, LINK_PRIVACY_POLICY) }, + modifier = rowModifier, + ) + // Rate App: store builds only. The fdroid flavor carries no Google Play link. + if (info.storeRatingEnabled) { + EnclyGroupDivider() + EnclyNavigationRow( + title = stringResource(Res.string.about_rate_app), + supporting = stringResource(Res.string.about_rate_app_desc), + icon = EnclyIcons.Star, + onClick = { openStorePage(links, toaster, info) }, + modifier = rowModifier, + ) + } + // Support the developer: fdroid only, same page as the drawer entry. + if (info.donationsEnabled) { + EnclyGroupDivider() + EnclyNavigationRow( + title = stringResource(Res.string.main_drawer_support), + supporting = stringResource(Res.string.about_support_dev_desc), + icon = EnclyIcons.Coffee, + onClick = onSupport, + modifier = rowModifier, + ) + } + EnclyGroupDivider() + // The code is public: a plain link in every flavor, not a donation or store link. + EnclyNavigationRow( + title = stringResource(Res.string.about_source_code), + supporting = stringResource(Res.string.about_source_code_desc), + icon = EnclyIcons.External, + onClick = { openExternalLink(links, toaster, LINK_REPOSITORY) }, + modifier = rowModifier, + ) + EnclyGroupDivider() + // Feedback: public GitHub issue tracker, no third-party form service. + EnclyNavigationRow( + title = stringResource(Res.string.about_feedback), + supporting = stringResource(Res.string.about_feedback_desc), + icon = EnclyIcons.Send, + onClick = { openExternalLink(links, toaster, LINK_FEEDBACK) }, + modifier = rowModifier, + ) + EnclyGroupDivider() + EnclyNavigationRow( + title = stringResource(Res.string.about_write_developer), + supporting = stringResource(Res.string.about_write_developer_desc), + icon = EnclyIcons.Mail, + onClick = { writeToDeveloper(links, toaster) }, + modifier = rowModifier, + ) + } + } +} + +private fun openStorePage(links: ExternalLinks, toaster: Toaster, info: AppInfo) { + // The store app's own link first; a device without one gets the store's web page in the browser. + val opened = info.storePageLinks.any { links.openUrl(it) } + if (!opened) { + toaster.show(Res.string.about_no_browser, ToastDuration.LONG, info.storePageLinks.lastOrNull().orEmpty()) + } +} + +private fun writeToDeveloper(links: ExternalLinks, toaster: Toaster) { + if (!links.writeMail(MAIL_DEVELOPMENT)) toaster.show(Res.string.about_no_mail_client, ToastDuration.SHORT) +} + +/** Opens [url] in the user's browser; Encly itself never performs the request. */ +internal fun openExternalLink(links: ExternalLinks, toaster: Toaster, url: String) { + if (!links.openUrl(url)) toaster.show(Res.string.about_no_browser, ToastDuration.LONG, url) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/EditTagScreen.kt similarity index 83% rename from app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/EditTagScreen.kt index 2494c46a..7405b8b2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/EditTagScreen.kt @@ -1,7 +1,5 @@ package com.pasich.encly.presentation.screen -import android.content.Context -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -33,17 +31,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState 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.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions @@ -56,10 +51,31 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import com.pasich.encly.R import com.pasich.encly.core.common.LoadState +import com.pasich.encly.core.common.asString +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.create_new_tag +import com.pasich.encly.core.resources.delete_tag +import com.pasich.encly.core.resources.main_drawer_tags +import com.pasich.encly.core.resources.save +import com.pasich.encly.core.resources.tag_add +import com.pasich.encly.core.resources.tag_create_failed +import com.pasich.encly.core.resources.tag_delete_failed +import com.pasich.encly.core.resources.tag_drag_handle +import com.pasich.encly.core.resources.tag_edit +import com.pasich.encly.core.resources.tag_hidden +import com.pasich.encly.core.resources.tag_move_down +import com.pasich.encly.core.resources.tag_move_up +import com.pasich.encly.core.resources.tag_name_label +import com.pasich.encly.core.resources.tag_name_taken +import com.pasich.encly.core.resources.tag_reorder_failed +import com.pasich.encly.core.resources.tag_show_in_list +import com.pasich.encly.core.resources.tag_show_in_list_desc +import com.pasich.encly.core.resources.tag_update_failed +import com.pasich.encly.core.resources.tags_empty_desc +import com.pasich.encly.core.resources.tags_empty_title +import com.pasich.encly.core.resources.tags_hint import com.pasich.encly.data.model.Tag -import com.pasich.encly.presentation.designsystem.EnclyBottomSheet import com.pasich.encly.presentation.designsystem.EnclyButton import com.pasich.encly.presentation.designsystem.EnclyEmptyState import com.pasich.encly.presentation.designsystem.EnclyGroupDivider @@ -74,16 +90,20 @@ import com.pasich.encly.presentation.designsystem.FieldState import com.pasich.encly.presentation.designsystem.RowSkeleton import com.pasich.encly.presentation.designsystem.SectionOverline import com.pasich.encly.presentation.designsystem.groupRowShape +import com.pasich.encly.presentation.dialogs.AdaptiveSheet import com.pasich.encly.presentation.dialogs.RequestDeleteTagsDialog +import com.pasich.encly.presentation.dialogs.rememberSheetDismisser 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 -import kotlinx.coroutines.launch +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyListState @@ -102,7 +122,7 @@ private val TagNameKeyboard = KeyboardOptions(keyboardType = KeyboardType.Passwo fun EditTagScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: TagListViewModel = enclyViewModel(), + viewModel: TagListViewModel = metroViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val counts by viewModel.noteCounts.collectAsStateWithLifecycle() @@ -110,15 +130,14 @@ fun EditTagScreen( var editingId by rememberSaveable { mutableStateOf(null) } var deleting by remember { mutableStateOf(null) } - val context = LocalContext.current - LaunchedEffect(viewModel) { showTagFailures(viewModel, snackbarHostState, context) } + LaunchedEffect(viewModel) { showTagFailures(viewModel, snackbarHostState) } Scaffold( modifier = modifier, containerColor = MaterialTheme.colorScheme.surface, snackbarHost = { EnclySnackbarHost(snackbarHostState) }, topBar = { - EnclyTopBar(title = stringResource(R.string.main_drawer_tags), onBack = navController::popBackStack) + EnclyTopBar(title = stringResource(Res.string.main_drawer_tags), onBack = navController::popBackStack) }, ) { padding -> TagsContent( @@ -163,16 +182,12 @@ fun EditTagScreen( } /** Failed tag operations as snackbars; a taken name is shown under the field that typed it. */ -private suspend fun showTagFailures( - viewModel: TagListViewModel, - snackbarHostState: SnackbarHostState, - context: Context, -) { +private suspend fun showTagFailures(viewModel: TagListViewModel, snackbarHostState: SnackbarHostState) { viewModel.operationFailures.collect { failure -> if (failure != TagOperationFailure.NAME_TAKEN ) { - snackbarHostState.showSnackbar(context.getString(failure.message())) + snackbarHostState.showSnackbar(getString(failure.message())) } } } @@ -232,8 +247,8 @@ private fun TagsContent(state: TagsScreenState, actions: TagsActions, modifier: item(key = "empty") { EnclyEmptyState( icon = EnclyIcons.Tag, - title = stringResource(R.string.tags_empty_title), - body = stringResource(R.string.tags_empty_desc), + title = stringResource(Res.string.tags_empty_title), + body = stringResource(Res.string.tags_empty_desc), ) } } else { @@ -255,9 +270,9 @@ private fun LazyListScope.tagRows( verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), modifier = Modifier.padding(top = EnclyTheme.spacing.l, bottom = EnclyTheme.spacing.s), ) { - SectionOverline(stringResource(R.string.main_drawer_tags)) + SectionOverline(stringResource(Res.string.main_drawer_tags)) Text( - text = stringResource(R.string.tags_hint), + text = stringResource(Res.string.tags_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -304,12 +319,12 @@ private class TagMove(val up: (() -> Unit)?, val down: (() -> Unit)?, val onDrag /** A tag: its icon, name ("Hidden tag" under it when hidden), note count and the drag handle. */ @Composable private fun ReorderableCollectionItemScope.TagRow(tag: Tag, count: Int, onOpen: () -> Unit, move: TagMove) { - val moveUp = stringResource(R.string.tag_move_up) - val moveDown = stringResource(R.string.tag_move_down) - val handleDescription = stringResource(R.string.tag_drag_handle) + val moveUp = stringResource(Res.string.tag_move_up) + val moveDown = stringResource(Res.string.tag_move_down) + val handleDescription = stringResource(Res.string.tag_drag_handle) EnclyListRow( title = tag.nameTag, - supporting = stringResource(R.string.tag_hidden).takeUnless { tag.isVisible }, + supporting = stringResource(Res.string.tag_hidden).takeUnless { tag.isVisible }, icon = EnclyIcons.Tag, onClick = onOpen, modifier = Modifier @@ -379,14 +394,14 @@ private fun NewTagField(taken: (String) -> Boolean, onCreate: (String, (Boolean) EnclyTextField( value = text, onValueChange = { text = it.take(TAG_NAME_MAX_LENGTH) }, - label = stringResource(R.string.create_new_tag), - state = if (isTaken) FieldState.Error(stringResource(R.string.tag_name_taken)) else FieldState.Default, + label = stringResource(Res.string.create_new_tag), + state = if (isTaken) FieldState.Error(stringResource(Res.string.tag_name_taken)) else FieldState.Default, keyboardOptions = TagNameKeyboard, keyboardActions = KeyboardActions(onDone = { create() }), ) AnimatedVisibility(visible = name.isNotEmpty()) { EnclyButton( - text = stringResource(R.string.tag_add), + text = stringResource(Res.string.tag_add), onClick = ::create, enabled = !isTaken, modifier = Modifier.fillMaxWidth(), @@ -408,7 +423,7 @@ private class TagSheetActions( @Composable private fun TagSheet(tag: Tag, taken: (String) -> Boolean, actions: TagSheetActions) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() + val dismiss = rememberSheetDismisser(sheetState, actions.onDismiss) var text by remember(tag.id) { mutableStateOf(tag.nameTag) } val name = normalizeTagName(text) val isTaken = name.isNotEmpty() && taken(name) @@ -417,39 +432,39 @@ private fun TagSheet(tag: Tag, taken: (String) -> Boolean, actions: TagSheetActi fun save() { if (!canSave) return actions.onRename(tag.copy(nameTag = name)) { saved -> - if (saved) scope.launch { sheetState.hide() }.invokeOnCompletion { actions.onDismiss() } + if (saved) dismiss() } } SaveOnPause { save() } - EnclyBottomSheet( + AdaptiveSheet( onDismissRequest = actions.onDismiss, sheetState = sheetState, - title = stringResource(R.string.tag_edit), + title = stringResource(Res.string.tag_edit), ) { EnclyTextField( value = text, onValueChange = { text = it.take(TAG_NAME_MAX_LENGTH) }, - label = stringResource(R.string.tag_name_label), - state = if (isTaken) FieldState.Error(stringResource(R.string.tag_name_taken)) else FieldState.Default, + label = stringResource(Res.string.tag_name_label), + state = if (isTaken) FieldState.Error(stringResource(Res.string.tag_name_taken)) else FieldState.Default, keyboardOptions = TagNameKeyboard, keyboardActions = KeyboardActions(onDone = { save() }), ) EnclySwitchRow( - title = stringResource(R.string.tag_show_in_list), - supporting = stringResource(R.string.tag_show_in_list_desc), + title = stringResource(Res.string.tag_show_in_list), + supporting = stringResource(Res.string.tag_show_in_list_desc), checked = tag.isVisible, onCheckedChange = { actions.onToggleVisible() }, ) EnclySheetRow( - title = stringResource(R.string.delete_tag), + title = stringResource(Res.string.delete_tag), icon = EnclyIcons.Trash, destructive = true, onClick = actions.onDelete, ) EnclyButton( - text = stringResource(R.string.save), + text = stringResource(Res.string.save), onClick = ::save, enabled = canSave, modifier = Modifier @@ -460,13 +475,12 @@ private fun TagSheet(tag: Tag, taken: (String) -> Boolean, actions: TagSheetActi } /** What the user is told when a tag operation fails. */ -@StringRes -private fun TagOperationFailure.message(): Int = when (this) { - TagOperationFailure.CREATE -> R.string.tag_create_failed - TagOperationFailure.UPDATE -> R.string.tag_update_failed - TagOperationFailure.DELETE -> R.string.tag_delete_failed - TagOperationFailure.REORDER -> R.string.tag_reorder_failed - TagOperationFailure.NAME_TAKEN -> R.string.tag_name_taken +private fun TagOperationFailure.message(): StringResource = when (this) { + TagOperationFailure.CREATE -> Res.string.tag_create_failed + TagOperationFailure.UPDATE -> Res.string.tag_update_failed + TagOperationFailure.DELETE -> Res.string.tag_delete_failed + TagOperationFailure.REORDER -> Res.string.tag_reorder_failed + TagOperationFailure.NAME_TAKEN -> Res.string.tag_name_taken } /** diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/FaqScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/FaqScreen.kt new file mode 100644 index 00000000..3341d10a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/FaqScreen.kt @@ -0,0 +1,161 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.ui.Modifier +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.about_faq_title +import com.pasich.encly.core.resources.faq_background_a +import com.pasich.encly.core.resources.faq_background_q +import com.pasich.encly.core.resources.faq_backup_a +import com.pasich.encly.core.resources.faq_backup_q +import com.pasich.encly.core.resources.faq_biometrics_a +import com.pasich.encly.core.resources.faq_biometrics_q +import com.pasich.encly.core.resources.faq_change_pin_a +import com.pasich.encly.core.resources.faq_change_pin_q +import com.pasich.encly.core.resources.faq_db_key_a +import com.pasich.encly.core.resources.faq_db_key_q +import com.pasich.encly.core.resources.faq_forgot_pin_a +import com.pasich.encly.core.resources.faq_forgot_pin_q +import com.pasich.encly.core.resources.faq_language_a +import com.pasich.encly.core.resources.faq_language_q +import com.pasich.encly.core.resources.faq_lock_note_a +import com.pasich.encly.core.resources.faq_lock_note_q +import com.pasich.encly.core.resources.faq_markdown_a +import com.pasich.encly.core.resources.faq_markdown_q +import com.pasich.encly.core.resources.faq_new_note_a +import com.pasich.encly.core.resources.faq_new_note_q +import com.pasich.encly.core.resources.faq_note_actions_a +import com.pasich.encly.core.resources.faq_note_actions_q +import com.pasich.encly.core.resources.faq_pin_length_a +import com.pasich.encly.core.resources.faq_pin_length_q +import com.pasich.encly.core.resources.faq_recovery_phrase_a +import com.pasich.encly.core.resources.faq_recovery_phrase_q +import com.pasich.encly.core.resources.faq_report_bug_a +import com.pasich.encly.core.resources.faq_report_bug_q +import com.pasich.encly.core.resources.faq_saving_a +import com.pasich.encly.core.resources.faq_saving_q +import com.pasich.encly.core.resources.faq_screen_description +import com.pasich.encly.core.resources.faq_screen_subtitle +import com.pasich.encly.core.resources.faq_screenshots_a +import com.pasich.encly.core.resources.faq_screenshots_q +import com.pasich.encly.core.resources.faq_section_basics +import com.pasich.encly.core.resources.faq_section_security +import com.pasich.encly.core.resources.faq_section_settings +import com.pasich.encly.core.resources.faq_section_troubleshooting +import com.pasich.encly.core.resources.faq_storage_a +import com.pasich.encly.core.resources.faq_storage_q +import com.pasich.encly.core.resources.faq_swipe_a +import com.pasich.encly.core.resources.faq_swipe_q +import com.pasich.encly.core.resources.faq_tags_a +import com.pasich.encly.core.resources.faq_tags_q +import com.pasich.encly.core.resources.faq_tasks_a +import com.pasich.encly.core.resources.faq_tasks_q +import com.pasich.encly.core.resources.faq_theme_a +import com.pasich.encly.core.resources.faq_theme_q +import com.pasich.encly.core.resources.faq_trash_a +import com.pasich.encly.core.resources.faq_trash_q +import com.pasich.encly.core.resources.faq_vault_damaged_a +import com.pasich.encly.core.resources.faq_vault_damaged_q +import com.pasich.encly.presentation.designsystem.EnclyExpandableRow +import com.pasich.encly.presentation.designsystem.EnclyGroup +import com.pasich.encly.presentation.designsystem.EnclyGroupDivider +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** One FAQ section: its title and its question/answer pairs. */ +private class FaqSection(val title: StringResource, val items: List>) + +private val faqSections = listOf( + FaqSection( + Res.string.faq_section_basics, + listOf( + Res.string.faq_new_note_q to Res.string.faq_new_note_a, + Res.string.faq_saving_q to Res.string.faq_saving_a, + Res.string.faq_note_actions_q to Res.string.faq_note_actions_a, + Res.string.faq_lock_note_q to Res.string.faq_lock_note_a, + Res.string.faq_trash_q to Res.string.faq_trash_a, + Res.string.faq_tags_q to Res.string.faq_tags_a, + Res.string.faq_tasks_q to Res.string.faq_tasks_a, + ), + ), + FaqSection( + Res.string.faq_section_security, + listOf( + Res.string.faq_storage_q to Res.string.faq_storage_a, + Res.string.faq_backup_q to Res.string.faq_backup_a, + Res.string.faq_markdown_q to Res.string.faq_markdown_a, + Res.string.faq_swipe_q to Res.string.faq_swipe_a, + Res.string.faq_db_key_q to Res.string.faq_db_key_a, + Res.string.faq_recovery_phrase_q to Res.string.faq_recovery_phrase_a, + Res.string.faq_forgot_pin_q to Res.string.faq_forgot_pin_a, + Res.string.faq_pin_length_q to Res.string.faq_pin_length_a, + Res.string.faq_screenshots_q to Res.string.faq_screenshots_a, + Res.string.faq_background_q to Res.string.faq_background_a, + ), + ), + FaqSection( + Res.string.faq_section_settings, + listOf( + Res.string.faq_change_pin_q to Res.string.faq_change_pin_a, + Res.string.faq_biometrics_q to Res.string.faq_biometrics_a, + Res.string.faq_theme_q to Res.string.faq_theme_a, + Res.string.faq_language_q to Res.string.faq_language_a, + ), + ), + FaqSection( + Res.string.faq_section_troubleshooting, + listOf( + Res.string.faq_vault_damaged_q to Res.string.faq_vault_damaged_a, + Res.string.faq_report_bug_q to Res.string.faq_report_bug_a, + ), + ), +) + +@Composable +fun FaqScreen(navController: NavHostController, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar(title = stringResource(Res.string.about_faq_title), onBack = { navController.popBackStack() }) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), + ) { + StepHeading( + title = stringResource(Res.string.faq_screen_subtitle), + body = stringResource(Res.string.faq_screen_description), + ) + faqSections.forEach { section -> + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(stringResource(section.title)) + EnclyGroup { + section.items.forEachIndexed { index, (question, answer) -> + if (index > 0) EnclyGroupDivider() + EnclyExpandableRow(question = stringResource(question), answer = stringResource(answer)) + } + } + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LicensesScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LicensesScreen.kt new file mode 100644 index 00000000..79402f63 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LicensesScreen.kt @@ -0,0 +1,125 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.licenses_fonts +import com.pasich.encly.core.resources.licenses_heading +import com.pasich.encly.core.resources.licenses_intro +import com.pasich.encly.core.resources.licenses_libraries +import com.pasich.encly.core.resources.licenses_title +import com.pasich.encly.platform.LocalExternalLinks +import com.pasich.encly.platform.LocalExtraLicenses +import com.pasich.encly.platform.LocalToaster +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.EnclyTopBar +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** A third-party work Encly ships: its name, its licence and where to read both. */ +private class OpenSourceWork(val name: String, val license: String, val url: String) + +private const val APACHE_2 = "Apache License 2.0" +private const val OFL = "SIL Open Font License 1.1" + +private class LicenseSection(val title: StringResource, val works: List) + +private val bundledFonts = listOf( + OpenSourceWork("Playfair Display", OFL, "https://github.com/clauseggers/Playfair"), + OpenSourceWork("Source Sans 3", OFL, "https://github.com/adobe-fonts/source-sans"), + OpenSourceWork("IBM Plex Sans", OFL, "https://github.com/IBM/plex"), + OpenSourceWork("Poppins", OFL, "https://github.com/itfoundry/Poppins"), +) + +private val bundledLibraries = listOf( + OpenSourceWork("AndroidX and Jetpack Compose", APACHE_2, "https://github.com/androidx/androidx"), + OpenSourceWork("Kotlin and kotlinx", APACHE_2, "https://github.com/JetBrains/kotlin"), + OpenSourceWork("Compose Multiplatform", APACHE_2, "https://github.com/JetBrains/compose-multiplatform"), + OpenSourceWork("Metro", APACHE_2, "https://github.com/ZacSweers/metro"), + OpenSourceWork("Sealite", APACHE_2, "https://github.com/pasichDev/sealite"), + OpenSourceWork("SQLCipher Community Edition", "BSD 3-Clause", "https://github.com/sqlcipher/sqlcipher"), + OpenSourceWork("OpenSSL", APACHE_2, "https://github.com/openssl/openssl"), + OpenSourceWork( + "BIP-39 English wordlist", + "MIT License", + "https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt", + ), + OpenSourceWork("Okio", APACHE_2, "https://github.com/square/okio"), + OpenSourceWork("Guava ListenableFuture", APACHE_2, "https://github.com/google/guava"), + OpenSourceWork("Reorderable", APACHE_2, "https://github.com/Calvin-LL/Reorderable"), + OpenSourceWork("Lucide", "ISC License", "https://github.com/lucide-icons/lucide"), + OpenSourceWork("Compose Icons for Lucide", "MIT License", "https://github.com/composablehorizons/composeicons"), +) + +/** The fonts and libraries Encly is built with, each linking to its licence. */ +@Composable +fun LicensesScreen(navController: NavHostController, modifier: Modifier = Modifier) { + val links = LocalExternalLinks.current + val toaster = LocalToaster.current + val extra = LocalExtraLicenses.current.map { OpenSourceWork(it.name, it.license, it.url) } + val sections = listOf( + LicenseSection(Res.string.licenses_libraries, bundledLibraries + extra), + LicenseSection(Res.string.licenses_fonts, bundledFonts), + ) + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar(title = stringResource(Res.string.licenses_title), onBack = { navController.popBackStack() }) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), + ) { + StepHeading( + title = stringResource(Res.string.licenses_heading), + body = stringResource(Res.string.licenses_intro), + ) + sections.forEach { section -> + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(stringResource(section.title)) + EnclyGroup { + section.works.forEachIndexed { index, work -> + if (index > 0) EnclyGroupDivider() + EnclyListRow( + title = work.name, + supporting = work.license, + onClick = { openExternalLink(links, toaster, work.url) }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) { + Icon( + EnclyIcons.External, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockExits.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockExits.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/screen/LockExits.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockExits.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockFormState.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockFormState.kt new file mode 100644 index 00000000..e2336d90 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockFormState.kt @@ -0,0 +1,136 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.error_database_open +import com.pasich.encly.core.resources.lock_device_locked +import com.pasich.encly.core.resources.lock_pin_key_lost +import com.pasich.encly.core.resources.lock_wrong_pin +import com.pasich.encly.core.resources.lock_wrong_recovery_phrase +import com.pasich.encly.presentation.designsystem.RecoveryPhraseState +import com.pasich.encly.presentation.screen.pincode.PinBuffer +import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft +import com.pasich.encly.presentation.viewmodel.PinUnlockResult +import com.pasich.encly.presentation.viewmodel.SeedUnlockResult +import org.jetbrains.compose.resources.StringResource + +/** + * What the lock screen's forms show: the PIN typed so far, the recovery phrase, and the last + * error of each. Held by [LockScreen] itself, above the "Unlocking…" view that replaces the + * forms while a credential is checked, so a wrong PIN or phrase is still shown afterwards + * instead of being lost with the form that asked for it. + */ +@Stable +@Suppress("TooManyFunctions") // One small handler per field of the PIN and phrase forms. +internal class LockFormState { + /** The recovery-phrase form is shown instead of the PIN pad. */ + var useRecovery by mutableStateOf(false) + + /** How many digits the PIN had when it was sent to be checked: the dots stay full meanwhile. */ + var submittedLength by mutableIntStateOf(0) + private set + + /** The digits typed so far, wiped when taken or cleared (see [PinBuffer]). */ + private val pinDigits = PinBuffer() + + /** How many PIN digits are typed. */ + val pinLength: Int get() = pinDigits.length + + var pinError by mutableStateOf(null) + private set + + /** Changes on every wrong PIN, so the dots shake again. */ + var shakeKey by mutableIntStateOf(0) + private set + + var lockoutSeconds by mutableLongStateOf(0L) + + /** The recovery words typed so far. */ + val phrase = RecoveryPhraseState() + + var phraseError by mutableStateOf(null) + private set + + val lockedOut: Boolean get() = lockoutSeconds > 0L + + /** A digit on the keypad; the first digit of a new attempt clears the last error. */ + fun typeDigit(digit: Int) { + if (pinDigits.isFull || lockedOut || digit !in 0..MAX_DIGIT) return + if (pinLength == 0) pinError = null + pinDigits.add(digit) + } + + fun deleteDigit() = pinDigits.deleteLast() + + /** Enough digits are typed to be a PIN; the Unlock button is on from here, nothing is sent by itself. */ + val canSubmitPin: Boolean get() = pinDigits.canSubmit + + /** The PIN, taken out of the form to be checked. The caller wipes the copy. */ + fun takePin(): CharArray { + submittedLength = pinDigits.length + return pinDigits.take() + } + + /** Forgets the typed digits. */ + fun clearPin() = pinDigits.clear() + + fun onPinResult(result: PinUnlockResult, lockoutRemainingMillis: Long) { + when (result) { + PinUnlockResult.WRONG_PIN -> { + pinError = Res.string.lock_wrong_pin + shakeKey++ + lockoutSeconds = lockoutSecondsLeft(lockoutRemainingMillis) + } + + PinUnlockResult.DB_ERROR -> pinError = Res.string.error_database_open + + PinUnlockResult.DEVICE_LOCKED -> pinError = Res.string.lock_device_locked + + PinUnlockResult.LOCKED_OUT -> lockoutSeconds = lockoutSecondsLeft(lockoutRemainingMillis) + + // The PIN can no longer unlock here: the recovery form says why, when there is one. + PinUnlockResult.KEY_LOST -> { + pinError = Res.string.lock_pin_key_lost + phraseError = Res.string.lock_pin_key_lost + } + + PinUnlockResult.SUCCESS, PinUnlockResult.BACKGROUNDED -> Unit + } + } + + /** Any edit of the words clears the last error. */ + fun onPhraseEdited() { + phraseError = null + } + + /** System back: from the recovery form to the PIN pad; the PIN pad itself stays. */ + fun back() { + if (useRecovery) leaveRecovery() + } + + /** Back to the PIN pad; the typed words are dropped. */ + fun leaveRecovery() { + useRecovery = false + phrase.clear() + phraseError = null + } + + /** A wrong phrase stays in the field, with the error under it, so it can be corrected. */ + fun onSeedResult(result: SeedUnlockResult) { + when (result) { + SeedUnlockResult.WRONG_SEED -> phraseError = Res.string.lock_wrong_recovery_phrase + SeedUnlockResult.DB_ERROR -> phraseError = Res.string.error_database_open + SeedUnlockResult.DEVICE_LOCKED -> phraseError = Res.string.lock_device_locked + SeedUnlockResult.SUCCESS, SeedUnlockResult.BACKGROUNDED -> Unit + } + } + + private companion object { + const val MAX_DIGIT = 9 + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockForms.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockForms.kt new file mode 100644 index 00000000..bc27ee00 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockForms.kt @@ -0,0 +1,235 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_back_to_pin +import com.pasich.encly.core.resources.lock_recover_access +import com.pasich.encly.core.resources.lock_recovery_subtitle +import com.pasich.encly.core.resources.lock_recovery_title +import com.pasich.encly.core.resources.lock_subtitle +import com.pasich.encly.core.resources.lock_title +import com.pasich.encly.core.resources.lock_unlock +import com.pasich.encly.core.resources.lock_unlocking +import com.pasich.encly.core.resources.lock_use_recovery_phrase +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput +import com.pasich.encly.presentation.screen.pincode.AuthLoading +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.PinSubmit +import com.pasich.encly.presentation.screen.pincode.pinLockoutText +import com.pasich.encly.presentation.viewmodel.PinUnlockResult +import com.pasich.encly.presentation.viewmodel.SeedUnlockResult +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +/** What the lock screen offers besides the form on show: the fingerprint key and the recovery phrase. */ +internal data class LockCapabilities(val biometricEnabled: Boolean, val recoveryAvailable: Boolean) + +/** The checks of a secret the lock screen can send; each wipes what it is given. */ +internal class LockChecks( + val pin: (pin: CharArray, onResult: (PinUnlockResult) -> Unit) -> Unit, + val seed: (phrase: CharArray, onResult: (SeedUnlockResult) -> Unit) -> Unit, +) + +/** What the lock screen's forms ask of the view model and of the screen (the view model stays in [LockScreen]). */ +internal class LockActions( + val checks: LockChecks, + val lockoutRemainingMillis: () -> Long, + val onUnlock: () -> Unit, + /** The recovery phrase opened the vault: the PIN is forgotten, set a new one or keep it. */ + val onRecoveryUnlock: () -> Unit, + /** The device-bound key is gone for good (see [PinUnlockResult.KEY_LOST]). */ + val onKeyLoss: () -> Unit, + val onPromptBiometric: () -> Unit, +) + +/** The form on show: the recovery phrase or the PIN pad. */ +@Composable +internal fun LockForms( + form: LockFormState, + busy: Boolean, + capabilities: LockCapabilities, + actions: LockActions, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + if (form.useRecovery) { + SeedLockContent( + form = form, + busy = busy, + authenticateSeed = actions.checks.seed, + onUnlock = actions.onRecoveryUnlock, + ) + // Drawn over the phrase form rather than instead of it, so it keeps its state. The PIN + // form shows the check in place (full dots, breathing logo) and hands over to the reveal. + if (busy) AuthLoading(stringResource(Res.string.lock_unlocking)) + } else { + PinLockContent(form, capabilities, busy, actions) + } + } +} + +@Composable +private fun PinLockContent(form: LockFormState, capabilities: LockCapabilities, busy: Boolean, actions: LockActions) { + PinLockoutTicker(form.lockedOut, actions.lockoutRemainingMillis) { form.lockoutSeconds = it } + // Digits typed but never submitted do not outlive the lock screen. + DisposableEffect(form) { onDispose { form.clearPin() } } + val submit = { submitSecret(form.takePin(), actions.checks.pin, actions) { r, ms -> form.onPinResult(r, ms) } } + + val shownError = form.pinError + PinEntryScaffold( + title = stringResource(Res.string.lock_title), + bodyAtBottom = true, + // The sub-heading slot carries the lockout countdown or the last error. + subtitle = when { + form.lockedOut -> pinLockoutText(form.lockoutSeconds) + shownError != null -> stringResource(shownError) + else -> stringResource(Res.string.lock_subtitle) + }, + subtitleIsError = form.lockedOut || shownError != null, + busy = busy, + ) { + PinEntry( + // The PIN is taken out of the form for the check; keep the dots as they were meanwhile. + entered = if (busy) form.submittedLength else form.pinLength, + error = shownError != null && form.pinLength == 0, + shakeKey = form.shakeKey, + enabled = !form.lockedOut && !busy, + actions = PinEntryActions( + onDigit = form::typeDigit, + onBackspace = form::deleteDigit, + onBiometric = actions.onPromptBiometric.takeIf { capabilities.biometricEnabled }, + ), + // No auto-submit: a PIN is 6 to 12 digits, so the user says when it is complete. + submit = PinSubmit( + text = stringResource(Res.string.lock_unlock), + enabled = form.canSubmitPin && !busy, + onClick = submit, + ), + ) + if (capabilities.recoveryAvailable) RecoveryLink(onUseRecovery = { form.useRecovery = true }) + } +} + +/** + * Sends [secret] (the PIN) to be checked by [authenticate], unless a lockout runs. The ViewModel wipes it + * once it is checked; [onFailure] shows a result that did not unlock, with the lockout left. + */ +private fun submitSecret( + secret: CharArray, + authenticate: (CharArray, (PinUnlockResult) -> Unit) -> Unit, + actions: LockActions, + onFailure: (PinUnlockResult, lockoutMillis: Long) -> Unit, +) { + if (actions.lockoutRemainingMillis() > 0) { + SensitiveDataCleaner.clear(secret) + return + } + authenticate(secret) { result -> + when (result) { + PinUnlockResult.SUCCESS -> actions.onUnlock() + + PinUnlockResult.KEY_LOST -> { + onFailure(result, actions.lockoutRemainingMillis()) + actions.onKeyLoss() + } + + else -> onFailure(result, actions.lockoutRemainingMillis()) + } + } +} + +@Composable +private fun SeedLockContent( + form: LockFormState, + busy: Boolean, + authenticateSeed: (phrase: CharArray, onResult: (SeedUnlockResult) -> Unit) -> Unit, + onUnlock: () -> Unit, +) { + ReleaseFocusWhile(busy) + val submit = { + if (form.phrase.canSubmit && !busy) { + authenticateSeed(form.phrase.toCharArray()) { result -> + if (result == SeedUnlockResult.SUCCESS) onUnlock() else form.onSeedResult(result) + } + } + } + PinEntryScaffold( + title = stringResource(Res.string.lock_recovery_title), + subtitle = stringResource(Res.string.lock_recovery_subtitle), + icon = EnclyIcons.Key, + // The 12 cells need the room: no 96 dp top, so they stay above the keyboard. + compact = true, + // Pinned above the keyboard: typing the phrase must never hide the button. + footer = { + EnclyButton( + text = stringResource(Res.string.lock_recover_access), + onClick = submit, + enabled = form.phrase.canSubmit && !busy, + ) + EnclyTextButton( + text = stringResource(Res.string.lock_back_to_pin), + onClick = form::leaveRecovery, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + ) + }, + ) { + RecoveryPhraseInput( + state = form.phrase, + enabled = !busy, + error = form.phraseError?.let { stringResource(it) }, + onEdit = form::onPhraseEdited, + onDone = submit, + modifier = Modifier + .padding(horizontal = EnclyTheme.spacing.gutter) + .padding(top = EnclyTheme.spacing.l), + ) + } +} + +/** "Forgot your PIN? Use your recovery phrase" under the keypad. */ +@Composable +private fun RecoveryLink(onUseRecovery: () -> Unit) { + EnclyTextButton( + text = stringResource(Res.string.lock_use_recovery_phrase), + onClick = onUseRecovery, + modifier = Modifier.padding( + top = EnclyTheme.spacing.l, + start = EnclyTheme.spacing.gutter, + end = EnclyTheme.spacing.gutter, + ), + ) +} + +/** + * The loading view covers a form but not the keyboard: while the phrase is checked the + * field lets go of focus, so the keyboard closes and cannot type into it meanwhile. + */ +@Composable +private fun ReleaseFocusWhile(busy: Boolean) { + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + LaunchedEffect(busy) { + if (busy) { + focusManager.clearFocus(force = true) + keyboard?.hide() + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt new file mode 100644 index 00000000..b70fa6a9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt @@ -0,0 +1,207 @@ +package com.pasich.encly.presentation.screen + +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.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.core.security.RecoveryStage +import com.pasich.encly.platform.LocalBiometricHost +import com.pasich.encly.platform.LocalSessionSharedAcrossWindows +import com.pasich.encly.presentation.effects.LocalUnlockReveal +import com.pasich.encly.presentation.effects.UnlockRevealState +import com.pasich.encly.presentation.effects.revealThen +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.navigation.RelockReturn +import com.pasich.encly.presentation.viewmodel.LockViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first + +/** 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 lockViewModel = metroViewModel() + val canReopenNote = lockViewModel::canReopenNote + val unlockReveal = LocalUnlockReveal.current + if (LocalSessionSharedAcrossWindows.current) { + FollowUnlockFromOtherWindows(navController, lockViewModel, unlockReveal) + } + val exits = remember(navController, unlockReveal) { + LockExits( + onUnlock = { isSessionLocked -> + navController.leaveLockScreen(unlockReveal, isSessionLocked, canReopenNote) + }, + // A recovery-phrase unlock: set a new PIN when none can be used, otherwise choose whether to keep it. + onRecoveryUnlock = { navController.leaveForRecoveryStage(lockViewModel.recoveryStage()) }, + onVaultLost = { + navController.navigate(NavRoutes.LossDataRoute.name) { + popUpTo(NavRoutes.LockRoute.name) { inclusive = true } + } + }, + ) + } + LockScreen(exits = exits, modifier = modifier) +} + +/** + * In a host with several windows over one vault, an unlock made on another window's lock screen opens the vault + * for this one too: this lock screen leaves for Home (or the note this window had open) like its own unlock does, + * without the reveal, or for the new-PIN screen when the other window unlocked with the recovery phrase. A short + * grace lets this window's own unlock start its reveal first, which this must not cut off. + */ +@Composable +private fun FollowUnlockFromOtherWindows( + navController: NavHostController, + viewModel: LockViewModel, + reveal: UnlockRevealState?, +) { + LaunchedEffect(viewModel) { + viewModel.sessionLocked.collectLatest { locked -> + if (locked) return@collectLatest + delay(FOLLOW_UNLOCK_GRACE_MS) + val ownUnlock = viewModel.busy.value || reveal?.running == true + if (!ownUnlock && !viewModel.isSessionLocked()) { + val stage = viewModel.recoveryStage() + if (stage != RecoveryStage.NONE) { + // The other window unlocked with the recovery phrase: this one asks the same, new PIN or choice. + navController.leaveForRecoveryStage(stage) + } else { + navController.leaveLockScreen(null, viewModel::isSessionLocked, viewModel::canReopenNote) + } + } + } + } +} + +private const val FOLLOW_UNLOCK_GRACE_MS = 250L + +/** After a recovery-phrase unlock: the new-PIN screen when a PIN is required, the keep-or-change choice otherwise. */ +private fun NavHostController.leaveForRecoveryStage(stage: RecoveryStage) { + val route = if (stage == RecoveryStage.CHOOSE) NavRoutes.RecoveryChoiceRoute else NavRoutes.PinCodeConfig + navigate(route.name) { + popUpTo(NavRoutes.LockRoute.name) { inclusive = true } + } +} + +@Composable +fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockViewModel = metroViewModel()) { + val host = LocalBiometricHost.current + val busy by viewModel.busy.collectAsState() + // Above the loading view below: the forms' input and errors survive the credential check. + val form = remember { LockFormState() } + + // From the recovery form Back returns to the PIN pad; from the PIN pad it goes to + // [LockExits.onBack], or nowhere: by default Back never leaves the lock screen. + BackHandler(enabled = true) { onLockBack(busy, form, exits.onBack) } + + val biometricEnabled = remember { + viewModel.biometricEnabled() && viewModel.biometricAvailable() + } + + fun goHome() = exits.onUnlock(viewModel::isSessionLocked) + + fun goToPinReset() = exits.onRecoveryUnlock(viewModel::isSessionLocked) + + fun onPinKeyLoss() = handlePinKeyLoss(form, viewModel.recoveryAvailable(), biometricEnabled, exits.onVaultLost) + + fun promptBiometric() { + if (host != null && viewModel.lockoutRemainingMillis() <= 0) { + viewModel.authenticateBiometric(host) { unlocked -> + if (unlocked) goHome() + } + } + } + + // Once per lock screen, and only with the window in front: a lock screen that appears while the + // app is in the background (the auto-lock) would otherwise start a prompt the system cancels + // unseen. The fingerprint button stays available meanwhile and after. + LaunchedEffect(host) { + if (biometricEnabled && host != null) { + host.owner.lifecycle.currentStateFlow.first { it.isAtLeast(Lifecycle.State.RESUMED) } + promptBiometric() + } + } + + LockForms( + form = form, + busy = busy, + capabilities = LockCapabilities( + biometricEnabled = biometricEnabled && host != null, + recoveryAvailable = viewModel.recoveryAvailable(), + ), + actions = LockActions( + checks = LockChecks( + pin = viewModel::authenticatePin, + seed = viewModel::authenticateSeed, + ), + lockoutRemainingMillis = viewModel::lockoutRemainingMillis, + onUnlock = ::goHome, + onRecoveryUnlock = ::goToPinReset, + onKeyLoss = ::onPinKeyLoss, + onPromptBiometric = ::promptBiometric, + ), + modifier = modifier, + ) +} + +private fun onLockBack(busy: Boolean, form: LockFormState, onBack: (() -> Unit)?) { + when { + busy -> Unit + form.useRecovery -> form.back() + else -> onBack?.invoke() + } +} + +/** + * Leaves the lock screen for Home, and the note that was open when the app re-locked (see + * EnclyApp), once the reveal covers the window so Home composes out of sight. If the + * session closed again while the reveal played (e.g. the app went to the background), it stays + * on the lock screen rather than open Home over a locked vault. The note is not reopened when + * [canReopenNote] says the open vault is not the one it was in (a wipe-PIN erase since). + */ +private fun NavHostController.leaveLockScreen( + reveal: UnlockRevealState?, + isSessionLocked: () -> Boolean, + canReopenNote: (savedEpoch: Long?) -> Boolean, +) { + val lockState = currentBackStackEntry?.savedStateHandle + val returnRoute = lockState?.get(RelockReturn.RETURN_ROUTE) + val returnEpoch = lockState?.get(RelockReturn.RETURN_EPOCH) + reveal.revealThen { + if (!isSessionLocked()) { + navigate(NavRoutes.HomeRoute.name) { + popUpTo(NavRoutes.LockRoute.name) { inclusive = true } + } + // Checked after the unlock: an erase is what changes the epoch. + if (returnRoute != null && canReopenNote(returnEpoch)) { + navigate(returnRoute) + currentBackStackEntry?.savedStateHandle?.set(RelockReturn.PLAIN_ENTRY, true) + } + } + } +} + +/** + * The PIN can no longer unlock on this device. With a recovery phrase the lock screen switches + * to it (the form shows why); with only a fingerprint the PIN pad keeps the message; with + * neither, nothing can open the vault here any more ([onVaultLost]). + */ +private fun handlePinKeyLoss( + form: LockFormState, + recoveryAvailable: Boolean, + biometric: Boolean, + onVaultLost: () -> Unit, +) { + if (recoveryAvailable) { + form.useRecovery = true + } else if (!biometric) { + onVaultLost() + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LossReason.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LossReason.kt new file mode 100644 index 00000000..75ce07e0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LossReason.kt @@ -0,0 +1,23 @@ +package com.pasich.encly.presentation.screen + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.legacy_vault_hold_to_start +import com.pasich.encly.core.resources.legacy_vault_message +import com.pasich.encly.core.resources.legacy_vault_title +import com.pasich.encly.core.resources.loss_hold_to_wipe +import com.pasich.encly.core.resources.loss_message +import com.pasich.encly.core.resources.loss_title +import org.jetbrains.compose.resources.StringResource + +/** Why the vault cannot be opened. */ +enum class LossReason(val title: StringResource, val message: StringResource, val action: StringResource) { + /** The encrypted data or its keys were changed or lost. */ + DAMAGED(Res.string.loss_title, Res.string.loss_message, Res.string.loss_hold_to_wipe), + + /** The data was written by Encly 1.x, which 2.0 cannot open (no migration). */ + OLDER_VERSION( + Res.string.legacy_vault_title, + Res.string.legacy_vault_message, + Res.string.legacy_vault_hold_to_start, + ), +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LossRecoveryScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LossRecoveryScreen.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/screen/LossRecoveryScreen.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LossRecoveryScreen.kt index 16323084..b2bec9ca 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LossRecoveryScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LossRecoveryScreen.kt @@ -16,14 +16,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.vault_reset_restore_hint import com.pasich.encly.presentation.designsystem.EnclyCallout import com.pasich.encly.presentation.designsystem.EnclyHoldToConfirmButton import com.pasich.encly.presentation.designsystem.EnclyIconTile import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource /** * The vault can no longer be opened: explains why ([reason]), and wipes everything only after @@ -64,7 +65,7 @@ fun LossRecoveryScreen( ) // The one way back after the wipe: a backup file and its 12 words. EnclyCallout( - text = stringResource(R.string.vault_reset_restore_hint), + text = stringResource(Res.string.vault_reset_restore_hint), icon = EnclyIcons.Restore, modifier = Modifier.padding(top = spacing.s), ) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/MainScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/MainScreen.kt new file mode 100644 index 00000000..2cd92888 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/MainScreen.kt @@ -0,0 +1,345 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_now +import com.pasich.encly.core.resources.main_drawer_settings +import com.pasich.encly.core.resources.note_add +import com.pasich.encly.core.resources.notes_title +import com.pasich.encly.core.resources.open_menu +import com.pasich.encly.core.resources.search_clear +import com.pasich.encly.core.resources.search_placeholder +import com.pasich.encly.core.resources.view_options +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.model.SwipeSettings +import com.pasich.encly.presentation.components.HomeTaskWidget +import com.pasich.encly.presentation.designsystem.EnclyFab +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclySearchField +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.IconWithTooltip +import com.pasich.encly.presentation.dialogs.NotesSortBottomSheet +import com.pasich.encly.presentation.dialogs.rememberSheetDismisser +import com.pasich.encly.presentation.drawer.MainDrawer +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.navigation.NewNoteCommand +import com.pasich.encly.presentation.navigation.TASKS_ADD_ARG +import com.pasich.encly.presentation.pane.ExpandedMainRoot +import com.pasich.encly.presentation.pane.MediumMainRoot +import com.pasich.encly.presentation.viewmodel.LockNowViewModel +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.ui.theme.EnclyTheme +import com.pasich.encly.ui.theme.LocalWidthClass +import com.pasich.encly.ui.theme.WidthClass +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +/** The scrim over the notes while the drawer is open. */ +private const val DRAWER_SCRIM_ALPHA = 0.32f + +@Composable +fun MainRootScreen(navController: NavHostController, modifier: Modifier = Modifier) { + when (LocalWidthClass.current) { + WidthClass.Compact -> CompactMainRoot(navController, modifier) + WidthClass.Medium -> MediumMainRoot(navController, modifier) + WidthClass.Expanded -> ExpandedMainRoot(navController, modifier) + } + UnlockedStartup() +} + +/** The phone layout: the notes, with the navigation drawer sliding over them. */ +@Composable +private fun CompactMainRoot(navController: NavHostController, modifier: Modifier = Modifier) { + // Not saved: coming back from a page opened in the drawer shows the notes with it closed. + val drawerState = remember { DrawerState(DrawerValue.Closed) } + val scope = rememberCoroutineScope() + // The screen draws under the status bar: the scrim dims the whole window, and the drawer + // pads its own content below the bar. + val statusBarDp = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } + + ModalNavigationDrawer( + modifier = modifier, + drawerState = drawerState, + gesturesEnabled = true, + scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = DRAWER_SCRIM_ALPHA), + drawerContent = { + MainDrawer( + drawerState = drawerState, + topInset = statusBarDp, + onItemClick = { selectedItem -> + scope.launch { + val navOptions = NavOptions.Builder() + .setLaunchSingleTop(true) + .setRestoreState(true) + .build() + navController.navigate(selectedItem.route, navOptions) + } + }, + ) + }, + ) { + MainScreen( + drawerState = drawerState, + navController = navController, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + navController: NavHostController, + drawerState: DrawerState?, + modifier: Modifier = Modifier, + noteOpener: NoteOpener? = null, + mainListStateViewModel: MainListStateViewModel = metroViewModel(), + settingsViewModel: SettingsViewModel = metroViewModel(), + noteListViewModel: NoteListViewModel = metroViewModel(), + searchViewModel: NoteSearchViewModel = metroViewModel(), + lockNowViewModel: LockNowViewModel = metroViewModel(), +) { + val isGrid by mainListStateViewModel.isGridNoteList.collectAsState() + val showTasks by settingsViewModel.showTasksFlow.collectAsState() + // The field reads local state (a flow round trip can drop fast keystrokes); the ViewModel + // gets every change. + // Plain remember: a search query is note content and must not land in saved state. + var query by remember { mutableStateOf("") } + val searchResults by searchViewModel.results.collectAsStateWithLifecycle() + + // State restoration for scroll states + val listScrollState = rememberLazyListState() + val gridScrollState = rememberLazyStaggeredGridState() + val atTop by rememberAtTop(isGrid, listScrollState, gridScrollState) + val scrollRequest by noteListViewModel.scrollToTopRequest.collectAsStateWithLifecycle() + ScrollToTopOnRequest(scrollRequest, isGrid, listScrollState, gridScrollState, noteListViewModel::onScrolledToTop) + + var viewOptionsVisible by rememberSaveable { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val navigation = remember(navController) { MainNavigation(navController) } + val notes = noteOpener ?: navigation + // The keyboard and the menu bar start a note the way the button below does. + NewNoteCommand { notes.newNote(noteListViewModel.state.value.selectedTag) } + + if (viewOptionsVisible) { + ViewOptionsSheet(isGrid, mainListStateViewModel::updateGridNoteList) { viewOptionsVisible = false } + } + + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + floatingActionButton = { + NewNoteFab(expanded = atTop, onClick = { notes.newNote(noteListViewModel.state.value.selectedTag) }) + }, + ) { padding -> + // The Scaffold has no top bar slot, so `padding` carries the status bar; consuming it + // keeps the notes bar below from padding the same inset again. + Column(modifier = Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding)) { + NotesTopBar( + onMenu = drawerState?.let { drawer -> { scope.launch { drawer.open() } } }, + onLockNow = lockNowViewModel::lockNow, + onSettings = { navigation.open(NavRoutes.SettingsRoute.name) }, + ) + NotesList( + isGrid = isGrid, + listScrollState = listScrollState, + gridScrollState = gridScrollState, + search = NotesSearch(query, searchResults), + swipe = settingsViewModel.swipeSettings.collectAsState().value ?: SwipeSettings.Off, + onItemClick = { note -> notes.editNote(note.id) }, + onSecondActionClick = { note -> notes.editNote(-1, "?copySource=${note.id}") }, + tabs = notes.tabs(), + header = { + NotesHeader( + query = query, + onQueryChange = { + query = it + searchViewModel.onQueryChange(it) + }, + onViewOptions = { viewOptionsVisible = true }, + tasks = HomeTasks( + onOpen = { navigation.open(NavRoutes.TasksRoute.name) }, + onNewTask = { navigation.open("${NavRoutes.TasksRoute.name}?$TASKS_ADD_ARG=true") }, + ).takeIf { showTasks && query.isBlank() }, + ) + }, + ) + } + } +} + +/** Whether the notes are scrolled to the top; the FAB then shows its label. */ +@Composable +private fun rememberAtTop(isGrid: Boolean, list: LazyListState, grid: LazyStaggeredGridState): State = + remember(isGrid, list, grid) { + derivedStateOf { if (isGrid) grid.firstVisibleItemIndex == 0 else list.firstVisibleItemIndex == 0 } + } + +/** + * Scrolls the notes back to the top while the ViewModel has a [request] pending (a new sort order + * or a new note), then reports it done through [onScrollComplete]. The request stays pending while the + * editor is open, so coming back to a list that got a new note meanwhile scrolls too; an + * unchanged list, or one restored after process death, keeps its position. + */ +@Composable +private fun ScrollToTopOnRequest( + request: Int?, + isGrid: Boolean, + list: LazyListState, + grid: LazyStaggeredGridState, + onScrollComplete: (Int) -> Unit, +) { + val currentOnScrollComplete by rememberUpdatedState(onScrollComplete) + LaunchedEffect(request) { + if (request == null) return@LaunchedEffect + if (isGrid) grid.scrollToItem(0) else list.scrollToItem(0) + currentOnScrollComplete(request) + } +} + +/** "New note", collapsed to its icon once the list scrolls. */ +@Composable +private fun NewNoteFab(expanded: Boolean, onClick: () -> Unit) { + EnclyFab( + text = stringResource(Res.string.note_add), + icon = EnclyIcons.PlusBold, + expanded = expanded, + onClick = onClick, + ) +} + +/** The view options sheet (sort order, list or grid); [onClose] runs once it has slid away. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ViewOptionsSheet(isGrid: Boolean, onToggleView: () -> Unit, onClose: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + NotesSortBottomSheet( + isBottomSheetVisible = true, + sheetState = sheetState, + isGrid = isGrid, + onToggleView = onToggleView, + onDismiss = rememberSheetDismisser(sheetState, onClose), + ) +} + +/** Where a note from the notes list opens: a full screen on a phone, the detail pane in an expanded window. */ +interface NoteOpener { + /** Opens note [noteId] (-1: a new one), with the editor route's query [params] such as a copy source. */ + fun editNote(noteId: Long, params: String = "") + + /** A new note, tagged with the chip selected in the list (0: none). */ + fun newNote(selectedTag: Long) = editNote(-1, if (selectedTag != 0L) "?addTag=$selectedTag" else "") + + /** Whether notes open as tabs beside the list: the list then marks the shown one and can open another. */ + val supportsTabs: Boolean get() = false + + /** The stored note shown beside the list, or null. */ + val activeNoteId: Long? get() = null + + /** Opens [note] as a tab of its own without leaving the one shown; only where [supportsTabs]. */ + fun openInNewTab(note: Note) = editNote(note.id) +} + +/** What the notes list shows of the tabs beside it, or null where the opener has none. */ +private fun NoteOpener.tabs(): NotesTabs? = takeIf { it.supportsTabs }?.let { opener -> + NotesTabs(activeNoteId = { opener.activeNoteId }, onOpenInNewTab = opener::openInNewTab) +} + +/** Where the notes screen goes: a note (new, existing or a copy) or another screen. */ +private class MainNavigation(private val navController: NavHostController) : NoteOpener { + override fun editNote(noteId: Long, params: String) { + val navOptions = NavOptions.Builder().setLaunchSingleTop(true).build() + navController.navigate("${NavRoutes.EditNoteRoute.name}/$noteId$params", navOptions) + } + + fun open(route: String) { + val navOptions = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true).build() + navController.navigate(route, navOptions) + } +} + +/** The root bar of the notes screen: menu, "Notes", Lock now and Settings. */ +@Composable +private fun NotesTopBar(onMenu: (() -> Unit)?, onLockNow: () -> Unit, onSettings: () -> Unit) { + EnclyTopBar( + title = stringResource(Res.string.notes_title), + navigation = onMenu?.let { menu -> + { IconWithTooltip(EnclyIcons.Menu, stringResource(Res.string.open_menu), menu) } + }, + actions = { + IconWithTooltip(EnclyIcons.Lock, stringResource(Res.string.lock_now), onLockNow) + IconWithTooltip(EnclyIcons.Sliders, stringResource(Res.string.main_drawer_settings), onSettings) + }, + ) +} + +/** Where the home tasks card leads: all tasks, or straight to a new one. Null hides the card. */ +private class HomeTasks(val onOpen: () -> Unit, val onNewTask: () -> Unit) + +/** Above the notes: the search field with its view options, the tag chips and the tasks card. */ +@Composable +private fun NotesHeader(query: String, onQueryChange: (String) -> Unit, onViewOptions: () -> Unit, tasks: HomeTasks?) { + val gutter = EnclyTheme.spacing.listGutter + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.rowGap)) { + EnclySearchField( + value = query, + onValueChange = onQueryChange, + placeholder = stringResource(Res.string.search_placeholder), + modifier = Modifier.padding(horizontal = gutter), + trailing = { + if (query.isNotEmpty()) { + IconWithTooltip(EnclyIcons.Close, stringResource(Res.string.search_clear), onClick = { + onQueryChange("") + }) + } else { + IconWithTooltip(EnclyIcons.Filter, stringResource(Res.string.view_options), onViewOptions) + } + }, + ) + if (query.isBlank()) TagsList() + if (tasks != null) { + HomeTaskWidget( + onTasksClick = tasks.onOpen, + onNewTask = tasks.onNewTask, + modifier = Modifier.padding(horizontal = gutter), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesCards.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesCards.kt new file mode 100644 index 00000000..6f89725c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesCards.kt @@ -0,0 +1,136 @@ +package com.pasich.encly.presentation.screen + +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 com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.swipe_delete_message +import com.pasich.encly.core.resources.swipe_delete_title +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.model.NoteListItem +import com.pasich.encly.platform.LocalNoteDocumentRenderer +import com.pasich.encly.platform.LocalNoteWindows +import com.pasich.encly.platform.LocalToaster +import com.pasich.encly.platform.NoteDocumentRenderer +import com.pasich.encly.platform.ToastDuration +import com.pasich.encly.presentation.components.swipe.SwipeContext +import com.pasich.encly.presentation.components.tiles.NoteContextActions +import com.pasich.encly.presentation.components.tiles.NoteItem +import com.pasich.encly.presentation.dialogs.ConfirmDialog +import com.pasich.encly.presentation.dialogs.NoteAction +import com.pasich.encly.presentation.screen.backup.MarkdownExportHost +import com.pasich.encly.presentation.viewmodel.DocumentFormat +import com.pasich.encly.presentation.viewmodel.MarkdownExportTarget +import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel +import com.pasich.encly.presentation.viewmodel.NoteListEvent +import com.pasich.encly.ui.theme.LocalPointerFirst +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +/** The cards to show and what a tap or long-press on one does. */ +internal class NotesContent( + val entries: List, + val highlight: String, + val onItemClick: (Note) -> Unit, + val onItemLongClick: (NoteListItem) -> Unit, + val swipe: SwipeContext, + val pointer: NotesPointer, +) + +/** What a mouse adds to a card: the right-click menu, and the tabs' marks and shortcuts. Null: touch only. */ +internal class NotesPointer(val tabs: NotesTabs?, val actionsFor: ((Note) -> NoteContextActions)?) + +/** The card of [entry] in the list or the grid; the same call for both. */ +@Composable +internal fun NotesCard(entry: NoteRow, content: NotesContent, excerptLines: Int, modifier: Modifier = Modifier) { + val tabs = content.pointer.tabs + NoteItem( + item = entry.item, + highlight = content.highlight, + excerptLines = excerptLines, + modifier = modifier, + onItemClick = content.onItemClick, + onItemLongClick = { content.onItemLongClick(entry.item) }, + contextActions = content.pointer.actionsFor?.invoke(entry.item.note), + current = tabs?.activeNoteId?.invoke() == entry.item.note.id, + onCommandClick = tabs?.onOpenInNewTab, + ) +} + +/** What a right click on a card does beyond the tabs: open, duplicate, and the list's own events. */ +internal class NoteMenuHandlers( + val onOpen: (Note) -> Unit, + val onDuplicate: (Note) -> Unit, + val onEvent: (NoteListEvent) -> Unit, +) + +/** + * The mouse side of the list: the right-click menu on a pointer-first device (a finger keeps the long-press + * sheet), whose "Move to trash" asks first like the swipe does, and the [tabs] if the host has any. + */ +@Composable +internal fun rememberNotesPointer(tabs: NotesTabs?, handlers: NoteMenuHandlers): NotesPointer { + var trashing by remember { mutableStateOf(null) } + ConfirmDialog( + isVisible = trashing != null, + titleText = stringResource(Res.string.swipe_delete_title), + messageText = stringResource(Res.string.swipe_delete_message), + onConfirm = { + trashing?.let { handlers.onEvent(NoteListEvent.NoteToTrash(it)) } + trashing = null + }, + onDismiss = { trashing = null }, + destructive = true, + ) + val windows = LocalNoteWindows.current + val renderer = LocalNoteDocumentRenderer.current + val export = rememberNoteDocumentExport(renderer) + if (!LocalPointerFirst.current) return NotesPointer(tabs, actionsFor = null) + return NotesPointer(tabs) { note -> + NoteContextActions( + onAction = { action -> + when (action) { + NoteAction.Edit -> handlers.onOpen(note) + NoteAction.Duplicate -> handlers.onDuplicate(note) + NoteAction.Delete -> trashing = note + else -> action.toEvent(note)?.let(handlers.onEvent) + } + }, + onOpenInNewTab = tabs?.let { { it.onOpenInNewTab(note) } }, + onOpenInNewWindow = windows?.let { { it.openNote(note.id) } }, + onExportPdf = export?.let { { it(note.id, DocumentFormat.PDF) } }, + onPrint = export?.let { { it(note.id, DocumentFormat.PRINT) } }, + ) + } +} + +/** + * Starts the PDF or print flow for a note of the list (the warning, the PIN, then the dialog), or null where the + * host has no [renderer]. The flow's dialogs are drawn here, beside the list. + */ +@Composable +private fun rememberNoteDocumentExport( + renderer: NoteDocumentRenderer?, +): ((noteId: Long, format: DocumentFormat) -> Unit)? { + if (renderer == null) return null + val flow: MarkdownExportViewModel = metroViewModel() + val toaster = LocalToaster.current + MarkdownExportHost(onMessage = {}, onMessageRes = { toaster.show(it, ToastDuration.SHORT) }) + return remember(flow, renderer) { + { noteId, format -> flow.start(MarkdownExportTarget.NoteAsDocument(noteId, format, renderer)) } + } +} + +/** The list event a sheet [NoteAction] on [note] stands for; null for the ones that open another screen. */ +internal fun NoteAction.toEvent(note: Note): NoteListEvent? = when (this) { + NoteAction.Edit, NoteAction.Duplicate -> null + NoteAction.Delete -> NoteListEvent.NoteToTrash(note) + is NoteAction.Pin -> NoteListEvent.PinNote(note, pinned) + is NoteAction.Archive -> NoteListEvent.ArchiveNote(note, archived) + is NoteAction.ChangeColor -> NoteListEvent.ChangeColor(note, color) + is NoteAction.ChangeTag -> NoteListEvent.ChangeTag(note = note, idTag = tagId, tagged = tagged) + is NoteAction.ChangeDescription -> NoteListEvent.ChangeDescription(note = note, description = description) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesListScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesListScreen.kt new file mode 100644 index 00000000..d43918bc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesListScreen.kt @@ -0,0 +1,428 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pasich.encly.core.common.LoadState +import com.pasich.encly.core.common.asString +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.empty_notes +import com.pasich.encly.core.resources.empty_notes_desc +import com.pasich.encly.core.resources.empty_notes_tagged +import com.pasich.encly.core.resources.empty_notes_tagged_desc +import com.pasich.encly.core.resources.main_drawer_archive +import com.pasich.encly.core.resources.notes_section_other +import com.pasich.encly.core.resources.notes_section_pinned +import com.pasich.encly.core.resources.search_nothing_matches +import com.pasich.encly.core.resources.show_all_notes +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.domain.model.NoteListItem +import com.pasich.encly.domain.model.SwipeSettings +import com.pasich.encly.presentation.components.swipe.SwipeableNote +import com.pasich.encly.presentation.components.swipe.rememberSwipeContext +import com.pasich.encly.presentation.designsystem.EnclyEmptyState +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyScrollbar +import com.pasich.encly.presentation.designsystem.NoteSkeleton +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.designsystem.bleed +import com.pasich.encly.presentation.dialogs.NoteAction +import com.pasich.encly.presentation.dialogs.NoteCardBottomSheet +import com.pasich.encly.presentation.viewmodel.NoteListEvent +import com.pasich.encly.presentation.viewmodel.NoteListEvent.ChangeTag +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.ui.theme.EnclyTheme +import com.pasich.encly.ui.theme.LocalWidthClass +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +private const val SHEET_CLEAR_DELAY_MS = 200L +private const val FADE_MS = 300 +private const val SKELETON_CARDS = 4 + +/** A grid card shows more of its note than a list card. */ +private const val GRID_EXCERPT_LINES = 4 + +/** What the search field of the notes screen holds, and the matches for it. */ +class NotesSearch(val query: String, val results: NoteSearchViewModel.SearchResults) + +/** + * The tabs beside a notes list, where there are any (the expanded layout): the stored note that is shown + * ([activeNoteId], read where the cards are drawn) is marked in the list, and a note can be opened as a tab of + * its own ([onOpenInNewTab]) by the right-click menu or by a click with the command key held. + */ +class NotesTabs(val activeNoteId: () -> Long?, val onOpenInNewTab: (Note) -> Unit) + +@Composable +fun NotesList( + isGrid: Boolean, + listScrollState: LazyListState, + gridScrollState: LazyStaggeredGridState, + onItemClick: (Note) -> Unit, + modifier: Modifier = Modifier, + search: NotesSearch = NotesSearch("", NoteSearchViewModel.SearchResults()), + swipe: SwipeSettings = SwipeSettings.Off, + onSecondActionClick: (Note) -> Unit = {}, + tabs: NotesTabs? = null, + header: @Composable () -> Unit = {}, + noteListViewModel: NoteListViewModel = metroViewModel(), +) { + val state by noteListViewModel.state.collectAsStateWithLifecycle() + val sheet = remember { NoteSheetState() } + + NoteActionsSheet( + sheet = sheet, + observeNote = noteListViewModel::observeNote, + onEvent = noteListViewModel::onEvent, + onEdit = onItemClick, + onDuplicate = onSecondActionClick, + ) + + // While a search is typed the list shows its matches, highlighted, instead of the tag's notes. + val searching = search.query.isNotBlank() + val shown = if (searching) search.results.notes else state.notes + val content = NotesContent( + // Search results come in match order, without the pinned/other sections. + entries = remember(shown, searching) { noteEntries(shown, sections = !searching) }, + highlight = if (searching) search.results.query else "", + onItemClick = onItemClick, + onItemLongClick = sheet::open, + swipe = rememberSwipeContext(swipe, noteListViewModel::perform), + pointer = rememberNotesPointer( + tabs = tabs, + handlers = NoteMenuHandlers( + onOpen = onItemClick, + onDuplicate = onSecondActionClick, + onEvent = noteListViewModel::onEvent, + ), + ), + ) + val status: @Composable () -> Unit = { + NotesStatus( + state = state, + searching = searching, + search = search, + onShowAll = { noteListViewModel.onEvent(NoteListEvent.SelectTag(Tag(id = 0, nameTag = "All"))) }, + ) + } + + if (isGrid && !searching) { + NotesGrid(content, gridScrollState, NotesTop(header, status), modifier) + } else { + NotesColumn(content, listScrollState, NotesTop(header, status), modifier) + } +} + +/** The long-pressed note whose actions sheet is open. */ +@Stable +internal class NoteSheetState { + var active by mutableStateOf(null) + var visible by mutableStateOf(false) + + fun open(item: NoteListItem) { + active = item + visible = true + } +} + +/** A note's actions: edit, pin, archive, duplicate, delete, its tag and description. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NoteActionsSheet( + sheet: NoteSheetState, + observeNote: (Long) -> Flow, + onEvent: (NoteListEvent) -> Unit, + onEdit: (Note) -> Unit, + onDuplicate: (Note) -> Unit, +) { + val scope = rememberCoroutineScope() + val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // A wide window shows the actions as a dialog: there is no sheet to slide away. + val wide = LocalWidthClass.current.isWide + + // The sheet shows the note as stored now, not as the list held it when the sheet opened: a tag + // changed here moves the note out of this list, and the next action must still act on the right row. + val activeId = sheet.active?.note?.id + val live by remember(activeId) { activeId?.let(observeNote) ?: emptyFlow() }.collectAsState(initial = null) + + fun close() { + sheet.visible = false + scope.launch { + if (!wide) bottomSheetState.hide() + delay(SHEET_CLEAR_DELAY_MS) + sheet.active = null + } + } + + val item = live ?: sheet.active?.noteWithTags ?: return + NoteCardBottomSheet( + isVisible = sheet.visible, + item = item, + sheetState = bottomSheetState, + onAction = { action -> + when (action) { + NoteAction.Edit -> onEdit(item.note) + NoteAction.Duplicate -> onDuplicate(item.note) + else -> action.toEvent(item.note)?.let(onEvent) + } + // Changing the tag, the colour or the description keeps the sheet open. + if (action.closesSheet()) close() + }, + onDismiss = ::close, + ) +} + +private fun NoteAction.closesSheet(): Boolean = + this !is NoteAction.ChangeTag && this !is NoteAction.ChangeColor && this !is NoteAction.ChangeDescription + +/** What sits above the cards: the caller's header, then the loading, empty or search status. */ +private class NotesTop(val header: @Composable () -> Unit, val status: @Composable () -> Unit) + +/** Room for the last card above the FAB. */ +@Composable +private fun notesBottomPadding() = EnclyTheme.spacing.fabHeight + EnclyTheme.spacing.l + EnclyTheme.spacing.m + +@Composable +private fun NotesColumn(content: NotesContent, state: LazyListState, top: NotesTop, modifier: Modifier = Modifier) { + val spacing = EnclyTheme.spacing + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = state, + contentPadding = PaddingValues(top = spacing.xs, bottom = notesBottomPadding()), + verticalArrangement = Arrangement.spacedBy(spacing.cardGap), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(spacing.cardGap)) { + top.header() + top.status() + } + } + items(items = content.entries, key = { it.key }) { entry -> + when (entry) { + is NoteSection -> SectionOverline( + text = stringResource(entry.title), + modifier = Modifier + .padding(horizontal = spacing.listGutter) + .animateItem(), + ) + + is NoteRow -> SwipeableNote( + note = entry.item.note, + swipe = content.swipe, + modifier = Modifier + .padding(horizontal = spacing.listGutter) + .animateItem(), + ) { cardModifier -> NotesCard(entry, content, excerptLines = 2, modifier = cardModifier) } + } + } + } + EnclyScrollbar(state) + } +} + +@Composable +private fun NotesGrid( + content: NotesContent, + state: LazyStaggeredGridState, + top: NotesTop, + modifier: Modifier = Modifier, +) { + val spacing = EnclyTheme.spacing + Box(modifier = modifier.fillMaxSize()) { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + state = state, + contentPadding = PaddingValues( + start = spacing.listGutter, + end = spacing.listGutter, + top = spacing.xs, + bottom = notesBottomPadding(), + ), + verticalItemSpacing = spacing.cardGap, + horizontalArrangement = Arrangement.spacedBy(spacing.cardGap), + ) { + // Full-width rows lay themselves out on the gutter, as in the list. + item(span = StaggeredGridItemSpan.FullLine) { + Column( + verticalArrangement = Arrangement.spacedBy(spacing.cardGap), + modifier = Modifier.bleed(spacing.listGutter), + ) { + top.header() + top.status() + } + } + items( + items = content.entries, + key = { it.key }, + span = { entry -> + if (entry is NoteSection) StaggeredGridItemSpan.FullLine else StaggeredGridItemSpan.SingleLane + }, + ) { entry -> + when (entry) { + is NoteSection -> + SectionOverline(text = stringResource(entry.title), modifier = Modifier.animateItem()) + + is NoteRow -> SwipeableNote( + note = entry.item.note, + swipe = content.swipe, + modifier = Modifier.animateItem(), + ) { cardModifier -> NotesCard(entry, content, GRID_EXCERPT_LINES, modifier = cardModifier) } + } + } + } + EnclyScrollbar(state) + } +} + +/** The skeleton while notes load, the empty or failed state, or "nothing matches" for a search. */ +@Composable +private fun NotesStatus(state: NoteListState, searching: Boolean, search: NotesSearch, onShowAll: () -> Unit) { + val spacing = EnclyTheme.spacing + val notesLoad = state.notesLoad + when { + searching -> { + val resultsAreCurrent = search.results.query == search.query.trim() + if (resultsAreCurrent && search.results.notes.isEmpty()) { + Text( + text = stringResource(Res.string.search_nothing_matches, search.results.query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = spacing.listGutter, vertical = spacing.l), + ) + } + } + + notesLoad is LoadState.Loading -> Column( + verticalArrangement = Arrangement.spacedBy(spacing.cardGap), + modifier = Modifier.padding(horizontal = spacing.listGutter), + ) { + repeat(SKELETON_CARDS) { NoteSkeleton() } + } + + else -> NotesEmptyOrFailed(state, onShowAll) + } +} + +/** A row of the notes list: a section heading, or a note. [key] is stable across reorders. */ +internal sealed interface NoteEntry { + val key: Any +} + +/** The "Pinned" / "Others" heading above a group of notes. */ +internal data class NoteSection(val title: StringResource) : NoteEntry { + override val key: Any get() = "section-${title.key}" +} + +@Stable +internal data class NoteRow(val item: NoteListItem) : NoteEntry { + override val key: Any get() = item.note.id +} + +/** + * [notes] (already ordered: pinned first, then by the chosen sort, archived last) as list rows. With + * [sections], pinned notes sit under "Pinned", the rest under "Others" and, in a hidden tag's view, its + * archived notes under "Archive"; with neither pinned nor archived notes there are no headings at all. + */ +internal fun noteEntries(notes: List, sections: Boolean): List { + val rows = notes.map(::NoteRow) + // An archived note is never pinned; should a stored one be both, it is listed once, as archived. + val pinned = rows.filter { it.item.note.isPinned && !it.item.note.isArchived } + val archived = rows.filter { it.item.note.isArchived } + val others = rows - pinned.toSet() - archived.toSet() + // Plain notes alone need no heading; next to pinned or archived ones they are "Others". + if (!sections || (pinned.isEmpty() && archived.isEmpty())) return rows + return buildList { + if (pinned.isNotEmpty()) { + add(NoteSection(Res.string.notes_section_pinned)) + addAll(pinned) + } + if (others.isNotEmpty()) { + add(NoteSection(Res.string.notes_section_other)) + addAll(others) + } + if (archived.isNotEmpty()) { + add(NoteSection(Res.string.main_drawer_archive)) + addAll(archived) + } + } +} + +/** + * "No notes yet" once loading finished with nothing, "No notes tagged …" (with Show all) while a + * tag chip filters, or the read error when loading failed. A failed read is never shown as + * "No notes": the vault must not look emptied. + */ +@Composable +private fun NotesEmptyOrFailed(state: NoteListState, onShowAll: () -> Unit) { + val failure = (state.notesLoad as? LoadState.Failed)?.error + val empty = state.emptyState + AnimatedVisibility( + visible = failure != null || empty != null, + enter = fadeIn(animationSpec = tween(durationMillis = FADE_MS)), + exit = fadeOut(animationSpec = tween(durationMillis = FADE_MS)), + ) { + when { + failure != null -> EnclyEmptyState( + icon = EnclyIcons.Paper, + title = failure.title.asString(), + body = failure.message.asString(), + error = true, + ) + + empty is NotesEmptyState.NoneTagged -> EnclyEmptyState( + icon = EnclyIcons.Tag, + title = stringResource(Res.string.empty_notes_tagged, empty.tagName), + body = stringResource(Res.string.empty_notes_tagged_desc), + actionLabel = stringResource(Res.string.show_all_notes), + onAction = onShowAll, + ) + + else -> EnclyEmptyState( + icon = EnclyIcons.Paper, + title = stringResource(Res.string.empty_notes), + body = stringResource(Res.string.empty_notes_desc), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt new file mode 100644 index 00000000..522696e9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -0,0 +1,399 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.pin_change_bar_title +import com.pasich.encly.core.resources.pin_change_confirm_subtitle +import com.pasich.encly.core.resources.pin_change_confirm_title +import com.pasich.encly.core.resources.pin_change_current_subtitle +import com.pasich.encly.core.resources.pin_change_current_title +import com.pasich.encly.core.resources.pin_change_new_subtitle +import com.pasich.encly.core.resources.pin_changed +import com.pasich.encly.core.resources.pin_create_title +import com.pasich.encly.core.resources.pin_current_wrong +import com.pasich.encly.core.resources.pin_in_use +import com.pasich.encly.core.resources.pin_mismatch_retry +import com.pasich.encly.core.resources.pin_reset_subtitle +import com.pasich.encly.core.resources.pin_update_failed +import com.pasich.encly.core.security.PinChange +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.platform.LocalSessionSharedAcrossWindows +import com.pasich.encly.presentation.designsystem.EnclyIconTile +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.effects.FadeTiming +import com.pasich.encly.presentation.effects.screenFade +import com.pasich.encly.presentation.navigation.NavRoutes +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.PinSubmit +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.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +private const val PIN_SUCCESS_DELAY_MS = 1_000L +private val StepFade = FadeTiming(delayMillis = 0, durationMillis = 300, easing = FastOutSlowInEasing) +private const val SUCCESS_SCALE_MS = 500 + +enum class PinAnimationState { + Entering, + SuccessAnimation, +} + +/** + * The three-step PIN change: current PIN (skipped after a recovery-phrase unlock), new PIN, + * confirmation. Holds the screen state so the composable only renders it. The PINs are + * CharArrays that are wiped once used, never Strings (see [PinBuffer]). + */ +private class PinChangeState(val isReset: Boolean) { + var step by mutableIntStateOf(if (isReset) 1 else 0) + + /** The new PIN typed at step 1, until the confirmation is compared with it. */ + private var firstPin: CharArray? = null + + /** The digits of the current step. */ + val input = PinBuffer() + var errorText by mutableStateOf(null) + var animationState by mutableStateOf(PinAnimationState.Entering) + var lockoutSeconds by mutableLongStateOf(0L) + + /** Changes on every wrong entry, so the dots shake again. */ + var shakeKey by mutableIntStateOf(0) + + /** Digits are refused while the current PIN is locked out. */ + val keysEnabled: Boolean get() = !(step == 0 && lockoutSeconds > 0L) + + /** A digit on the keypad; refused during a lockout. */ + 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) { + 0 -> viewModel.verifyCurrentPin(pin) { ok -> onCurrentPinChecked(ok, viewModel) } + + 1 -> { + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = pin + errorText = null + step = 2 + } + + else -> confirmNewPin(pin, viewModel) + } + } + + private fun confirmNewPin(pin: CharArray, viewModel: SecuritySettingsViewModel) { + val first = firstPin + firstPin = null + val matches = first != null && pin.contentEquals(first) + first?.let(SensitiveDataCleaner::clear) + if (matches) { + viewModel.activationPinAuth(pin) { result -> + when (result) { + PinChange.SET -> animationState = PinAnimationState.SuccessAnimation + PinChange.IN_USE -> restartNewPin(Res.string.pin_in_use) + PinChange.FAILED -> restartNewPin(Res.string.pin_update_failed) + } + } + } else { + SensitiveDataCleaner.clear(pin) + restartNewPin(Res.string.pin_mismatch_retry) + } + } + + /** Typed or kept digits do not outlive the screen. */ + fun clear() { + input.clear() + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = null + } + + private fun onCurrentPinChecked(ok: Boolean, viewModel: SecuritySettingsViewModel) { + val lockout = lockoutSecondsLeft(viewModel.pinLockoutRemainingMillis()) + errorText = null + when { + ok -> step = 1 + + // A locked-out PIN is refused even when right: say so, not "wrong PIN". + lockout > 0L -> { + lockoutSeconds = lockout + shakeKey++ + } + + else -> { + errorText = Res.string.pin_current_wrong + shakeKey++ + } + } + } + + private fun restartNewPin(error: StringResource) { + errorText = error + shakeKey++ + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = null + step = 1 + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun PinCodeConfigScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + securityViewModel: SecuritySettingsViewModel = metroViewModel(), +) { + // After a recovery-phrase unlock the old PIN is forgotten: start at "new PIN", and do not + // let the user leave without one. + val pinState = remember { PinChangeState(isReset = !securityViewModel.requiresCurrentPin()) } + + BackHandler(enabled = pinState.isReset) { } + if (pinState.isReset && LocalSessionSharedAcrossWindows.current) { + LeaveWhenAnotherWindowSetThePin(pinState, securityViewModel::requiresCurrentPin, navController) + } + PinLockoutTicker(pinState.lockoutSeconds > 0L, securityViewModel::pinLockoutRemainingMillis) { + pinState.lockoutSeconds = it + } + + DisposableEffect(pinState) { onDispose { pinState.clear() } } + + LaunchedEffect(pinState.animationState) { + if (pinState.animationState == PinAnimationState.SuccessAnimation) { + delay(PIN_SUCCESS_DELAY_MS) + navController.finishPinChange(pinState.isReset) + } + } + + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) { + // From Settings there is a way back; a forgotten-PIN reset must end with a new PIN. + if (!pinState.isReset) { + EnclyTopBar( + title = stringResource(Res.string.pin_change_bar_title), + onBack = { navController.popBackStack() }, + ) + } + Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + if (pinState.animationState == PinAnimationState.Entering) { + AnimatedContent( + targetState = pinState.step, + transitionSpec = { + slideIntoContainer(SlideDirection.Start) + .togetherWith(slideOutOfContainer(SlideDirection.Start)) + }, + label = "StepAnimation", + ) { currentStep -> + // Drawn per draw call: fadeIn/fadeOut would put the step in an offscreen layer. + Box(Modifier.screenFade(this, enter = { StepFade }, exit = { StepFade })) { + PinStepContent( + step = currentStep, + pinState = pinState, + onSubmitPin = { pinState.onPinComplete(securityViewModel) }, + ) + } + } + } else { + SuccessAnimation() + } + } + } + } +} + +/** The page of [step]: the keypad of the PIN steps. */ +@Composable +private fun PinStepContent(step: Int, pinState: PinChangeState, onSubmitPin: () -> Unit) { + MainPinContent( + text = pinStepText(step, pinState), + entered = pinState.input.length, + entry = PinEntryState( + enabled = pinState.keysEnabled, + shakeKey = pinState.shakeKey, + compact = !pinState.isReset, + canSubmit = pinState.input.canSubmit, + onSubmit = { + if (pinState.animationState == PinAnimationState.Entering && pinState.input.canSubmit) { + onSubmitPin() + } + }, + ), + onInput = pinState::typeDigit, + onDelete = pinState.input::deleteLast, + ) +} + +private const val RESET_STILL_NEEDED_POLL_MS = 1_000L + +/** Another window may set the new PIN first: this reset has nothing left to do then, and goes on into the app. */ +@Composable +private fun LeaveWhenAnotherWindowSetThePin( + pinState: PinChangeState, + requiresCurrentPin: () -> Boolean, + navController: NavHostController, +) { + val stillRequired by rememberUpdatedState(requiresCurrentPin) + LaunchedEffect(pinState) { + while (pinState.animationState == PinAnimationState.Entering) { + delay(RESET_STILL_NEEDED_POLL_MS) + if (pinState.animationState == PinAnimationState.Entering && stillRequired()) { + navController.finishPinChange(isReset = true) + break + } + } + } +} + +/** A reset (forgotten PIN) ends in the app; a change from Settings goes back there. */ +private fun NavHostController.finishPinChange(isReset: Boolean) { + if (isReset) { + navigate(NavRoutes.HomeRoute.name) { + popUpTo(NavRoutes.PinCodeConfig.name) { inclusive = true } + } + } else { + popBackStack() + } +} + +@Composable +private fun pinStepText(step: Int, state: PinChangeState): PinStepText { + val lockout = state.lockoutSeconds.takeIf { it > 0L && step == 0 }?.let { pinLockoutText(it) } + return PinStepText( + title = pinStepTitle(step), + subtitle = if (state.isReset && step == 1) Res.string.pin_reset_subtitle else pinStepSubtitle(step), + message = lockout ?: state.errorText?.let { stringResource(it) }, + ) +} + +private fun pinStepTitle(step: Int): StringResource = when (step) { + 0 -> Res.string.pin_change_current_title + 1 -> Res.string.pin_create_title + else -> Res.string.pin_change_confirm_title +} + +private fun pinStepSubtitle(step: Int): StringResource = when (step) { + 0 -> Res.string.pin_change_current_subtitle + 1 -> Res.string.pin_change_new_subtitle + else -> Res.string.pin_change_confirm_subtitle +} + +/** What a PIN step shows: its title and subtitle, and an error or lockout message. */ +data class PinStepText(val title: StringResource, val subtitle: StringResource, val message: String?) + +/** How the keypad of a PIN step behaves: off during a lockout, the shake, a compact top. */ +private class PinEntryState( + val enabled: Boolean, + val shakeKey: Int, + val compact: Boolean, + val canSubmit: Boolean, + val onSubmit: () -> Unit, +) + +@Composable +private fun MainPinContent( + text: PinStepText, + entered: Int, + entry: PinEntryState, + onInput: (Int) -> Unit, + onDelete: () -> Unit, +) { + 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 = entry.compact, + bodyAtBottom = true, + ) { + PinEntry( + entered = entered, + error = message != null && entered == 0, + shakeKey = entry.shakeKey, + enabled = entry.enabled, + actions = PinEntryActions( + onDigit = onInput, + onBackspace = onDelete, + ), + submit = PinSubmit( + text = stringResource(Res.string.action_continue), + enabled = entry.canSubmit, + onClick = entry.onSubmit, + ), + ) + } +} + +@Composable +private fun SuccessAnimation() { + val scale = remember { Animatable(0f) } + val description = stringResource(Res.string.pin_changed) + + LaunchedEffect(Unit) { + scale.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = SUCCESS_SCALE_MS, easing = FastOutSlowInEasing), + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.m, Alignment.CenterVertically), + modifier = Modifier + .fillMaxSize() + .semantics(mergeDescendants = true) { contentDescription = description }, + ) { + EnclyIconTile(icon = EnclyIcons.Check, modifier = Modifier.scale(scale.value)) + Text( + text = description, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.alpha(scale.value.coerceIn(0f, 1f)), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/RecoveryChoiceScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/RecoveryChoiceScreen.kt new file mode 100644 index 00000000..029c9337 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/RecoveryChoiceScreen.kt @@ -0,0 +1,80 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.recovery_choice_body_pin +import com.pasich.encly.core.resources.recovery_choice_title +import com.pasich.encly.core.resources.recovery_keep_pin +import com.pasich.encly.core.resources.recovery_set_new_pin +import com.pasich.encly.core.security.RecoveryStage +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold +import com.pasich.encly.presentation.viewmodel.LockViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource + +private const val CHOICE_SETTLED_POLL_MS = 1_000L + +/** + * After the recovery phrase unlocked a vault whose PIN still works: keep it, which is the default, or + * set a new PIN. The vault is open meanwhile, but there is no way past this page except one of the two, so a + * forgotten PIN is replaced only by the user's own choice. A PIN that cannot be used any more skips this page: the + * new-PIN screen comes straight up (see [LockScreen]). + */ +@Composable +fun RecoveryChoiceScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + viewModel: LockViewModel = metroViewModel(), +) { + BackHandler(enabled = true) { } + // Another window may settle the choice first: this one goes on into the app with it. + LaunchedEffect(viewModel) { + while (true) { + delay(CHOICE_SETTLED_POLL_MS) + if (viewModel.recoveryStage() == RecoveryStage.NONE && !viewModel.isSessionLocked()) { + navController.goHome() + break + } + } + } + PinEntryScaffold( + title = stringResource(Res.string.recovery_choice_title), + subtitle = stringResource(Res.string.recovery_choice_body_pin), + icon = EnclyIcons.Key, + modifier = modifier, + footer = { + EnclyButton( + text = stringResource(Res.string.recovery_keep_pin), + onClick = { + viewModel.keepAfterRecovery() + navController.goHome() + }, + ) + EnclyTextButton( + text = stringResource(Res.string.recovery_set_new_pin), + onClick = { + navController.navigate(NavRoutes.PinCodeConfig.name) { + popUpTo(NavRoutes.RecoveryChoiceRoute.name) { inclusive = true } + } + }, + modifier = Modifier.fillMaxWidth(), + ) + }, + ) { } +} + +private fun NavHostController.goHome() { + navigate(NavRoutes.HomeRoute.name) { + popUpTo(NavRoutes.RecoveryChoiceRoute.name) { inclusive = true } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/StorageUnreadableScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/StorageUnreadableScreen.kt new file mode 100644 index 00000000..14114058 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/StorageUnreadableScreen.kt @@ -0,0 +1,76 @@ +package com.pasich.encly.presentation.screen + +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.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.vault_unreadable_message +import com.pasich.encly.core.resources.vault_unreadable_retry +import com.pasich.encly.core.resources.vault_unreadable_still +import com.pasich.encly.core.resources.vault_unreadable_title +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyIconTile +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +/** + * The vault's state file could not be read at start (another program holds it, or the device is still locked). + * Nothing is damaged and nothing is offered to wipe: the only action is to try again. + */ +@Composable +fun StorageUnreadableScreen(onRetry: () -> Unit, stillUnreadable: Boolean, modifier: Modifier = Modifier) { + val spacing = EnclyTheme.spacing + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(spacing.rowGap), + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(start = spacing.gutter, end = spacing.gutter, top = spacing.lockTop, bottom = spacing.l), + ) { + EnclyIconTile(icon = EnclyIcons.Alert) + Text( + text = stringResource(Res.string.vault_unreadable_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = spacing.xs), + ) + Text( + text = stringResource(Res.string.vault_unreadable_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (stillUnreadable) { + Text( + text = stringResource(Res.string.vault_unreadable_still), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + EnclyButton( + text = stringResource(Res.string.vault_unreadable_retry), + onClick = onRetry, + modifier = Modifier.padding(top = spacing.xl), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/SupportScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/SupportScreen.kt new file mode 100644 index 00000000..c0b54999 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/SupportScreen.kt @@ -0,0 +1,156 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +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.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.donation_description +import com.pasich.encly.core.resources.donation_title +import com.pasich.encly.core.resources.support_browser_note +import com.pasich.encly.core.resources.support_fact_free_desc +import com.pasich.encly.core.resources.support_fact_free_title +import com.pasich.encly.core.resources.support_fact_private_desc +import com.pasich.encly.core.resources.support_fact_private_title +import com.pasich.encly.core.resources.support_fact_uses_desc +import com.pasich.encly.core.resources.support_fact_uses_title +import com.pasich.encly.core.resources.support_method_kofi_hint +import com.pasich.encly.core.resources.support_title +import com.pasich.encly.platform.LocalExternalLinks +import com.pasich.encly.platform.LocalToaster +import com.pasich.encly.presentation.designsystem.EnclyIconTile +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.FactRow +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +private const val KOFI_URL = "https://ko-fi.com/pasichdev" + +/** + * Support the developer (F-Droid build only): why donations matter, then one Ko-fi card that + * opens the page in the browser; the app never handles payments. + */ +@Composable +fun SupportScreen(navController: NavHostController, modifier: Modifier = Modifier) { + val links = LocalExternalLinks.current + val toaster = LocalToaster.current + val spacing = EnclyTheme.spacing + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar(title = stringResource(Res.string.support_title), onBack = { navController.popBackStack() }) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = spacing.gutter, vertical = spacing.l), + verticalArrangement = Arrangement.spacedBy(spacing.section), + ) { + EnclyIconTile(icon = EnclyIcons.Heart) + StepHeading( + title = stringResource(Res.string.donation_title), + body = stringResource(Res.string.donation_description), + large = true, + ) + SupportFacts() + Column(verticalArrangement = Arrangement.spacedBy(spacing.s)) { + KofiCard(onClick = { openExternalLink(links, toaster, KOFI_URL) }) + Text( + text = stringResource(Res.string.support_browser_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** Why support matters, as three Welcome-style facts. */ +@Composable +private fun SupportFacts() { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.factGap)) { + FactRow( + icon = EnclyIcons.Star, + title = stringResource(Res.string.support_fact_free_title), + description = stringResource(Res.string.support_fact_free_desc), + ) + FactRow( + icon = EnclyIcons.Privacy, + title = stringResource(Res.string.support_fact_private_title), + description = stringResource(Res.string.support_fact_private_desc), + ) + FactRow( + icon = EnclyIcons.Heart, + title = stringResource(Res.string.support_fact_uses_title), + description = stringResource(Res.string.support_fact_uses_desc), + ) + } +} + +/** The Ko-fi card, filled with `primaryContainer` so it reads as the screen's one action. */ +@Composable +private fun KofiCard(onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val spacing = EnclyTheme.spacing + Surface( + shape = MaterialTheme.shapes.medium, + color = colors.primaryContainer, + contentColor = colors.onPrimaryContainer, + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.m), + modifier = Modifier.padding(spacing.m), + ) { + Surface(shape = MaterialTheme.shapes.small, color = colors.primary) { + Icon( + imageVector = EnclyIcons.Heart, + contentDescription = null, + tint = colors.onPrimary, + modifier = Modifier + .padding(spacing.s) + .size(spacing.iconMedium), + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(spacing.textGap)) { + Text(text = "Ko-fi", style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(Res.string.support_method_kofi_hint), + style = MaterialTheme.typography.bodySmall, + ) + } + Icon( + imageVector = EnclyIcons.External, + contentDescription = null, + modifier = Modifier.size(spacing.iconSmall), + ) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TagsListScreen.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TagsListScreen.kt index 1b4fb222..1e99fb3e 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TagsListScreen.kt @@ -12,23 +12,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.pasich.encly.R import com.pasich.encly.core.common.LoadState +import com.pasich.encly.core.common.asString +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.all 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource /** * The tag filter chips of the notes list: "All", then one chip per tag, in one horizontally * scrolling row that runs to the screen edges. */ @Composable -fun TagsList(modifier: Modifier = Modifier, tagListViewModel: TagListViewModel = enclyViewModel()) { +fun TagsList(modifier: Modifier = Modifier, tagListViewModel: TagListViewModel = metroViewModel()) { val state by tagListViewModel.state.collectAsStateWithLifecycle() val gutter = EnclyTheme.spacing.listGutter @@ -55,7 +57,7 @@ fun TagsList(modifier: Modifier = Modifier, tagListViewModel: TagListViewModel = ) { item { TagItem( - item = Tag(nameTag = stringResource(R.string.all), id = 0), + item = Tag(nameTag = stringResource(Res.string.all), id = 0), isSelected = state.selectedTagId.toInt() == 0, onItemClick = { tagListViewModel.onEvent(SelectTag(Tag(nameTag = "All", id = 0))) }, ) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TasksList.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TasksList.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/presentation/screen/TasksList.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TasksList.kt index 62a2955e..2ce2e2d9 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TasksList.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TasksList.kt @@ -1,8 +1,8 @@ package com.pasich.encly.presentation.screen -import androidx.activity.compose.BackHandler import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -16,10 +16,18 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.clear +import com.pasich.encly.core.resources.task_add +import com.pasich.encly.core.resources.task_all_done_title +import com.pasich.encly.core.resources.task_done_section +import com.pasich.encly.core.resources.task_empty_active +import com.pasich.encly.core.resources.task_empty_desc +import com.pasich.encly.core.resources.task_empty_filtered +import com.pasich.encly.core.resources.task_empty_title import com.pasich.encly.data.model.Task import com.pasich.encly.presentation.components.tasks.InlineSubtaskActions import com.pasich.encly.presentation.components.tasks.SubtaskTreeActions @@ -30,6 +38,7 @@ import com.pasich.encly.presentation.components.tiles.TaskItemActions import com.pasich.encly.presentation.designsystem.EnclyEmptyState import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclyInlineLink +import com.pasich.encly.presentation.designsystem.EnclyScrollbar import com.pasich.encly.presentation.designsystem.RowSkeleton import com.pasich.encly.presentation.designsystem.SectionOverline import com.pasich.encly.presentation.viewmodel.InlineSubtaskEdit @@ -37,6 +46,7 @@ import com.pasich.encly.presentation.viewmodel.TaskFilter import com.pasich.encly.presentation.viewmodel.TasksUiState import com.pasich.encly.presentation.viewmodel.TasksViewModel import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource private const val SKELETON_ROWS = 4 @@ -82,22 +92,25 @@ internal fun TasksList( modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current - LazyColumn( - state = state, - modifier = modifier - .fillMaxSize() - .imePadding() - .pointerInput(focusManager) { detectTapGestures { focusManager.clearFocus() } }, - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), - contentPadding = tasksListPadding(), - ) { - tasksContent( - state = list, - actions = actions, - onFilter = callbacks.onFilter, - onNewTask = callbacks.onNewTask, - onClearCompleted = callbacks.onClearCompleted, - ) + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + state = state, + modifier = Modifier + .fillMaxSize() + .imePadding() + .pointerInput(focusManager) { detectTapGestures { focusManager.clearFocus() } }, + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + contentPadding = tasksListPadding(), + ) { + tasksContent( + state = list, + actions = actions, + onFilter = callbacks.onFilter, + onNewTask = callbacks.onNewTask, + onClearCompleted = callbacks.onClearCompleted, + ) + } + EnclyScrollbar(state) } } @@ -137,9 +150,9 @@ private fun LazyListScope.tasksContent( item { EnclyEmptyState( icon = EnclyIcons.Checklist, - title = stringResource(R.string.task_empty_title), - body = stringResource(R.string.task_empty_desc), - actionLabel = stringResource(R.string.task_add), + title = stringResource(Res.string.task_empty_title), + body = stringResource(Res.string.task_empty_desc), + actionLabel = stringResource(Res.string.task_add), onAction = onNewTask, ) } @@ -152,14 +165,14 @@ private fun LazyListScope.tasksContent( item(key = "empty") { EnclyEmptyState( icon = EnclyIcons.Checklist, - title = stringResource(R.string.task_all_done_title).takeIf { ui.selectedPriorityFilter == null }, + title = stringResource(Res.string.task_all_done_title).takeIf { ui.selectedPriorityFilter == null }, body = stringResource( if (ui.selectedPriorityFilter != null ) { - R.string.task_empty_filtered + Res.string.task_empty_filtered } else { - R.string.task_empty_active + Res.string.task_empty_active }, ), ) @@ -231,7 +244,7 @@ private fun DoneHeader(count: Int, onClear: () -> Unit) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = EnclyTheme.spacing.m), ) { - SectionOverline(stringResource(R.string.task_done_section, count), modifier = Modifier.weight(1f)) - EnclyInlineLink(text = stringResource(R.string.clear), onClick = onClear) + SectionOverline(stringResource(Res.string.task_done_section, count), modifier = Modifier.weight(1f)) + EnclyInlineLink(text = stringResource(Res.string.clear), onClick = onClear) } } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TasksScreen.kt similarity index 76% rename from app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TasksScreen.kt index cbe6dff2..304f5ed5 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/TasksScreen.kt @@ -1,8 +1,5 @@ package com.pasich.encly.presentation.screen -import android.content.Context -import androidx.activity.compose.LocalActivity -import androidx.annotation.StringRes import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState @@ -26,13 +23,24 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.NavHostController -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.main_drawer_tasks +import com.pasich.encly.core.resources.subtask_deleted +import com.pasich.encly.core.resources.subtasks_all_done +import com.pasich.encly.core.resources.subtasks_complete_task +import com.pasich.encly.core.resources.task_add +import com.pasich.encly.core.resources.task_clear_completed_failed +import com.pasich.encly.core.resources.task_create_failed +import com.pasich.encly.core.resources.task_delete_failed +import com.pasich.encly.core.resources.task_deleted +import com.pasich.encly.core.resources.task_status_update_failed +import com.pasich.encly.core.resources.task_update_failed +import com.pasich.encly.core.resources.undo +import com.pasich.encly.platform.LocalAppWindow import com.pasich.encly.presentation.designsystem.EnclyFab import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclySnackbarHost @@ -42,9 +50,12 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource /** * The tasks screen: progress, filter chips, the open tasks and, below them, a "Done" section @@ -57,15 +68,14 @@ fun TasksScreen( navController: NavHostController, modifier: Modifier = Modifier, openNewTask: Boolean = false, - viewModel: TasksViewModel = enclyViewModel(), + viewModel: TasksViewModel = metroViewModel(), ) { val uiState by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } var confirmClear by remember { mutableStateOf(false) } OpenOnArrival(openNewTask, viewModel::showAddTaskDialog) - val context = LocalContext.current - LaunchedEffect(viewModel) { showTaskMessages(viewModel, snackbarHostState, context) } + LaunchedEffect(viewModel) { showTaskMessages(viewModel, snackbarHostState) } val listState = rememberTasksListState(uiState) val list = TasksListState(uiState, viewModel.expandedTaskIds.collectAsState().value, viewModel.inlineEdit) val itemActions = remember(viewModel) { taskItemActions(viewModel) } @@ -79,7 +89,7 @@ fun TasksScreen( snackbarHost = { EnclySnackbarHost(snackbarHostState) }, topBar = { EnclyTopBar( - title = stringResource(R.string.main_drawer_tasks), + title = stringResource(Res.string.main_drawer_tasks), onBack = { viewModel.commitInlineEdit() navController.popBackStack() @@ -127,7 +137,7 @@ fun TasksScreen( @Composable private fun TasksFab(listState: LazyListState, onClick: () -> Unit) { val expanded by remember(listState) { derivedStateOf { listState.firstVisibleItemIndex == 0 } } - EnclyFab(text = stringResource(R.string.task_add), icon = EnclyIcons.Plus, expanded = expanded, onClick = onClick) + EnclyFab(text = stringResource(Res.string.task_add), icon = EnclyIcons.Plus, expanded = expanded, onClick = onClick) } /** @@ -139,10 +149,10 @@ private fun TasksFab(listState: LazyListState, onClick: () -> Unit) { private fun OnPause(action: () -> Unit) { val currentAction by rememberUpdatedState(action) val lifecycleOwner = LocalLifecycleOwner.current - val activity = LocalActivity.current - DisposableEffect(lifecycleOwner, activity) { + val window = LocalAppWindow.current + DisposableEffect(lifecycleOwner, window) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE && activity?.isChangingConfigurations != true) currentAction() + if (event == Lifecycle.Event.ON_PAUSE && !window.isChangingConfigurations) currentAction() } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } @@ -178,21 +188,17 @@ private fun rememberTasksListState(state: TasksUiState): LazyListState { * Failures, "Task deleted" and "Sub-task deleted" with Undo, and "All sub-tasks done" with * Complete task, as snackbars, until the calling effect ends. */ -private suspend fun showTaskMessages( - viewModel: TasksViewModel, - snackbarHostState: SnackbarHostState, - context: Context, -) = coroutineScope { +private suspend fun showTaskMessages(viewModel: TasksViewModel, snackbarHostState: SnackbarHostState) = coroutineScope { launch { viewModel.operationFailures.collect { failure -> - snackbarHostState.showSnackbar(context.getString(failure.message()), duration = SnackbarDuration.Long) + snackbarHostState.showSnackbar(getString(failure.message()), duration = SnackbarDuration.Long) } } launch { viewModel.deletedTasks.collect { task -> val result = snackbarHostState.showSnackbar( - message = context.getString(R.string.task_deleted), - actionLabel = context.getString(R.string.undo), + message = getString(Res.string.task_deleted), + actionLabel = getString(Res.string.undo), duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) viewModel.restoreTask(task) @@ -201,8 +207,8 @@ private suspend fun showTaskMessages( launch { viewModel.subtaskDeletions.collect { subtask -> val result = snackbarHostState.showSnackbar( - message = context.getString(R.string.subtask_deleted), - actionLabel = context.getString(R.string.undo), + message = getString(Res.string.subtask_deleted), + actionLabel = getString(Res.string.undo), duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) viewModel.restoreSubtask(subtask) @@ -211,8 +217,8 @@ private suspend fun showTaskMessages( launch { viewModel.completionOffers.collect { taskId -> val result = snackbarHostState.showSnackbar( - message = context.getString(R.string.subtasks_all_done), - actionLabel = context.getString(R.string.subtasks_complete_task), + message = getString(Res.string.subtasks_all_done), + actionLabel = getString(Res.string.subtasks_complete_task), duration = SnackbarDuration.Long, ) if (result == SnackbarResult.ActionPerformed) viewModel.toggleTaskCompletion(taskId, true) @@ -221,11 +227,10 @@ private suspend fun showTaskMessages( } /** What the user is told when a task operation fails. */ -@StringRes -private fun TaskOperationFailure.message(): Int = when (this) { - TaskOperationFailure.CREATE -> R.string.task_create_failed - TaskOperationFailure.UPDATE -> R.string.task_update_failed - TaskOperationFailure.STATUS_UPDATE -> R.string.task_status_update_failed - TaskOperationFailure.CLEAR_COMPLETED -> R.string.task_clear_completed_failed - TaskOperationFailure.DELETE -> R.string.task_delete_failed +private fun TaskOperationFailure.message(): StringResource = when (this) { + TaskOperationFailure.CREATE -> Res.string.task_create_failed + TaskOperationFailure.UPDATE -> Res.string.task_update_failed + TaskOperationFailure.STATUS_UPDATE -> Res.string.task_status_update_failed + TaskOperationFailure.CLEAR_COMPLETED -> Res.string.task_clear_completed_failed + TaskOperationFailure.DELETE -> Res.string.task_delete_failed } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/UnlockedStartup.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/UnlockedStartup.kt new file mode 100644 index 00000000..b91b6afa --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/UnlockedStartup.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.presentation.screen + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.pasich.encly.presentation.dialogs.WhatsNewSheet +import com.pasich.encly.presentation.effects.LocalUnlockReveal +import com.pasich.encly.presentation.navigation.LocalVaultLocked +import com.pasich.encly.presentation.viewmodel.StartupViewModel +import com.pasich.encly.utils.currentLanguageTag +import dev.zacsweers.metrox.viewmodel.metroViewModel + +/** + * What the home screen starts when the vault is open: the trash is emptied of notes older than 28 days, and, the + * first time after an update, the "What's new" sheet is shown. The sheet waits for the unlock animation to end + * and is never drawn while the vault is locked, so it cannot appear over the lock screen. + */ +@Composable +internal fun UnlockedStartup(viewModel: StartupViewModel = metroViewModel()) { + val languageTag = currentLanguageTag() + LaunchedEffect(viewModel) { viewModel.onUnlocked(languageTag) } + val due by viewModel.whatsNewDue.collectAsState() + val revealing = LocalUnlockReveal.current?.running == true + val locked = LocalVaultLocked.current + val notes = due + if (notes != null && !revealing && !locked) { + WhatsNewSheet(notes = notes, onDismiss = viewModel::dismissWhatsNew) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt index 9585e62a..b2e352be 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt @@ -12,11 +12,14 @@ 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.core.common.LoadState +import com.pasich.encly.core.common.asString +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.archive_empty_desc +import com.pasich.encly.core.resources.archive_empty_title +import com.pasich.encly.core.resources.main_drawer_archive import com.pasich.encly.domain.model.NoteListItem import com.pasich.encly.domain.model.SwipeSettings import com.pasich.encly.presentation.components.swipe.SwipeContext @@ -33,8 +36,9 @@ import com.pasich.encly.presentation.screen.NoteSheetState import com.pasich.encly.presentation.viewmodel.ArchiveState import com.pasich.encly.presentation.viewmodel.ArchiveViewModel import com.pasich.encly.presentation.viewmodel.SettingsViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource private const val SKELETON_CARDS = 3 @@ -46,8 +50,8 @@ private const val SKELETON_CARDS = 3 fun ArchiveScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: ArchiveViewModel = enclyViewModel(), - settingsViewModel: SettingsViewModel = enclyViewModel(), + viewModel: ArchiveViewModel = metroViewModel(), + settingsViewModel: SettingsViewModel = metroViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val swipe by settingsViewModel.swipeSettings.collectAsStateWithLifecycle() @@ -67,7 +71,7 @@ fun ArchiveScreen( containerColor = MaterialTheme.colorScheme.surface, topBar = { EnclyTopBar( - title = stringResource(R.string.main_drawer_archive), + title = stringResource(Res.string.main_drawer_archive), onBack = { navController.popBackStack() }, ) }, @@ -118,8 +122,8 @@ private fun ArchivedNotes( state.notes.isEmpty() -> item { EnclyEmptyState( icon = EnclyIcons.Archive, - title = stringResource(R.string.archive_empty_title), - body = stringResource(R.string.archive_empty_desc), + title = stringResource(Res.string.archive_empty_title), + body = stringResource(Res.string.archive_empty_desc), ) } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogActions.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupDialogActions.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogActions.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupDialogActions.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt new file mode 100644 index 00000000..1d236a50 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt @@ -0,0 +1,386 @@ +package com.pasich.encly.presentation.screen.backup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.backup_confirm_phrase_body +import com.pasich.encly.core.resources.backup_confirm_phrase_title +import com.pasich.encly.core.resources.backup_count_notes +import com.pasich.encly.core.resources.backup_count_tags +import com.pasich.encly.core.resources.backup_count_tasks +import com.pasich.encly.core.resources.backup_create_phrase +import com.pasich.encly.core.resources.backup_decrypt +import com.pasich.encly.core.resources.backup_enter_phrase_body +import com.pasich.encly.core.resources.backup_enter_phrase_title +import com.pasich.encly.core.resources.backup_import_choose +import com.pasich.encly.core.resources.backup_import_mode_title +import com.pasich.encly.core.resources.backup_import_summary +import com.pasich.encly.core.resources.backup_merge +import com.pasich.encly.core.resources.backup_merge_row_desc +import com.pasich.encly.core.resources.backup_needs_phrase_body +import com.pasich.encly.core.resources.backup_needs_phrase_title +import com.pasich.encly.core.resources.backup_phrase_write_down +import com.pasich.encly.core.resources.backup_phrase_written +import com.pasich.encly.core.resources.backup_replace +import com.pasich.encly.core.resources.backup_replace_all +import com.pasich.encly.core.resources.backup_replace_confirm_body +import com.pasich.encly.core.resources.backup_replace_confirm_title +import com.pasich.encly.core.resources.backup_replace_row_desc +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.onboarding_phrase_hide +import com.pasich.encly.core.resources.onboarding_phrase_show +import com.pasich.encly.core.resources.onboarding_phrase_title +import com.pasich.encly.core.resources.onboarding_verify_body +import com.pasich.encly.core.resources.onboarding_verify_confirm +import com.pasich.encly.core.resources.onboarding_verify_show_again +import com.pasich.encly.core.resources.onboarding_verify_title +import com.pasich.encly.core.resources.recovery_phrase_entry_label +import com.pasich.encly.core.resources.security_recovery_replace_confirm +import com.pasich.encly.core.resources.security_recovery_replace_confirm_body +import com.pasich.encly.core.resources.security_recovery_replace_confirm_title +import com.pasich.encly.core.resources.security_recovery_replace_title +import com.pasich.encly.core.resources.security_recovery_replace_warning_body +import com.pasich.encly.core.resources.security_recovery_replace_warning_title +import com.pasich.encly.presentation.designsystem.CalloutTone +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclyDialog +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.EnclyPillButton +import com.pasich.encly.presentation.designsystem.EnclySheetRow +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.LabelHeader +import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput +import com.pasich.encly.presentation.designsystem.RecoveryPhraseState +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.presentation.designsystem.WordGrid +import com.pasich.encly.presentation.screen.onboarding.FooterSpec +import com.pasich.encly.presentation.screen.onboarding.OnboardingFooter +import com.pasich.encly.presentation.screen.onboarding.PhraseCheckFields +import com.pasich.encly.presentation.screen.onboarding.allChecksCorrect +import com.pasich.encly.presentation.viewmodel.BackupStep +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource + +/** + * The dialog or full-screen step for the current [step] of a backup or vault flow, if it has + * one. Phrase steps and the import choice cover the whole screen like the onboarding steps, so + * callers draw this last, over their content (in a Box). [busy] shows progress on the primary. + */ +@Composable +fun BackupDialogs(actions: BackupDialogActions, step: BackupStep, busy: Boolean = false) { + when (step) { + is BackupStep.Reauth -> ReauthDialog(actions.reauth, step, actions.cancel) + + BackupStep.NeedsPhrase -> MessageDialog( + title = Res.string.backup_needs_phrase_title, + body = Res.string.backup_needs_phrase_body, + confirm = Res.string.backup_create_phrase, + onConfirm = actions.phrase::create, + onDismiss = actions.cancel, + ) + + is BackupStep.ShowNewPhrase -> NewPhraseStep(step.words, step.replacing, actions) + + BackupStep.ConfirmReplacePhrase -> ReplacePhraseDialog(actions) + + is BackupStep.CheckNewPhrase -> PhraseCheckStep(step, actions, busy) + + is BackupStep.ConfirmExistingPhrase -> PhraseEntryStep( + texts = PhraseEntryTexts( + Res.string.backup_needs_phrase_title, + Res.string.backup_confirm_phrase_title, + Res.string.backup_confirm_phrase_body, + ), + error = step.error, + busy = busy, + actions = actions, + onSubmit = actions.phrase::submitExisting, + ) + + is BackupStep.EnterImportPhrase -> PhraseEntryStep( + texts = PhraseEntryTexts( + Res.string.backup_import_choose, + Res.string.backup_enter_phrase_title, + Res.string.backup_enter_phrase_body, + Res.string.backup_decrypt, + ), + error = step.error, + busy = busy, + actions = actions, + onSubmit = actions.importing::submitPhrase, + ) + + is BackupStep.ChooseImportMode -> ImportModeStep(step, actions, busy) + + BackupStep.ConfirmReplace -> MessageDialog( + title = Res.string.backup_replace_confirm_title, + body = Res.string.backup_replace_confirm_body, + confirm = Res.string.backup_replace, + onConfirm = actions.importing::confirmReplace, + destructive = true, + // Cancel goes back to the choice: the backup was decrypted with 12 typed words. + onDismiss = actions.importing::backToChoice, + ) + + else -> Unit + } +} + +/** Before new recovery words: the old ones stop working here, old backups keep needing them. */ +@Composable +private fun ReplacePhraseDialog(actions: BackupDialogActions) = MessageDialog( + title = Res.string.security_recovery_replace_confirm_title, + body = Res.string.security_recovery_replace_confirm_body, + confirm = Res.string.security_recovery_replace_confirm, + onConfirm = actions.phrase::replace, + onDismiss = actions.cancel, +) + +@Composable +private fun MessageDialog( + title: StringResource, + body: StringResource, + confirm: StringResource, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + destructive: Boolean = false, +) { + EnclyDialog( + title = stringResource(title), + text = stringResource(body), + onDismissRequest = onDismiss, + confirm = DialogAction(stringResource(confirm), onConfirm, destructive = destructive), + dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), + ) +} + +/** + * A backup step laid out like an onboarding step, over the whole screen: a back header with + * [label], the scrolling [content] in the gutter, and [footer] pinned above the keyboard. + * System back runs [onBack] too. + */ +@Composable +private fun FullScreenStep( + label: String, + onBack: () -> Unit, + footer: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + BackHandler(onBack = onBack) + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .imePadding(), + ) { + LabelHeader(label = label, onBack = onBack) + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.l), + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = EnclyTheme.spacing.gutter) + .padding(top = EnclyTheme.spacing.m, bottom = EnclyTheme.spacing.l), + content = content, + ) + footer() + } + } +} + +/** + * The new recovery phrase to write down, with a Hide/Show pill, as in onboarding. A + * [replacing] phrase also says what happens to the old words and the backups made with them. + */ +@Composable +private fun NewPhraseStep(words: List, replacing: Boolean, actions: BackupDialogActions) { + var hidden by remember { mutableStateOf(false) } + FullScreenStep( + label = stringResource( + if (replacing) Res.string.security_recovery_replace_title else Res.string.backup_needs_phrase_title, + ), + onBack = actions.cancel, + footer = { OnboardingFooter(FooterSpec(Res.string.backup_phrase_written, actions.phrase::writtenDown)) }, + ) { + StepHeading( + title = stringResource(Res.string.onboarding_phrase_title), + body = stringResource(Res.string.backup_phrase_write_down), + ) + if (replacing) { + EnclyCallout( + title = stringResource(Res.string.security_recovery_replace_warning_title), + text = stringResource(Res.string.security_recovery_replace_warning_body), + tone = CalloutTone.WARNING, + ) + } + WordGrid(words = words, hidden = hidden) + EnclyPillButton( + text = stringResource(if (hidden) Res.string.onboarding_phrase_show else Res.string.onboarding_phrase_hide), + leadingIcon = if (hidden) EnclyIcons.Eye else EnclyIcons.EyeOff, + onClick = { hidden = !hidden }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +/** Three of the new words typed back, each checked as it is typed; Confirm once all are right. */ +@Composable +private fun PhraseCheckStep(step: BackupStep.CheckNewPhrase, actions: BackupDialogActions, busy: Boolean) { + val checks = remember(step.positions, step.expected) { step.positions.zip(step.expected) } + val answers = remember(step.positions) { mutableStateMapOf() } + FullScreenStep( + label = stringResource(Res.string.backup_needs_phrase_title), + onBack = actions.phrase::showAgain, + footer = { + OnboardingFooter( + FooterSpec( + primary = Res.string.onboarding_verify_confirm, + onPrimary = { actions.phrase.submitCheck(step.positions.map { answers[it].orEmpty() }) }, + enabled = allChecksCorrect(checks, answers), + loading = busy, + secondary = Res.string.onboarding_verify_show_again, + onSecondary = actions.phrase::showAgain, + ), + ) + }, + ) { + StepHeading( + title = stringResource(Res.string.onboarding_verify_title), + body = stringResource(Res.string.onboarding_verify_body), + ) + PhraseCheckFields( + checks = checks, + answers = answers, + onAnswer = { index, answer -> + answers[index] = answer.trim().lowercase() + actions.clearError() + }, + enabled = !busy, + ) + } +} + +private data class PhraseEntryTexts( + val label: StringResource, + val title: StringResource, + val body: StringResource, + val confirm: StringResource = Res.string.action_continue, +) + +/** The 12 words, one numbered cell each; the primary waits for a valid phrase. */ +@Composable +private fun PhraseEntryStep( + texts: PhraseEntryTexts, + error: StringResource?, + busy: Boolean, + actions: BackupDialogActions, + onSubmit: (CharArray) -> Unit, +) { + // Never saved state: the words live only while this step does. + val phrase = remember { RecoveryPhraseState() } + val submit = { if (phrase.canSubmit && !busy) onSubmit(phrase.toCharArray()) } + FullScreenStep( + label = stringResource(texts.label), + onBack = actions.cancel, + footer = { + OnboardingFooter(FooterSpec(texts.confirm, submit, enabled = phrase.canSubmit, loading = busy)) + }, + ) { + StepHeading(title = stringResource(texts.title), body = stringResource(texts.body)) + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + Text( + text = stringResource(Res.string.recovery_phrase_entry_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + RecoveryPhraseInput( + state = phrase, + enabled = !busy, + error = error?.let { stringResource(it) }, + onEdit = actions.clearError, + onDone = submit, + ) + } + } +} + +/** + * What the decrypted backup holds, and the choice: merge, or replace everything (confirmed + * again). Only Cancel, back and the header drop the decrypted backup. + */ +@Composable +private fun ImportModeStep(step: BackupStep.ChooseImportMode, actions: BackupDialogActions, busy: Boolean) { + val summary = stringResource( + Res.string.backup_import_summary, + pluralStringResource(Res.plurals.backup_count_notes, step.notes, step.notes), + pluralStringResource(Res.plurals.backup_count_tasks, step.tasks, step.tasks), + pluralStringResource(Res.plurals.backup_count_tags, step.tags, step.tags), + ) + FullScreenStep( + label = stringResource(Res.string.backup_import_choose), + onBack = actions.cancel, + footer = { + EnclyTextButton( + text = stringResource(Res.string.cancel), + onClick = actions.cancel, + enabled = !busy, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = EnclyTheme.spacing.gutter) + .padding(top = EnclyTheme.spacing.s, bottom = EnclyTheme.spacing.stepGap), + ) + }, + ) { + StepHeading(title = stringResource(Res.string.backup_import_mode_title), body = summary) + EnclyGroup { + EnclySheetRow( + title = stringResource(Res.string.backup_merge), + supporting = stringResource(Res.string.backup_merge_row_desc), + icon = EnclyIcons.Plus, + enabled = !busy, + onClick = actions.importing::merge, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + EnclyGroupDivider() + EnclySheetRow( + title = stringResource(Res.string.backup_replace_all), + supporting = stringResource(Res.string.backup_replace_row_desc), + icon = EnclyIcons.Restore, + destructive = true, + enabled = !busy, + onClick = actions.importing::askReplace, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupErrors.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupErrors.kt new file mode 100644 index 00000000..42862d11 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupErrors.kt @@ -0,0 +1,43 @@ +package com.pasich.encly.presentation.screen.backup + +import com.pasich.encly.core.backup.BackupError +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_corrupted +import com.pasich.encly.core.resources.backup_error_export_too_large +import com.pasich.encly.core.resources.backup_error_invalid_phrase +import com.pasich.encly.core.resources.backup_error_io +import com.pasich.encly.core.resources.backup_error_locked +import com.pasich.encly.core.resources.backup_error_not_a_backup +import com.pasich.encly.core.resources.backup_error_target_exists +import com.pasich.encly.core.resources.backup_error_too_large +import com.pasich.encly.core.resources.backup_error_unsupported +import com.pasich.encly.core.resources.backup_error_wrong_phrase +import org.jetbrains.compose.resources.StringResource + +/** The message shown for a failed export/import. Never includes file content or words. */ +fun backupErrorMessage(error: BackupError): StringResource = when (error) { + BackupError.NOT_A_BACKUP -> Res.string.backup_error_not_a_backup + + BackupError.UNSUPPORTED_VERSION -> Res.string.backup_error_unsupported + + BackupError.TRUNCATED, BackupError.CORRUPTED, BackupError.INVALID_PAYLOAD -> + Res.string.backup_error_corrupted + + BackupError.TOO_LARGE -> Res.string.backup_error_too_large + + BackupError.TARGET_EXISTS -> Res.string.backup_error_target_exists + + BackupError.WRONG_SECRET -> Res.string.backup_error_wrong_phrase + + BackupError.INVALID_PHRASE -> Res.string.backup_error_invalid_phrase + + BackupError.VAULT_UNAVAILABLE -> Res.string.backup_error_locked + + BackupError.IO -> Res.string.backup_error_io +} + +/** Export-specific wording: there the vault, not a picked file, is what is too large. */ +fun backupExportErrorMessage(error: BackupError): StringResource = when (error) { + BackupError.TOO_LARGE -> Res.string.backup_error_export_too_large + else -> backupErrorMessage(error) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupScreen.kt new file mode 100644 index 00000000..15170d84 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupScreen.kt @@ -0,0 +1,281 @@ +package com.pasich.encly.presentation.screen.backup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LinearProgressIndicator +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.core.backup.BackupFormat +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_export +import com.pasich.encly.core.resources.backup_import +import com.pasich.encly.core.resources.backup_import_desc +import com.pasich.encly.core.resources.backup_import_done +import com.pasich.encly.core.resources.backup_intro_body +import com.pasich.encly.core.resources.backup_intro_short +import com.pasich.encly.core.resources.backup_intro_title +import com.pasich.encly.core.resources.backup_last_export +import com.pasich.encly.core.resources.backup_never_exported +import com.pasich.encly.core.resources.backup_section_export +import com.pasich.encly.core.resources.backup_section_import +import com.pasich.encly.core.resources.backup_section_markdown +import com.pasich.encly.core.resources.backup_title +import com.pasich.encly.core.resources.markdown_export +import com.pasich.encly.core.resources.markdown_export_desc +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.LocalDocumentPickers +import com.pasich.encly.platform.PickerUnavailableException +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyPillButton +import com.pasich.encly.presentation.designsystem.EnclySnackbarHost +import com.pasich.encly.presentation.designsystem.EnclyTonalButton +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.viewmodel.BackupAction +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.MarkdownExportTarget +import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.utils.formatNoteDate +import com.pasich.encly.utils.todayStamp +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import kotlin.time.Instant + +/** + * Settings → Backup: encrypted export and import. Files go through the Storage Access + * Framework only, so Encly needs no storage permission and never learns more than the one + * document the user picked. + */ +@Composable +fun BackupScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + viewModel: BackupViewModel = metroViewModel(), + markdownViewModel: MarkdownExportViewModel = metroViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + BackupPickers( + step = state.step, + onExportTarget = viewModel.exportFlow::onTarget, + onImportFile = viewModel.importFlow::onFile, + onPickerLaunch = viewModel::onSystemPickerLaunched, + onPickerUnavailable = viewModel::onSystemPickerUnavailable, + ) + val dialogActions = rememberBackupDialogActions(viewModel) + + // Shown from a screen-wide scope: clearing the message restarts this effect, which must not + // cancel the snackbar it just opened. + val snackbarScope = rememberCoroutineScope() + LaunchedEffect(state.message) { + val message = state.message ?: return@LaunchedEffect + viewModel.clearMessage() + snackbarScope.launch { snackbarHostState.showSnackbar(message.resolve()) } + } + + Box(modifier = modifier.fillMaxSize()) { + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar(title = stringResource(Res.string.backup_title), onBack = { navController.popBackStack() }) + }, + snackbarHost = { EnclySnackbarHost(snackbarHostState) }, + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + // A fixed slot: the page does not jump when the progress bar comes and goes. + Box(modifier = Modifier.fillMaxWidth().height(EnclyTheme.spacing.xxs)) { + if (state.busy) { + LinearProgressIndicator( + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.fillMaxSize(), + ) + } + } + BackupContent( + state = state, + onStart = viewModel::start, + onMarkdown = { markdownViewModel.start(MarkdownExportTarget.AllData) }, + ) + } + } + // Drawn last: the phrase steps cover the whole screen. + BackupDialogs(dialogActions, state.step, state.busy) + MarkdownExportHost(onMessage = { text -> snackbarScope.launch { snackbarHostState.showSnackbar(text) } }) + } +} + +@Composable +private fun BackupContent(state: BackupUiState, onStart: (BackupAction) -> Unit, onMarkdown: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), + ) { + Text( + text = stringResource(Res.string.backup_intro_short), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(text = stringResource(Res.string.backup_section_export)) + ExportAction( + lastExportAt = state.lastExportAt, + enabled = !state.busy, + onExport = { onStart(BackupAction.EXPORT) }, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(text = stringResource(Res.string.backup_section_import)) + ImportAction(enabled = !state.busy, onImport = { onStart(BackupAction.IMPORT) }) + } + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(text = stringResource(Res.string.backup_section_markdown)) + MarkdownAction(enabled = !state.busy, onExport = onMarkdown) + } + EnclyCallout( + title = stringResource(Res.string.backup_intro_title), + text = stringResource(Res.string.backup_intro_body), + icon = EnclyIcons.Shield, + ) + } +} + +@Composable +private fun ExportAction(lastExportAt: Long?, enabled: Boolean, onExport: () -> Unit) { + val lastExport = lastExportAt?.let { + stringResource(Res.string.backup_last_export, formatNoteDate(Instant.fromEpochMilliseconds(it))) + } ?: stringResource(Res.string.backup_never_exported) + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + EnclyTonalButton(text = stringResource(Res.string.backup_export), onClick = onExport, enabled = enabled) + Text(text = lastExport, style = EnclyTheme.typography.meta, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** The plaintext export: a `.zip` of Markdown files, which is not encrypted (a warning comes first). */ +@Composable +private fun MarkdownAction(enabled: Boolean, onExport: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + EnclyPillButton( + text = stringResource(Res.string.markdown_export), + onClick = onExport, + enabled = enabled, + leadingIcon = EnclyIcons.Upload, + ) + Text( + text = stringResource(Res.string.markdown_export_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ImportAction(enabled: Boolean, onImport: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + EnclyPillButton( + text = stringResource(Res.string.backup_import), + onClick = onImport, + enabled = enabled, + leadingIcon = EnclyIcons.Restore, + ) + Text( + text = stringResource(Res.string.backup_import_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Opens the system "create document" / "open document" picker when a flow asks for it. */ +@Composable +private fun BackupPickers( + step: BackupStep, + onExportTarget: (DocumentRef?) -> Unit, + onImportFile: (DocumentRef?) -> Unit, + onPickerLaunch: () -> Unit, + onPickerUnavailable: () -> Unit, +) { + val pickers = LocalDocumentPickers.current + val exportLauncher = pickers.rememberCreateDocument(BackupFormat.MIME_TYPE, onExportTarget) + val importLauncher = pickers.rememberOpenDocument(onImportFile) + // The effect restarts only on [step]; always report to the latest callbacks. + val currentOnPickerLaunched by rememberUpdatedState(onPickerLaunch) + val currentOnPickerUnavailable by rememberUpdatedState(onPickerUnavailable) + LaunchedEffect(step) { + when (step) { + BackupStep.PickExportTarget -> + launchPicker(currentOnPickerLaunched, currentOnPickerUnavailable) { + exportLauncher.launch(defaultFileName()) + } + + BackupStep.PickImportFile -> + launchPicker(currentOnPickerLaunched, currentOnPickerUnavailable) { + importLauncher.launch(arrayOf("*/*")) + } + + else -> Unit + } + } +} + +/** + * Keeps the vault open for the picker only once it has really started: a device without a + * documents app throws [PickerUnavailableException], which ends the flow with a message + * instead of crashing and leaves the next backgrounding locking as usual. + */ +private fun launchPicker(onLaunched: () -> Unit, onUnavailable: () -> Unit, launch: () -> Unit) { + try { + launch() + } catch (_: PickerUnavailableException) { + onUnavailable() + return + } + onLaunched() +} + +/** "encly-backup-2026-09-23.enclybak": a date only, never a title or other content. */ +private fun defaultFileName(): String { + val date = todayStamp() + return "encly-backup-$date.${BackupFormat.FILE_EXTENSION}" +} + +private suspend fun BackupMessage.resolve(): String = when (this) { + is BackupMessage.Text -> getString(id) + + is BackupMessage.Imported -> getString( + Res.string.backup_import_done, + summary.notesAdded, + summary.tasksAdded, + summary.tagsAdded, + summary.skipped, + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/MarkdownExportHost.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/MarkdownExportHost.kt new file mode 100644 index 00000000..f40ef528 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/MarkdownExportHost.kt @@ -0,0 +1,112 @@ +package com.pasich.encly.presentation.screen.backup + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.markdown_export_warning_body +import com.pasich.encly.core.resources.markdown_export_warning_title +import com.pasich.encly.core.resources.pdf_export_warning_body +import com.pasich.encly.core.resources.pdf_export_warning_title +import com.pasich.encly.core.resources.print_warning_body +import com.pasich.encly.core.resources.print_warning_title +import com.pasich.encly.data.export.MarkdownExport +import com.pasich.encly.platform.LocalDocumentPickers +import com.pasich.encly.platform.PickerUnavailableException +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import com.pasich.encly.presentation.viewmodel.BackupMessage +import com.pasich.encly.presentation.viewmodel.BackupStep +import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource + +/** + * The screen side of the Markdown export, drawn by the Backup screen and by the editor: the + * warning that the file is not encrypted, the PIN (or biometric) re-authentication every export + * uses, and the system save dialog the file is written through. [onMessage] shows the result. + * Nothing else leaves the app: no share sheet, no temp file. + */ +@Composable +internal fun MarkdownExportHost( + onMessage: (String) -> Unit, + viewModel: MarkdownExportViewModel = metroViewModel(), + onMessageRes: ((StringResource) -> Unit)? = null, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val pickers = LocalDocumentPickers.current + val noteLauncher = pickers.rememberCreateDocument(MarkdownExport.MIME_NOTE, viewModel::onTarget) + val zipLauncher = pickers.rememberCreateDocument(MarkdownExport.MIME_ZIP, viewModel::onTarget) + val pdfLauncher = pickers.rememberCreateDocument(MIME_PDF, viewModel::onTarget) + val currentOnMessage by rememberUpdatedState(onMessage) + val currentOnMessageRes by rememberUpdatedState(onMessageRes) + + LaunchedEffect(state.step) { + val step = state.step as? BackupStep.PickMarkdownTarget ?: return@LaunchedEffect + try { + val launcher = when { + step.pdf -> pdfLauncher + step.zip -> zipLauncher + else -> noteLauncher + } + launcher.launch(step.name) + } catch (_: PickerUnavailableException) { + viewModel.onSystemPickerUnavailable() + return@LaunchedEffect + } + viewModel.onSystemPickerLaunched() + } + LaunchedEffect(state.message) { + val message = state.message as? BackupMessage.Text ?: return@LaunchedEffect + viewModel.clearMessage() + // A host that shows its messages itself (the list's toasts) takes the resource; the others the text. + currentOnMessageRes?.invoke(message.id) ?: currentOnMessage(getString(message.id)) + } + + when (val step = state.step) { + BackupStep.ConfirmPlainExport -> PlainExportWarning( + onContinue = viewModel::confirmWarning, + onCancel = viewModel::cancel, + ) + + is BackupStep.ConfirmDocumentExport -> PlainExportWarning( + onContinue = viewModel::confirmWarning, + onCancel = viewModel::cancel, + title = stringResource( + if (step.print) Res.string.print_warning_title else Res.string.pdf_export_warning_title, + ), + body = stringResource( + if (step.print) Res.string.print_warning_body else Res.string.pdf_export_warning_body, + ), + ) + + is BackupStep.Reauth -> ReauthDialog(viewModel.reauthFlow, step, viewModel::cancel) + + else -> Unit + } +} + +private const val MIME_PDF = "application/pdf" + +/** Before any plaintext export: the file is not encrypted, wherever it is saved. */ +@Composable +private fun PlainExportWarning( + onContinue: () -> Unit, + onCancel: () -> Unit, + title: String = stringResource(Res.string.markdown_export_warning_title), + body: String = stringResource(Res.string.markdown_export_warning_body), +) { + EnclyDialog( + title = title, + text = body, + onDismissRequest = onCancel, + confirm = DialogAction(stringResource(Res.string.action_continue), onContinue), + dismiss = DialogAction(stringResource(Res.string.cancel), onCancel), + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/ReauthDialog.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/ReauthDialog.kt new file mode 100644 index 00000000..ac96e70b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/ReauthDialog.kt @@ -0,0 +1,121 @@ +package com.pasich.encly.presentation.screen.backup + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.backup_reauth_subtitle +import com.pasich.encly.core.resources.backup_reauth_title +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.platform.LocalBiometricHost +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.ProtectFromCapture +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.PinSubmit +import com.pasich.encly.presentation.screen.pincode.pinLockoutText +import com.pasich.encly.presentation.viewmodel.BackupStep +import com.pasich.encly.presentation.viewmodel.ReauthFlow +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +/** + * Full-screen PIN entry (plus the fingerprint key, when enabled) before an export or import, run by [reauth]; + * [onCancel] closes it. The one re-authentication every export goes through. + */ +@Composable +internal fun ReauthDialog(reauth: ReauthFlow, step: BackupStep.Reauth, onCancel: () -> Unit) { + // Before the Dialog: its own window takes the protection from the activity's when it is made. + ProtectFromCapture() + var lockoutSeconds by remember { mutableLongStateOf(0L) } + // Keyed on the step too: a wrong PIN (a new step) may just have started a lockout. + PinLockoutTicker(step to (lockoutSeconds > 0L), reauth::pinLockoutRemainingMillis) { + lockoutSeconds = it + } + val lockedOut = lockoutSeconds > 0L + val error = step.error?.let { stringResource(it) } + val subtitle = when { + lockedOut -> pinLockoutText(lockoutSeconds) + error != null -> error + else -> stringResource(Res.string.backup_reauth_subtitle) + } + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + ReauthPinEntry( + reauth = reauth, + step = step, + entry = ReauthEntry( + subtitle = subtitle, + subtitleIsError = lockedOut || error != null, + hasError = error != null, + lockedOut = lockedOut, + onCancel = onCancel, + ), + ) + } +} + +/** What the entry of [ReauthDialog] shows: the sub-heading, a running lockout and where the cancel button goes. */ +private class ReauthEntry( + val subtitle: String, + val subtitleIsError: Boolean, + val hasError: Boolean, + val lockedOut: Boolean, + val onCancel: () -> Unit, +) + +@Composable +private fun ReauthPinEntry(reauth: ReauthFlow, step: BackupStep.Reauth, entry: ReauthEntry) { + val host = LocalBiometricHost.current + // Wiped once submitted or when the dialog closes, never a String (see PinBuffer). + val input = remember { PinBuffer() } + DisposableEffect(input) { onDispose { input.clear() } } + PinEntryScaffold( + title = stringResource(Res.string.backup_reauth_title), + subtitle = entry.subtitle, + subtitleIsError = entry.subtitleIsError, + bodyAtBottom = true, + modifier = Modifier.fillMaxSize(), + ) { + PinEntry( + entered = input.length, + error = entry.hasError && input.length == 0, + shakeKey = step.failures, + enabled = !entry.lockedOut, + actions = PinEntryActions( + onDigit = { digit -> if (!entry.lockedOut) input.add(digit) }, + onBackspace = input::deleteLast, + onBiometric = if (step.biometric && host != null) { + { reauth.withBiometric(host) } + } else { + null + }, + ), + // The flow wipes the PIN it is given. No auto-submit: a PIN is 6 to 12 digits. + submit = PinSubmit( + text = stringResource(Res.string.action_continue), + enabled = input.canSubmit, + onClick = { if (input.canSubmit) reauth.submitPin(input.take()) }, + ), + ) + EnclyTextButton( + text = stringResource(Res.string.cancel), + onClick = entry.onCancel, + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/CopyMarkdown.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/CopyMarkdown.kt new file mode 100644 index 00000000..6820a7be --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/CopyMarkdown.kt @@ -0,0 +1,26 @@ +package com.pasich.encly.presentation.screen.editnote + +import com.pasich.encly.data.export.MarkdownNotes +import com.pasich.encly.platform.SensitiveClipboard +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel + +/** + * Copies the note as the editor shows it now (unsaved edits included) as Markdown, through + * [SensitiveClipboard]: marked sensitive and cleared after a minute. The note is already open, so no + * re-authentication is asked; this is the text a selection would copy. + * + * [label] is the clipboard label and [confirmation] the text shown after the copy (both in the in-app + * language). Returns the confirmation to show, or null when there is nothing to say: nothing was written + * (a blank note, an unreadable one) or the system already confirms the copy (Android 13+). + */ +fun copyNoteAsMarkdown( + clipboard: SensitiveClipboard, + viewModel: EditNoteViewModel, + label: String, + confirmation: String, +): String? { + if (viewModel.isBlankDraft || viewModel.contentLoadFailed.value) return null + val markdown = MarkdownNotes.renderBody(viewModel.state.value.note.title, viewModel.blocks) + val copied = clipboard.copy(label, markdown) + return confirmation.takeIf { copied && !clipboard.systemConfirmsCopy } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt similarity index 77% rename from app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt index 9fe235da..1a887fb2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt @@ -9,10 +9,8 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import com.pasich.encly.domain.model.FontStyleType import com.pasich.encly.domain.model.NoteLineSpacing -import com.pasich.encly.dynamicBlocks.BlockType -import com.pasich.encly.presentation.editor.blocks.LocalSlashLabels -import com.pasich.encly.presentation.editor.blocks.rememberSlashLabels -import com.pasich.encly.ui.theme.fontSet +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.ui.theme.sharedFontSet import com.pasich.encly.utils.FontSizeUtils /** @@ -23,7 +21,8 @@ private object EditNoteDefaults { val DEFAULT_FONT_STYLE = FontStyleType.DEFAULT const val DEFAULT_SIMPLE_EDIT = false - val DEFAULT_FONT_FAMILIES = DEFAULT_FONT_STYLE.fontFamilies() + /** The platform's own font: what a failure falls back to, before the bundled files are read. */ + val DEFAULT_FONT_FAMILIES = FontFamilies(heading = FontFamily.Default, body = FontFamily.Default) } /** @@ -41,9 +40,6 @@ 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) */ @@ -70,11 +66,11 @@ fun EditNoteSettingsProvider( LocalBaseFontSize provides baseFontSize, LocalFontStyle provides fontStyle, LocalSimpleEdit provides simpleEdit, - LocalNoteLineSpacing provides lineSpacing, LocalFontStyles provides fontStyles, - LocalSlashLabels provides rememberSlashLabels(), - content = content, - ) + ) { + // The editor reads its fonts, spacing and texts from what the app binds to it. + AppEditorProvider(fontStyles, lineSpacing, content) + } } /** @@ -96,17 +92,18 @@ fun rememberFontStyles(): FontStyles { private fun rememberFontStylesOptimized( baseFontSize: TextUnit = LocalBaseFontSize.current, fontStyle: FontStyleType = LocalFontStyle.current, -): FontStyles = remember(baseFontSize, fontStyle) { - try { - val fontSizes = computeFontSizes(baseFontSize) - val fontFamilies = computeFontFamilies(fontStyle) - - FontStyles( - sizes = fontSizes, - families = fontFamilies, - ) - } catch (_: Exception) { - createDefaultFontStyles() +): FontStyles { + // The editor uses the app-wide font set (Settings → Appearance → Font). + val fonts = fontStyle.sharedFontSet() + return remember(baseFontSize, fonts) { + try { + FontStyles( + sizes = computeFontSizes(baseFontSize), + families = FontFamilies(heading = fonts.display, body = fonts.body), + ) + } catch (_: Exception) { + createDefaultFontStyles() + } } } @@ -133,20 +130,6 @@ private fun computeFontSizes(baseFontSize: TextUnit): FontSizes = try { createDefaultFontSizes() } -/** - * Safe font families computation without Compose context - */ -private fun computeFontFamilies(fontStyle: FontStyleType): FontFamilies = try { - fontStyle.fontFamilies() -} catch (_: Exception) { - EditNoteDefaults.DEFAULT_FONT_FAMILIES -} - -/** The editor uses the app-wide font set (Settings → Appearance → Font). */ -private fun FontStyleType.fontFamilies(): FontFamilies = fontSet().let { - FontFamilies(heading = it.display, body = it.body) -} - /** * Creates default font sizes for error scenarios */ diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt new file mode 100644 index 00000000..8d6aef68 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt @@ -0,0 +1,548 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.LocalOverscrollFactory +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.savedstate.read +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.dialog_message +import com.pasich.encly.core.resources.dialog_title +import com.pasich.encly.core.resources.markdown_copied +import com.pasich.encly.core.resources.markdown_copy +import com.pasich.encly.core.resources.note_discard_confirm_message +import com.pasich.encly.core.resources.note_discard_confirm_title +import com.pasich.encly.core.resources.swipe_delete_message +import com.pasich.encly.core.resources.swipe_delete_title +import com.pasich.encly.domain.model.NoteColor +import com.pasich.encly.editor.theme.LocalEditorTheme +import com.pasich.encly.editor.theme.blockGap +import com.pasich.encly.editor.theme.isCompact +import com.pasich.encly.editor.ui.EditorBlocksHost +import com.pasich.encly.editor.ui.FindHighlights +import com.pasich.encly.editor.ui.TextRangeOverlay +import com.pasich.encly.editor.ui.editorBlocks +import com.pasich.encly.editor.ui.rememberBlockFocusRegistry +import com.pasich.encly.editor.ui.rememberWriteAtEnd +import com.pasich.encly.editor.ui.tapBelowBlocks +import com.pasich.encly.platform.LocalSensitiveClipboard +import com.pasich.encly.presentation.components.editNote.NoteBottomBar +import com.pasich.encly.presentation.components.editNote.NoteOverline +import com.pasich.encly.presentation.components.editNote.TitleField +import com.pasich.encly.presentation.designsystem.EnclyScrollbar +import com.pasich.encly.presentation.designsystem.EnclySnackbarHost +import com.pasich.encly.presentation.designsystem.NoteSkeleton +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.persistence.SaveStatusNote +import com.pasich.encly.presentation.effects.ContainerEnd +import com.pasich.encly.presentation.effects.rememberNoteContainerModifier +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.navigation.RelockReturn +import com.pasich.encly.presentation.pane.EditorPane +import com.pasich.encly.presentation.screen.backup.MarkdownExportHost +import com.pasich.encly.presentation.viewmodel.DocumentFormat +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.MarkdownExportTarget +import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel +import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.ui.theme.noteColorStyle +import com.pasich.encly.utils.currentLanguageTag +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource + +/** Items above the blocks: the overline and the title. */ +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, + pane: EditorPane? = null, + viewModel: EditNoteViewModel = metroViewModel(), +) { + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + // A new language rebuilds the exits (their texts), as a new configuration did. + val language = currentLanguageTag() + val lifecycleOwner = LocalLifecycleOwner.current + val snackbarHostState = remember { SnackbarHostState() } + val entry = lifecycleOwner as? NavBackStackEntry + // The route's argument, not the ViewModel's id: a new note gets its id on the first save, and + // only an existing, editable note has a card to grow from. + // An editor in the detail pane sits beside its card, so there is nothing to grow from either. + val plain = pane != null || viewModel.isReadTrashOnly || + entry?.savedStateHandle?.contains(RelockReturn.PLAIN_ENTRY) == true + val cardNoteId = if (plain) -1L else entry?.arguments?.read { getLongOrNull("idNote") } ?: -1L + val container = rememberNoteContainerModifier(cardNoteId, ContainerEnd.EDITOR) + val exits = remember(viewModel, navController, language, pane) { + EditorExits( + viewModel = viewModel, + where = if (pane != null) PaneEditorPlace(pane) else ScreenEditorPlace(navController, entry), + scope = scope, + onFailure = { message -> scope.launch { snackbarHostState.showSnackbar(getString(message)) } }, + beforeLeaving = { + keyboardController?.hide() + focusManager.clearFocus() + }, + ) + } + 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 editedElsewhere by viewModel.editedElsewhere.collectAsState() + val overlays = rememberEditorOverlays() + val listState = rememberLazyListState() + val focusRegistry = rememberBlockFocusRegistry(viewModel.blocks) + val findState = viewModel.find.collectAsState() + val highlights = remember(findState) { FindHighlights(findState) } + val controller = rememberEditorController() + val host = remember(controller, focusRegistry, highlights) { + EditorBlocksHost(controller, focusRegistry, highlights) { type, block -> + overlays.blockSheet.open(type, block.id) + } + } + val titleFocus = remember { FocusRequester() } + + SaveWhenPaused() + if (pane != null) PaneTabBinding(pane, exits) + val documents = rememberEditorDocumentExport(exits) + EditorShortcutCommands(overlays, documents) + ClaimNoteForEditing() + val blocksStart = HEADER_ITEMS + (if (contentLoadFailed) 1 else 0) + (if (editedElsewhere) 1 else 0) + CarryOutFocusRequests(focusRegistry, listState, blocksStart = blocksStart) + ScrollToFindMatch(listState, highlights, blocksStart) + FocusNewNoteTitle(titleFocus) + BackHandler { exits.back() } + CloseFindOrSlashOnBack() + LeaveBlockSelectionOnBack() + EditNoteSettingsProvider( + baseFontSize = fontSize.sp, + fontStyle = fontStyle, + simpleEdit = simpleEdit, + lineSpacing = lineSpacing, + ) { + // Inside the provider: the block sheets are the editor's, and read its strings and theme. + EditorOverlays( + overlays = overlays, + exits = exits, + documents = documents, + onManageTags = { navController.navigate(NavRoutes.EditTagRoute.name) }, + onMessage = { text -> scope.launch { snackbarHostState.showSnackbar(text) } }, + ) + EditorScaffold( + actions = EditorBarActions( + onBack = exits::back, + onRestore = exits::restore, + onDelete = { overlays.isDeleteDialogVisible = true }, + onTags = { overlays.isTagSheetVisible = true }, + onMenu = { overlays.isEditMenuVisible = true }, + onLockToggle = viewModel::toggleLockEditor, + ), + listState = listState, + snackbarHostState = snackbarHostState, + onMessage = { text -> scope.launch { snackbarHostState.showSnackbar(text) } }, + modifier = modifier.then(container), + ) { padding -> + EditorContent( + host = host, + listState = listState, + titleFocus = titleFocus, + modifier = Modifier.padding(padding), + ) + } + } +} + +/** + * The editor's frame: the pinned bar, and the formatting toolbar, always there while the note + * can be edited, keyboard or not (a hardware keyboard never shows one); it lifts itself above + * the keyboard. + */ +@Composable +private fun EditorScaffold( + actions: EditorBarActions, + listState: LazyListState, + snackbarHostState: SnackbarHostState, + onMessage: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: EditNoteViewModel = metroViewModel(), + content: @Composable (PaddingValues) -> Unit, +) { + val lockEditor by viewModel.lockEditor.collectAsState() + val picking by viewModel.selectedBlocks.ids.collectAsState() + val textRange by viewModel.textRange.ends.collectAsState() + val crossBlocks = textRange != null && viewModel.textRange.isCrossField + val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() + val findOpen by rememberFindOpen() + val pageColor = rememberPageColor() + Scaffold( + modifier = modifier.onPreviewKeyEvent { event -> + viewModel.handleHistoryKey(event) + }, + containerColor = pageColor, + // Status and navigation bars, a display cutout and the keyboard all stay clear of text. + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { + Column { + EditorTopBar( + state = EditorBarState( + isReadTrashOnly = viewModel.isReadTrashOnly, + lockEditor = lockEditor, + canToggleLock = !contentLoadFailed, + containerColor = pageColor, + ), + listState = listState, + actions = actions, + ) + if (findOpen) EditorFindBar() + } + }, + bottomBar = { + when { + // Picked blocks have their own bar, also where the note cannot be changed (they can be copied). + picking.isNotEmpty() -> EditorSelectionBar(onMessage) + + // A selection that runs across blocks has one too; the fields keep the keyboard, so it sits above it. + crossBlocks -> EditorTextRangeBar(onMessage, Modifier.imePadding()) + + !lockEditor && !viewModel.isReadTrashOnly -> NoteBottomBar(simpleEdit = LocalSimpleEdit.current) + } + }, + snackbarHost = { EnclySnackbarHost(snackbarHostState) }, + content = content, + ) +} + +/** + * The editor's page colour: the theme's surface, tinted by the note's colour. Read through derived + * state, so a title keystroke does not recompose the editor; a colour change fades in. + */ +@Composable +private fun rememberPageColor(viewModel: EditNoteViewModel = metroViewModel()): Color { + val noteState = viewModel.state.collectAsState() + val colorKey by remember(noteState) { derivedStateOf { noteState.value.note.color } } + val scheme = MaterialTheme.colorScheme + val target = remember(colorKey, scheme) { + noteColorStyle(NoteColor.fromKey(colorKey), scheme)?.page ?: scheme.surface + } + return animateColorAsState(target, label = "pageColor").value +} + +/** Which of the editor's sheets and dialogs are open. */ +internal class EditorOverlaysState( + val blockSheet: BlockSheetState, + editMenu: MutableState, + tagSheet: MutableState, +) { + var isEditMenuVisible by editMenu + var isTagSheetVisible by tagSheet + var isDeleteDialogVisible by mutableStateOf(false) + var isDiscardDialogVisible by mutableStateOf(false) + var isTrashDialogVisible by mutableStateOf(false) +} + +@Composable +private fun rememberEditorOverlays(): EditorOverlaysState { + val blockSheet = rememberBlockSheetState() + val editMenu = rememberSaveable { mutableStateOf(false) } + val tagSheet = rememberSaveable { mutableStateOf(false) } + return remember { EditorOverlaysState(blockSheet, editMenu, tagSheet) } +} + +/** The editor's sheets and dialogs: block sheets, tags, More actions, delete and discard. */ +@Suppress("LongMethod") // One sheet, its dialogs and the actions of its rows, wired in one place. +@Composable +private fun EditorOverlays( + overlays: EditorOverlaysState, + exits: EditorExits, + documents: EditorDocumentExport, + onManageTags: () -> Unit, + onMessage: (String) -> Unit, + viewModel: EditNoteViewModel = metroViewModel(), + markdownViewModel: MarkdownExportViewModel = metroViewModel(), +) { + val clipboard = LocalSensitiveClipboard.current + val copyLabel = stringResource(Res.string.markdown_copy) + val copyConfirmation = stringResource(Res.string.markdown_copied) + val lockEditor by viewModel.lockEditor.collectAsState() + EditorConfirmDialogs( + visible = ConfirmationsVisible( + delete = overlays.isDeleteDialogVisible, + discard = overlays.isDiscardDialogVisible, + trash = overlays.isTrashDialogVisible, + ), + onDelete = exits::delete, + onDiscard = exits::discard, + onTrash = exits::trash, + onDismiss = { + overlays.isDeleteDialogVisible = false + overlays.isDiscardDialogVisible = false + overlays.isTrashDialogVisible = false + }, + ) + EditorBlockSheets(overlays.blockSheet, canEdit = !lockEditor && !viewModel.isReadTrashOnly) + if (overlays.isTagSheetVisible) { + EditorTagSheet( + onManageTags = { + overlays.isTagSheetVisible = false + onManageTags() + }, + onDismiss = { overlays.isTagSheetVisible = false }, + ) + } + MarkdownExportHost(onMessage) + EditNoteBottomSheet( + isVisible = overlays.isEditMenuVisible, + onDismiss = { overlays.isEditMenuVisible = false }, + documents = documents.available, + onAction = { action -> + when (action) { + EditNoteBottomSheetAction.CLOSE_NO_SAVE -> exits.discardOrConfirm { + overlays.isDiscardDialogVisible = + true + } + + EditNoteBottomSheetAction.DUPLICATE -> exits.duplicate() + + EditNoteBottomSheetAction.FIND -> viewModel.openFind() + + EditNoteBottomSheetAction.SELECT_ALL -> viewModel.selectAllBlocks() + + EditNoteBottomSheetAction.TRASH -> exits.trash() + + EditNoteBottomSheetAction.EXPORT_MARKDOWN -> exits.exportMarkdown { id -> + markdownViewModel.start(MarkdownExportTarget.SingleNote(id)) + } + + EditNoteBottomSheetAction.EXPORT_PDF -> documents.start(DocumentFormat.PDF) + + EditNoteBottomSheetAction.PRINT -> documents.start(DocumentFormat.PRINT) + + EditNoteBottomSheetAction.COPY_MARKDOWN -> copyNoteAsMarkdown( + clipboard, + viewModel, + copyLabel, + copyConfirmation, + )?.let(onMessage) + + EditNoteBottomSheetAction.ARCHIVE -> exits.archive(archived = true) + + EditNoteBottomSheetAction.UNARCHIVE -> exits.archive(archived = false) + } + }, + ) +} + +/** The editor's list and, on a desktop, its scrollbar. */ +@Composable +private fun EditorContent( + host: EditorBlocksHost, + listState: LazyListState, + titleFocus: FocusRequester, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + EditorBlocks(host, listState, titleFocus) + TextRangeOverlay(host, listState) + EnclyScrollbar(listState) + } +} + +/** The editor's list: overline, title, a notice when the content is unreadable, then the blocks. */ +@Composable +private fun EditorBlocks( + host: EditorBlocksHost, + listState: LazyListState, + titleFocus: FocusRequester, + modifier: Modifier = Modifier, + viewModel: EditNoteViewModel = metroViewModel(), +) { + val spacing = LocalEditorTheme.current.lineSpacing + val controller = rememberEditorController() + val headerGap = if (spacing.isCompact) EnclyTheme.spacing.labelGap else 0.dp + val lockEditor by viewModel.lockEditor.collectAsState() + val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() + val editedElsewhere by viewModel.editedElsewhere.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 } } + + CompositionLocalProvider(LocalOverscrollFactory provides null) { + LazyColumn( + state = listState, + // Default block frames and this gap give 14 dp between blocks (spec §4.4). + verticalArrangement = Arrangement.spacedBy(spacing.blockGap(EnclyTheme.spacing.labelGap)), + contentPadding = PaddingValues(top = EnclyTheme.spacing.xxs), + modifier = modifier + .fillMaxSize() + // The empty page under a short note is part of the note: a tap there writes at its end, keyboard up. + .tapBelowBlocks(listState, enabled = !lockEditor, onTap = rememberWriteAtEnd(controller)), + ) { + if (loading) { + item(key = "skeleton") { + NoteSkeleton(modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter)) + } + return@LazyColumn + } + item(key = "overline") { + EditorOverline(modifier = Modifier.padding(bottom = headerGap)) + } + item(key = "title") { + EditorTitle( + readOnly = lockEditor, + modifier = Modifier + .focusRequester(titleFocus) + .padding(bottom = headerGap), + ) + } + editorNotices(contentLoadFailed, editedElsewhere, headerGap) + editorBlocks( + host, + viewModel.blocks, + isLocked = lockEditor, + editingLinkIds = editingLinkIds, + afterBlocks = if (templatesOffered) ({ TemplateChooser() }) else null, + ) + } + } +} + +/** The title field, bound to the note's title; "Next" moves on to the first block. */ +@Composable +private fun EditorTitle( + readOnly: Boolean, + modifier: Modifier = Modifier, + viewModel: EditNoteViewModel = metroViewModel(), +) { + TitleField( + title = { viewModel.state.value.note.title }, + titleChanges = viewModel.state, + onTitleChange = viewModel::updateTitle, + onNext = viewModel::focusFirstBlock, + readOnly = readOnly, + modifier = modifier, + ) +} + +/** The overline: the note's tags and when it was edited, and "Not saved" after a failed save. */ +@Composable +private fun EditorOverline( + modifier: Modifier = Modifier, + viewModel: EditNoteViewModel = metroViewModel(), + tagViewModel: TagListViewModel = metroViewModel(), +) { + // Read through derived state: a title keystroke or a save does not recompose the overline. + val noteState = viewModel.state.collectAsState() + val status = viewModel.status.collectAsState() + val meta by remember(noteState) { derivedStateOf { noteState.value.note.let { it.tagIds to it.date } } } + val notSaved by remember(status) { derivedStateOf { status.value == SaveStatusNote.FAILED } } + val tagState by tagViewModel.state.collectAsState() + val (tagIds, date) = meta + NoteOverline( + // In the tags' own order, the one of the tags screen. + tagNames = tagState.listTags.filter { it.id in tagIds }.map { it.nameTag }, + date = date, + notSaved = notSaved, + modifier = modifier.padding( + start = EnclyTheme.spacing.gutter, + end = EnclyTheme.spacing.gutter, + bottom = EnclyTheme.spacing.xs, + ), + ) +} + +/** Which of the editor's confirmations are open. */ +private class ConfirmationsVisible(val delete: Boolean, val discard: Boolean, val trash: Boolean) + +/** Delete (from the trash), discard, and moving to the trash from the keyboard all ask first. */ +@Composable +private fun EditorConfirmDialogs( + visible: ConfirmationsVisible, + onDelete: () -> Unit, + onDiscard: () -> Unit, + onTrash: () -> Unit, + onDismiss: () -> Unit, +) { + ConfirmDialog( + isVisible = visible.delete, + titleText = stringResource(Res.string.dialog_title), + messageText = stringResource(Res.string.dialog_message), + onConfirm = { + onDismiss() + onDelete() + }, + onDismiss = onDismiss, + destructive = true, + ) + ConfirmDialog( + isVisible = visible.discard, + titleText = stringResource(Res.string.note_discard_confirm_title), + messageText = stringResource(Res.string.note_discard_confirm_message), + onConfirm = { + onDismiss() + onDiscard() + }, + onDismiss = onDismiss, + destructive = true, + ) + ConfirmDialog( + isVisible = visible.trash, + titleText = stringResource(Res.string.swipe_delete_title), + messageText = stringResource(Res.string.swipe_delete_message), + onConfirm = { + onDismiss() + onTrash() + }, + onDismiss = onDismiss, + destructive = true, + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteViewModelController.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteViewModelController.kt new file mode 100644 index 00000000..c4db948b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteViewModelController.kt @@ -0,0 +1,138 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.markdown_copy +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.SelectedBlocks +import com.pasich.encly.editor.state.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.SlashPicker +import com.pasich.encly.editor.state.TextRangeSelection +import com.pasich.encly.editor.ui.EditorController +import com.pasich.encly.platform.LocalSensitiveClipboard +import com.pasich.encly.platform.SensitiveClipboard +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.flow.StateFlow +import org.jetbrains.compose.resources.stringResource + +/** + * The editor's view of the note's view model: every call goes to the view model's own method, so + * its rules (a locked, read-only or simple-edit note changes nothing) apply unchanged. + */ +@Suppress("TooManyFunctions") // One call each for what the editor's blocks can ask. +internal class EditNoteViewModelController( + private val viewModel: EditNoteViewModel, + private val clipboard: SensitiveClipboard, + private val clipLabel: String, +) : EditorController { + override val blocks: List get() = viewModel.blocks + + override val slashPicker: SlashPicker get() = viewModel.slashPicker + + override val selectedBlocks: SelectedBlocks get() = viewModel.selectedBlocks + + override val interactedBlockId: StateFlow get() = viewModel.interactedBlockId + + override val textRange: TextRangeSelection get() = viewModel.textRange + + override fun selectAllText(): Boolean = viewModel.selectAllText() + + override fun replaceTextRange(replacement: String): Boolean = viewModel.replaceTextRange(replacement) + + override fun copyTextRange(markdown: Boolean): Boolean = + copyBlocks(clipboard, viewModel, viewModel.textRangeBlocks(), markdown, clipLabel) + + override fun cutTextRange(): Boolean = + viewModel.canEditBlocks && viewModel.canReplaceTextRange() && copyTextRange(markdown = true) && + viewModel.replaceTextRange("") + + override fun indexOfBlock(block: Block): Int = viewModel.indexOfBlock(block) + + override fun onBlockFocused(block: Block) = viewModel.onBlockFocused(block) + + override fun onBlockFocusLost(block: Block) = viewModel.onBlockFocusLost(block) + + override fun onBlockInteraction(block: Block) = viewModel.onBlockInteraction(block) + + override fun addBlockAfter(afterIndex: Int, blockType: BlockType) = viewModel.addBlockAfter(afterIndex, blockType) + + override fun addBlockToEnd() = viewModel.addBlockToEnd() + + override fun removeBlock(block: Block, action: BlockRemoveAction) = viewModel.removeBlock(block, action) + + override fun replaceBlock(index: Int, newBlock: Block): Boolean = viewModel.replaceBlock(index, newBlock) + + override fun moveBlock(up: Boolean) = viewModel.moveBlock(up) + + override fun moveBlockOf(block: Block, up: Boolean) = viewModel.moveBlockOf(block, up) + + override fun interactedBlockPosition(): Int = viewModel.getMoveBlockState() + + override fun duplicateBlock(block: Block) = viewModel.duplicateBlock(block) + + override fun toggleChecklistLock(block: Block) = viewModel.toggleChecklistLock(block) + + override fun editLink(block: Block.LinkBlock) = viewModel.editLink(block) + + override fun onBlockTextChanged(block: Block, newText: String) = viewModel.onBlockTextChanged(block, newText) + + override fun onListItemsChanged(block: Block.ListBlock, newItems: List, mergeable: Boolean) = + viewModel.onListItemsChanged(block, newItems, mergeable) + + override fun onLinkChanged(block: Block.LinkBlock, newLink: LinkDataBlock) = viewModel.onLinkChanged(block, newLink) + + override fun onLineBreak(block: Block, lineBreak: LineBreak): String? = viewModel.onLineBreak(block, lineBreak) + + override fun onPasteCopied(block: Block, itemId: String?, head: String, pasted: String, tail: String): String? = + viewModel.onPasteCopied(block, itemId, head, pasted, tail) + + override fun onBackspaceAtStart(block: Block): Boolean = viewModel.onBackspaceAtStart(block) + + override fun onShortcut(block: Block, typed: String, shortcut: Shortcut): Boolean = + viewModel.onShortcut(block, typed, shortcut) + + override fun onLinkPasted(block: Block, pasted: String, link: LinkDataBlock): Boolean = + viewModel.onLinkPasted(block, pasted, link) + + override fun onListLineBreak(block: Block.ListBlock, itemId: String, lineBreak: LineBreak): String? = + viewModel.onListLineBreak(block, itemId, lineBreak) + + override fun onListBackspaceAtStart(block: Block.ListBlock, itemId: String): Boolean = + viewModel.onListBackspaceAtStart(block, itemId) + + override fun onCaretMoved(block: Block, offset: Int) = viewModel.onCaretMoved(block, offset) + + override fun onListItemCaret(block: Block, itemId: String, offset: Int) = + viewModel.onListItemCaret(block, itemId, offset) + + override fun onSlashTyped(block: Block, text: String, started: Boolean) = + viewModel.onSlashTyped(block, text, started) + + override fun applySlash(block: Block, command: SlashCommand) = viewModel.applySlash(block, command) + + override fun onListItemShift(block: Block.ListBlock, itemId: String, deeper: Boolean): Boolean = + viewModel.shiftListItem(block, itemId, deeper) + + override fun onListItemMove(block: Block.ListBlock, itemId: String, by: Int): Boolean = + viewModel.moveListItem(block, itemId, by) + override fun onTableChanged(block: Block.TableBlock, newTable: TableData, mergeable: Boolean) = + viewModel.onTableChanged(block, newTable, mergeable) +} + +/** The editor's view of the screen's note view model. */ +@Composable +internal fun rememberEditorController(): EditorController { + val viewModel: EditNoteViewModel = metroViewModel() + val clipboard = LocalSensitiveClipboard.current + val label = stringResource(Res.string.markdown_copy) + return remember(viewModel, clipboard, label) { EditNoteViewModelController(viewModel, clipboard, label) } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorBindings.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorBindings.kt new file mode 100644 index 00000000..30048896 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorBindings.kt @@ -0,0 +1,315 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.block_bulleted_list +import com.pasich.encly.core.resources.block_checklist +import com.pasich.encly.core.resources.block_code +import com.pasich.encly.core.resources.block_duplicate +import com.pasich.encly.core.resources.block_heading +import com.pasich.encly.core.resources.block_heading_level +import com.pasich.encly.core.resources.block_link +import com.pasich.encly.core.resources.block_list +import com.pasich.encly.core.resources.block_move_down +import com.pasich.encly.core.resources.block_move_up +import com.pasich.encly.core.resources.block_numbered_list +import com.pasich.encly.core.resources.block_select +import com.pasich.encly.core.resources.block_separator +import com.pasich.encly.core.resources.block_table +import com.pasich.encly.core.resources.checklist_lock +import com.pasich.encly.core.resources.checklist_unlock +import com.pasich.encly.core.resources.delete_block +import com.pasich.encly.core.resources.enter_url +import com.pasich.encly.core.resources.falied_content +import com.pasich.encly.core.resources.link_add +import com.pasich.encly.core.resources.link_blocked +import com.pasich.encly.core.resources.link_copy +import com.pasich.encly.core.resources.link_edit +import com.pasich.encly.core.resources.link_open +import com.pasich.encly.core.resources.list_item_indent +import com.pasich.encly.core.resources.list_item_move_down +import com.pasich.encly.core.resources.list_item_move_up +import com.pasich.encly.core.resources.list_item_outdent +import com.pasich.encly.core.resources.list_item_placeholder +import com.pasich.encly.core.resources.list_item_reorder +import com.pasich.encly.core.resources.markdown_copy +import com.pasich.encly.core.resources.more_options +import com.pasich.encly.core.resources.press_to_edit +import com.pasich.encly.core.resources.quote +import com.pasich.encly.core.resources.selection_close +import com.pasich.encly.core.resources.selection_copy +import com.pasich.encly.core.resources.selection_count +import com.pasich.encly.core.resources.selection_cut +import com.pasich.encly.core.resources.selection_delete +import com.pasich.encly.core.resources.slash_menu_title +import com.pasich.encly.core.resources.table_add_column +import com.pasich.encly.core.resources.table_add_row +import com.pasich.encly.core.resources.table_cell_options +import com.pasich.encly.core.resources.table_header_row +import com.pasich.encly.core.resources.table_remove_column +import com.pasich.encly.core.resources.table_remove_row +import com.pasich.encly.core.resources.tap_again_to_delete +import com.pasich.encly.core.resources.template_action_items +import com.pasich.encly.core.resources.template_agenda +import com.pasich.encly.core.resources.template_attendees +import com.pasich.encly.core.resources.template_blank +import com.pasich.encly.core.resources.template_checklist_todo +import com.pasich.encly.core.resources.template_journal +import com.pasich.encly.core.resources.template_journal_gratitude +import com.pasich.encly.core.resources.template_journal_today +import com.pasich.encly.core.resources.template_journal_tomorrow +import com.pasich.encly.core.resources.template_meeting +import com.pasich.encly.core.resources.template_notes +import com.pasich.encly.core.resources.template_project +import com.pasich.encly.core.resources.template_project_decisions +import com.pasich.encly.core.resources.template_project_goal +import com.pasich.encly.core.resources.template_project_questions +import com.pasich.encly.core.resources.template_project_tasks +import com.pasich.encly.core.resources.text +import com.pasich.encly.domain.model.NoteLineSpacing +import com.pasich.encly.editor.EditorClipboard +import com.pasich.encly.editor.EditorLogger +import com.pasich.encly.editor.EditorProvider +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.EditorStrings +import com.pasich.encly.editor.LinkOpener +import com.pasich.encly.editor.theme.EditorFontFamilies +import com.pasich.encly.editor.theme.EditorFontSizes +import com.pasich.encly.editor.theme.EditorIcons +import com.pasich.encly.editor.theme.EditorLineSpacing +import com.pasich.encly.editor.theme.EditorSpacing +import com.pasich.encly.editor.theme.EditorTheme +import com.pasich.encly.platform.LocalExternalLinks +import com.pasich.encly.platform.LocalSensitiveClipboard +import com.pasich.encly.platform.LocalToaster +import com.pasich.encly.platform.SensitiveClipboard +import com.pasich.encly.platform.Toaster +import com.pasich.encly.presentation.designsystem.Code +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.Table +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +// What the app binds to the editor's extension points (editor/EditorServices.kt, EditorStrings.kt, +// theme/EditorTheme.kt): its string resources, its design system, its clipboard and link policies +// and its logger. The editor itself knows none of them. + +/** + * Provides the editor with the app's strings, theme (built from [fontStyles] and [lineSpacing]), + * clipboard, link opener and logger above [content]. + */ +@Composable +internal fun AppEditorProvider(fontStyles: FontStyles, lineSpacing: NoteLineSpacing, content: @Composable () -> Unit) { + val clipboard = LocalSensitiveClipboard.current + val links = LocalExternalLinks.current + val toaster = LocalToaster.current + EditorProvider( + strings = rememberEditorStrings(), + theme = rememberEditorTheme(fontStyles, lineSpacing), + clipboard = remember(clipboard, toaster) { AppEditorClipboard(clipboard, toaster) }, + linkOpener = remember(links, toaster) { LinkOpener { url -> openNoteLink(links, toaster, url) } }, + logger = AppEditorLogger, + content = content, + ) +} + +/** The editor's texts from the app's string resources, resolved in the language they have now. */ +internal class ResourceEditorStrings(private val texts: Map) : EditorStrings { + override fun text(key: EditorString): String = texts.getValue(key) + + override fun heading(level: Int): String = + texts.getValue(EditorString.BLOCK_HEADING_LEVEL).replace(HEADING_LEVEL_ARGUMENT, level.toString()) + + override fun selectionCount(count: Int): String = + texts.getValue(EditorString.SELECTION_COUNT).replace(HEADING_LEVEL_ARGUMENT, count.toString()) + + private companion object { + /** The placeholder of the number in `block_heading_level` and `selection_count`. */ + const val HEADING_LEVEL_ARGUMENT = "%1\$d" + } +} + +/** + * The strings in the current language. The editor asks for text outside composition, so every + * string it knows is read here; the strings follow the locale, so the instance changes with it. + */ +@Composable +internal fun rememberEditorStrings(): EditorStrings { + val texts = EditorString.entries.associateWith { stringResource(it.resource()) } + return remember(texts) { ResourceEditorStrings(texts) } +} + +/** The string resource of [this] key. */ +@Suppress("CyclomaticComplexMethod", "LongMethod") // One branch per string the editor shows. +internal fun EditorString.resource(): StringResource = when (this) { + EditorString.TEXT -> Res.string.text + EditorString.PRESS_TO_EDIT -> Res.string.press_to_edit + EditorString.BLOCK_HEADING -> Res.string.block_heading + EditorString.BLOCK_HEADING_LEVEL -> Res.string.block_heading_level + EditorString.BLOCK_CHECKLIST -> Res.string.block_checklist + EditorString.BLOCK_BULLETED_LIST -> Res.string.block_bulleted_list + EditorString.BLOCK_NUMBERED_LIST -> Res.string.block_numbered_list + EditorString.BLOCK_LIST -> Res.string.block_list + EditorString.QUOTE -> Res.string.quote + EditorString.BLOCK_LINK -> Res.string.block_link + EditorString.BLOCK_SEPARATOR -> Res.string.block_separator + EditorString.MORE_OPTIONS -> Res.string.more_options + EditorString.LIST_ITEM_PLACEHOLDER -> Res.string.list_item_placeholder + EditorString.SLASH_MENU_TITLE -> Res.string.slash_menu_title + EditorString.ENTER_URL -> Res.string.enter_url + EditorString.LINK_BLOCKED -> Res.string.link_blocked + EditorString.LINK_CONTENT_FAILED -> Res.string.falied_content + EditorString.LINK_ADD -> Res.string.link_add + EditorString.LINK_OPEN -> Res.string.link_open + EditorString.LINK_COPY -> Res.string.link_copy + EditorString.LINK_EDIT -> Res.string.link_edit + EditorString.BLOCK_MOVE_UP -> Res.string.block_move_up + EditorString.BLOCK_MOVE_DOWN -> Res.string.block_move_down + EditorString.BLOCK_DUPLICATE -> Res.string.block_duplicate + EditorString.BLOCK_DELETE -> Res.string.delete_block + EditorString.TAP_AGAIN_TO_DELETE -> Res.string.tap_again_to_delete + EditorString.CHECKLIST_LOCK -> Res.string.checklist_lock + EditorString.CHECKLIST_UNLOCK -> Res.string.checklist_unlock + EditorString.BLOCK_SELECT -> Res.string.block_select + EditorString.SELECTION_COUNT -> Res.string.selection_count + EditorString.SELECTION_CLOSE -> Res.string.selection_close + EditorString.SELECTION_COPY -> Res.string.selection_copy + EditorString.SELECTION_COPY_MARKDOWN -> Res.string.markdown_copy + EditorString.SELECTION_CUT -> Res.string.selection_cut + EditorString.SELECTION_DELETE -> Res.string.selection_delete + EditorString.TEMPLATE_BLANK -> Res.string.template_blank + EditorString.TEMPLATE_MEETING -> Res.string.template_meeting + EditorString.TEMPLATE_ATTENDEES -> Res.string.template_attendees + EditorString.TEMPLATE_AGENDA -> Res.string.template_agenda + EditorString.TEMPLATE_NOTES -> Res.string.template_notes + EditorString.TEMPLATE_ACTION_ITEMS -> Res.string.template_action_items + EditorString.TEMPLATE_JOURNAL -> Res.string.template_journal + EditorString.TEMPLATE_JOURNAL_TODAY -> Res.string.template_journal_today + EditorString.TEMPLATE_JOURNAL_GRATITUDE -> Res.string.template_journal_gratitude + EditorString.TEMPLATE_JOURNAL_TOMORROW -> Res.string.template_journal_tomorrow + EditorString.TEMPLATE_PROJECT -> Res.string.template_project + EditorString.TEMPLATE_PROJECT_GOAL -> Res.string.template_project_goal + EditorString.TEMPLATE_PROJECT_TASKS -> Res.string.template_project_tasks + EditorString.TEMPLATE_PROJECT_DECISIONS -> Res.string.template_project_decisions + EditorString.TEMPLATE_PROJECT_QUESTIONS -> Res.string.template_project_questions + EditorString.TEMPLATE_CHECKLIST_TODO -> Res.string.template_checklist_todo + EditorString.LIST_ITEM_REORDER -> Res.string.list_item_reorder + EditorString.LIST_ITEM_MOVE_UP -> Res.string.list_item_move_up + EditorString.LIST_ITEM_MOVE_DOWN -> Res.string.list_item_move_down + EditorString.LIST_ITEM_INDENT -> Res.string.list_item_indent + EditorString.LIST_ITEM_OUTDENT -> Res.string.list_item_outdent + EditorString.BLOCK_CODE -> Res.string.block_code + EditorString.BLOCK_TABLE -> Res.string.block_table + EditorString.TABLE_ADD_ROW -> Res.string.table_add_row + EditorString.TABLE_ADD_COLUMN -> Res.string.table_add_column + EditorString.TABLE_REMOVE_ROW -> Res.string.table_remove_row + EditorString.TABLE_REMOVE_COLUMN -> Res.string.table_remove_column + EditorString.TABLE_HEADER_ROW -> Res.string.table_header_row + EditorString.TABLE_CELL_OPTIONS -> Res.string.table_cell_options +} + +/** + * The app's design system as the editor's theme: spacing from [EnclyTheme.spacing], the quote and + * figure styles from its type scale, the fonts and sizes of [fontStyles], the icons of + * [EnclyIcons]. + */ +@Composable +internal fun rememberEditorTheme(fontStyles: FontStyles, lineSpacing: NoteLineSpacing): EditorTheme { + val spacing = EnclyTheme.spacing + val quote = EnclyTheme.typography.quote + val data = EnclyTheme.typography.dataSmall + return remember(fontStyles, lineSpacing, spacing, quote, data) { + EditorTheme( + spacing = EditorSpacing( + xxs = spacing.xxs, + xs = spacing.xs, + s = spacing.s, + m = spacing.m, + l = spacing.l, + textGap = spacing.textGap, + labelGap = spacing.labelGap, + rowGap = spacing.rowGap, + fieldGap = spacing.fieldGap, + hairline = spacing.hairline, + stroke = spacing.stroke, + minTouchTarget = spacing.minTouchTarget, + buttonHeight = spacing.buttonHeight, + rowHeight = spacing.rowHeight, + icon = spacing.icon, + iconMedium = spacing.iconMedium, + iconSmall = spacing.iconSmall, + tileSmall = spacing.tileSmall, + gutter = spacing.gutter, + tapArea = spacing.editorTapArea, + ), + fontSizes = EditorFontSizes( + textBlock = fontStyles.sizes.textBlock, + h1 = fontStyles.sizes.h1, + h2 = fontStyles.sizes.h2, + h3 = fontStyles.sizes.h3, + h4 = fontStyles.sizes.h4, + quote = fontStyles.sizes.quote, + list = fontStyles.sizes.list, + ), + fontFamilies = EditorFontFamilies(heading = fontStyles.families.heading, body = fontStyles.families.body), + lineSpacing = lineSpacing.toEditor(), + quoteStyle = quote, + dataStyle = data, + icons = AppEditorIcons, + ) + } +} + +/** The app's reading density as the editor's. */ +internal fun NoteLineSpacing.toEditor(): EditorLineSpacing = when (this) { + NoteLineSpacing.COMPACT -> EditorLineSpacing.COMPACT + NoteLineSpacing.DEFAULT -> EditorLineSpacing.DEFAULT + NoteLineSpacing.COMFORTABLE -> EditorLineSpacing.COMFORTABLE +} + +private val AppEditorIcons by lazy { + EditorIcons( + text = EnclyIcons.Type, + heading1 = EnclyIcons.Heading1, + heading2 = EnclyIcons.Heading2, + heading3 = EnclyIcons.Heading3, + heading4 = EnclyIcons.Heading4, + checklist = EnclyIcons.Checklist, + bulletList = EnclyIcons.ListBullet, + numberedList = EnclyIcons.ListNumbered, + quote = EnclyIcons.Quote, + link = EnclyIcons.Link, + separator = EnclyIcons.Separator, + globe = EnclyIcons.Globe, + plus = EnclyIcons.Plus, + check = EnclyIcons.Check, + external = EnclyIcons.External, + copy = EnclyIcons.Copy, + edit = EnclyIcons.Edit, + arrowUp = EnclyIcons.ArrowUp, + arrowDown = EnclyIcons.ArrowDown, + duplicate = EnclyIcons.Duplicate, + trash = EnclyIcons.Trash, + close = EnclyIcons.Close, + lock = EnclyIcons.Lock, + lockOpen = EnclyIcons.LockOpen, + grip = EnclyIcons.Grip, + code = EnclyIcons.Code, + table = EnclyIcons.Table, + ) +} + +/** + * The editor copies through the app's clipboard policy ([com.pasich.encly.platform.SensitiveClipboard]): + * every clip is marked sensitive and cleared after a minute, so [sensitive] changes nothing here. + */ +internal class AppEditorClipboard(private val clipboard: SensitiveClipboard, private val toaster: Toaster) : + EditorClipboard { + override fun copy(label: String, text: String, sensitive: Boolean) = copyNoteLink(clipboard, toaster, label, text) +} + +/** Failure events go to the app's logger, which writes nothing in any build. */ +internal val AppEditorLogger = EditorLogger { tag, event -> AppLogger.w(tag, event) } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorDocumentExport.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorDocumentExport.kt new file mode 100644 index 00000000..dabc7d71 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorDocumentExport.kt @@ -0,0 +1,42 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.pasich.encly.platform.LocalNoteDocumentRenderer +import com.pasich.encly.platform.NoteDocumentRenderer +import com.pasich.encly.presentation.viewmodel.DocumentFormat +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.MarkdownExportTarget +import com.pasich.encly.presentation.viewmodel.MarkdownExportViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel + +/** + * "Export as PDF" and "Print" of the note in this editor, for hosts that have a [NoteDocumentRenderer] (the + * desktop). They run the plaintext export's flow (warning, PIN, then the save dialog or the printer's), on the + * note as it is stored: the editor saves first, exactly as the Markdown export does. + */ +internal class EditorDocumentExport( + private val exits: EditorExits, + private val viewModel: EditNoteViewModel, + private val flow: MarkdownExportViewModel, + private val renderer: NoteDocumentRenderer?, +) { + /** Whether this host can make PDFs and printouts at all. */ + val available: Boolean get() = renderer != null + + /** Starts [format]; false when there is nothing to export (no renderer, a blank or an unreadable note). */ + fun start(format: DocumentFormat): Boolean { + val draw = renderer + if (draw == null || viewModel.isBlankDraft || viewModel.contentLoadFailed.value) return false + exits.exportMarkdown { id -> flow.start(MarkdownExportTarget.NoteAsDocument(id, format, draw)) } + return true + } +} + +@Composable +internal fun rememberEditorDocumentExport(exits: EditorExits): EditorDocumentExport { + val viewModel: EditNoteViewModel = metroViewModel() + val flow: MarkdownExportViewModel = metroViewModel() + val renderer = LocalNoteDocumentRenderer.current + return remember(exits, viewModel, flow, renderer) { EditorDocumentExport(exits, viewModel, flow, renderer) } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt new file mode 100644 index 00000000..bf7e73d5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt @@ -0,0 +1,210 @@ +package com.pasich.encly.presentation.screen.editnote + +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 +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.pasich.encly.editor.focus.BlockFocusRegistry +import com.pasich.encly.editor.ui.FindHighlights +import com.pasich.encly.editor.ui.sheets.BlockSheetKind +import com.pasich.encly.presentation.pane.ActiveEditor +import com.pasich.encly.presentation.pane.EditorPane +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +private const val INITIAL_FOCUS_FRAMES = 5 + +/** + * Flushes the editor as soon as it leaves the foreground: ProcessLifecycleOwner re-locks and + * closes SQLCipher later in the background transition, so this closes the autosave window first. + */ +@Composable +internal fun SaveWhenPaused(viewModel: EditNoteViewModel = metroViewModel()) { + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE) viewModel.saveForBackground() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} + +/** + * The ViewModel says which block to focus; [registry], which holds the fields' FocusRequesters, + * does it once that block is composed, scrolling a block off screen into view first + * ([blocksStart]: the list items above the blocks). A newer request replaces an older one. + */ +@Composable +internal fun CarryOutFocusRequests( + registry: BlockFocusRegistry, + listState: LazyListState, + blocksStart: Int, + viewModel: EditNoteViewModel = metroViewModel(), +) { + val currentBlocksStart by rememberUpdatedState(blocksStart) + DisposableEffect(viewModel, registry) { + viewModel.immediateFocus = { request -> registry.focus(request) } + onDispose { viewModel.immediateFocus = null } + } + LaunchedEffect(viewModel, registry) { + viewModel.focusRequests.collectLatest { request -> + registry.focusWhenComposed(request) { blockId -> + val index = viewModel.blocks.indexOfFirst { it.id == blockId } + if (index >= 0) listState.reveal(currentBlocksStart + index) + } + } + } +} + +/** + * Scrolls item [index] into view. An item just below the screen (the paragraph Enter adds under + * the last line, with the keyboard up) is scrolled up from the bottom by a little, as typing + * would, instead of jumping it to the top; its field then keeps its cursor in view itself. + */ +private suspend fun LazyListState.reveal(index: Int) { + val info = layoutInfo + val last = info.visibleItemsInfo.lastOrNull() + if (last != null && index > last.index && index - last.index <= NEAR_ITEMS) { + val hidden = (last.offset + last.size - info.viewportEndOffset).coerceAtLeast(0) + animateScrollBy((hidden + info.viewportSize.height / REVEAL_FRACTION).toFloat()) + } else if (info.visibleItemsInfo.none { it.index == index }) { + animateScrollToItem(index) + } +} + +/** How far past the last item on screen an item counts as just below it. */ +private const val NEAR_ITEMS = 2 + +/** An item just below the screen is revealed by this fraction of the screen's height. */ +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 = metroViewModel()) { + val keyboardController = LocalSoftwareKeyboardController.current + var done by rememberSaveable { mutableStateOf(!viewModel.isNewNote) } + LaunchedEffect(done) { + if (done) return@LaunchedEffect + repeat(INITIAL_FOCUS_FRAMES) { + withFrameNanos { } + if (runCatching { titleFocus.requestFocus() }.isSuccess) { + keyboardController?.show() + done = true + return@LaunchedEffect + } + } + done = true + } +} + +/** Which block sheet is open, and for which block (by id). */ +internal class BlockSheetState(kind: MutableState, blockId: MutableState) { + var kind by kind + var blockId by blockId + + fun open(sheet: BlockSheetKind, id: String) { + kind = sheet + blockId = id + } + + fun close() { + kind = null + blockId = null + } +} + +@Composable +internal fun rememberBlockSheetState(): BlockSheetState { + val kind = rememberSaveable { mutableStateOf(null) } + val blockId = rememberSaveable { mutableStateOf(null) } + return remember { BlockSheetState(kind, blockId) } +} + +/** Whether the find bar is open. */ +@Composable +internal fun rememberFindOpen(viewModel: EditNoteViewModel = metroViewModel()): State { + val open = remember(viewModel) { viewModel.find.map { it.isOpen }.distinctUntilChanged() } + return open.collectAsState(initial = false) +} + +/** + * Scrolls the list to the match Find is on: to its block when that block is off screen, then to the + * match itself within the block (a long paragraph or list is taller than the screen). Nothing gets + * focus and nothing is edited. + */ +@Composable +internal fun ScrollToFindMatch( + listState: LazyListState, + highlights: FindHighlights, + blocksStart: Int, + viewModel: EditNoteViewModel = metroViewModel(), +) { + 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 found = match ?: return@LaunchedEffect + val index = viewModel.blocks.indexOfFirst { it.id == found.blockId } + if (index >= 0) listState.reveal(currentBlocksStart + index) + highlights.reveal(found) + } +} + +/** Back closes the find bar, else an open slash picker, before it leaves the note. */ +@Composable +internal fun CloseFindOrSlashOnBack(viewModel: EditNoteViewModel = metroViewModel()) { + 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() + } +} + +/** + * Ties an editor in the detail pane to its tab: the tab shows the note's title and, once the note is + * stored, its id; and the tabs can save the editor before showing another note, or close it the way its + * Back does. + */ +@Composable +internal fun PaneTabBinding(pane: EditorPane, exits: EditorExits, viewModel: EditNoteViewModel = metroViewModel()) { + val noteState by viewModel.state.collectAsState() + val note = noteState.note + LaunchedEffect(pane, note.id, note.title) { pane.tabs.report(pane.tabKey, note.id, note.title) } + val currentExits by rememberUpdatedState(exits) + DisposableEffect(pane, viewModel) { + val editor = ActiveEditor( + save = { + val saved = viewModel.saveNote() + // The stored id (a new note gets one now) before the tabs decide whether this one is blank. + val stored = viewModel.state.value.note + if (saved) pane.tabs.report(pane.tabKey, stored.id, stored.title) + saved + }, + requestClose = { currentExits.back() }, + ) + pane.tabs.attach(editor) + onDispose { pane.tabs.detach(editor) } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorExits.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorExits.kt new file mode 100644 index 00000000..af4edfe9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorExits.kt @@ -0,0 +1,146 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.note_archive_failed +import com.pasich.encly.core.resources.note_delete_failed +import com.pasich.encly.core.resources.note_discard_failed +import com.pasich.encly.core.resources.note_duplicate_failed +import com.pasich.encly.core.resources.note_restore_failed +import com.pasich.encly.core.resources.note_save_failed +import com.pasich.encly.core.resources.note_trash_failed +import com.pasich.encly.core.resources.note_unarchive_failed +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.pane.EditorPane +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource + +/** + * Every way out of the editor: back (saves), restore, delete, discard, trash, archive and duplicate. Each + * writes first and leaves only once the write succeeded; a failure keeps the editor open and is + * reported through [onFailure]. The first exit wins: a second Back (gesture and arrow) or any + * other exit tapped meanwhile is ignored, so the editor never pops past the notes list. + */ +@Suppress("TooManyFunctions") // One function per way out. +internal class EditorExits( + private val viewModel: EditNoteViewModel, + private val where: EditorPlace, + private val scope: CoroutineScope, + private val onFailure: (StringResource) -> Unit, + private val beforeLeaving: () -> Unit, +) { + private var leaving = false + + fun back() = leave(Res.string.note_save_failed, viewModel::saveNote) + + fun restore() = leave(Res.string.note_restore_failed, viewModel::noteRestore) + + fun delete() = leave(Res.string.note_delete_failed, viewModel::noteDelete) + + fun trash() = leave(Res.string.note_trash_failed, viewModel::noteMoveToTrash) + + /** Moves the note to the archive (or, when it is archived, back to the list) and leaves. */ + fun archive(archived: Boolean) { + if (viewModel.isBlankDraft) return + leave( + failure = if (archived) Res.string.note_archive_failed else Res.string.note_unarchive_failed, + action = { viewModel.saveNote() && viewModel.noteSetArchived(archived) }, + ) + } + + /** + * Saves the note, then hands its id to [start], which begins the plaintext export + * (warning, PIN, save dialog). The editor stays open. Nothing to export from a blank new note + * or a note whose stored content could not be read. + */ + fun exportMarkdown(start: (id: Long) -> Unit) { + if (viewModel.isBlankDraft || viewModel.contentLoadFailed.value) return + scope.launch { + if (viewModel.saveNote() && viewModel.state.value.note.id > 0L) { + start(viewModel.state.value.note.id) + } else { + onFailure(Res.string.note_save_failed) + } + } + } + + fun discard() = leave(Res.string.note_discard_failed, viewModel::discardChanges) + + /** Discards at once when nothing would be lost; otherwise asks first ([confirm]). */ + fun discardOrConfirm(confirm: () -> Unit) { + scope.launch { if (viewModel.hasChanges()) confirm() else discard() } + } + + /** Stores a copy and opens it in place of this editor. A blank new note has nothing to copy. */ + fun duplicate() { + if (viewModel.isBlankDraft) return + var copyId = -1L + leave( + failure = Res.string.note_duplicate_failed, + action = { + copyId = if (viewModel.saveNote()) viewModel.noteDuplicate() else -1L + copyId > 0L + }, + onDone = { openCopy(copyId) }, + ) + } + + private fun leave(failure: StringResource, action: suspend () -> Boolean, onDone: () -> Unit = ::pop) { + if (leaving) return + leaving = true + scope.launch { + if (action()) { + onDone() + } else { + leaving = false + onFailure(failure) + } + } + } + + private fun pop() { + viewModel.markExitHandled() + beforeLeaving() + where.close() + } + + private fun openCopy(copyId: Long) { + viewModel.markExitHandled() + beforeLeaving() + where.openCopy(copyId) + } +} + +/** Where an editor lives, and so what leaving it does: popping its screen, or closing its tab. */ +internal interface EditorPlace { + /** Leaves the editor. */ + fun close() + + /** Opens the stored copy [copyId] in place of the editor. */ + fun openCopy(copyId: Long) +} + +/** An editor that is a full screen of [navController]'s back stack ([entry]: its destination). */ +internal class ScreenEditorPlace(private val navController: NavHostController, private val entry: NavBackStackEntry?) : + EditorPlace { + // Pops only this editor, and only while it is on top. + override fun close() { + if (entry == null || navController.currentBackStackEntry == entry) navController.popBackStack() + } + + override fun openCopy(copyId: Long) { + navController.navigate("${NavRoutes.EditNoteRoute.name}/$copyId") { + entry?.let { popUpTo(it.destination.id) { inclusive = true } } + } + } +} + +/** An editor in the detail pane: leaving closes its tab, and a copy takes the tab's place. */ +internal class PaneEditorPlace(private val pane: EditorPane) : EditorPlace { + override fun close() = pane.tabs.remove(pane.tabKey) + + override fun openCopy(copyId: Long) = pane.tabs.replace(pane.tabKey, copyId) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt index d908531b..2b079716 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt @@ -21,18 +21,24 @@ 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.core.resources.Res +import com.pasich.encly.core.resources.find_close +import com.pasich.encly.core.resources.find_count +import com.pasich.encly.core.resources.find_next +import com.pasich.encly.core.resources.find_no_matches +import com.pasich.encly.core.resources.find_placeholder +import com.pasich.encly.core.resources.find_previous +import com.pasich.encly.editor.state.FindState import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclySearchField import com.pasich.encly.presentation.designsystem.IconWithTooltip -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 +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource private const val FOCUS_FRAMES = 3 @@ -42,7 +48,7 @@ private const val FOCUS_FRAMES = 3 * saved state), and the whole bar is gone when the vault locks. */ @Composable -internal fun EditorFindBar(modifier: Modifier = Modifier, viewModel: EditNoteViewModel = enclyViewModel()) { +internal fun EditorFindBar(modifier: Modifier = Modifier, viewModel: EditNoteViewModel = metroViewModel()) { val state by viewModel.find.collectAsState() val focus = remember { FocusRequester() } LaunchedEffect(focus) { @@ -61,7 +67,7 @@ internal fun EditorFindBar(modifier: Modifier = Modifier, viewModel: EditNoteVie EnclySearchField( value = state.query, onValueChange = viewModel::onFindQueryChanged, - placeholder = stringResource(R.string.find_placeholder), + placeholder = stringResource(Res.string.find_placeholder), modifier = Modifier.weight(1f), fieldModifier = Modifier.focusRequester(focus), onSubmit = viewModel::findNext, @@ -70,17 +76,17 @@ internal fun EditorFindBar(modifier: Modifier = Modifier, viewModel: EditNoteVie val hasMatches = state.matches.isNotEmpty() IconWithTooltip( EnclyIcons.ArrowUp, - stringResource(R.string.find_previous), + stringResource(Res.string.find_previous), viewModel::findPrevious, enabled = hasMatches, ) IconWithTooltip( EnclyIcons.ArrowDown, - stringResource(R.string.find_next), + stringResource(Res.string.find_next), viewModel::findNext, enabled = hasMatches, ) - IconWithTooltip(EnclyIcons.Close, stringResource(R.string.find_close), viewModel::closeFind) + IconWithTooltip(EnclyIcons.Close, stringResource(Res.string.find_close), viewModel::closeFind) } } @@ -89,8 +95,8 @@ internal fun EditorFindBar(modifier: Modifier = Modifier, viewModel: EditNoteVie 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) + state.matches.isEmpty() -> stringResource(Res.string.find_no_matches) + else -> stringResource(Res.string.find_count, state.current + 1, state.matches.size) } Text( text = text, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorNotices.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorNotices.kt new file mode 100644 index 00000000..a3bebc0e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorNotices.kt @@ -0,0 +1,35 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.note_load_failed +import com.pasich.encly.core.resources.note_open_elsewhere +import com.pasich.encly.presentation.designsystem.CalloutTone +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +/** What is said under the title: that the content could not be read, or that another window edits the note. */ +internal fun LazyListScope.editorNotices(contentLoadFailed: Boolean, editedElsewhere: Boolean, gap: Dp) { + if (contentLoadFailed) { + item(key = "unreadable") { + EnclyCallout( + text = stringResource(Res.string.note_load_failed), + tone = CalloutTone.WARNING, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter, vertical = gap), + ) + } + } + if (editedElsewhere) { + item(key = "elsewhere") { + EnclyCallout( + text = stringResource(Res.string.note_open_elsewhere), + tone = CalloutTone.INFO, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter, vertical = gap), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorSelection.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorSelection.kt new file mode 100644 index 00000000..f2add26e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorSelection.kt @@ -0,0 +1,151 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.markdown_copied +import com.pasich.encly.core.resources.markdown_copy +import com.pasich.encly.core.resources.selection_copied +import com.pasich.encly.data.export.MarkdownBlocks +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.state.plainText +import com.pasich.encly.editor.ui.SelectedBlocksActions +import com.pasich.encly.editor.ui.SelectedBlocksBar +import com.pasich.encly.editor.ui.TextRangeBar +import com.pasich.encly.platform.LocalSensitiveClipboard +import com.pasich.encly.platform.SensitiveClipboard +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +/** The [blocks] as the text a copy puts on the clipboard: Markdown, or plain text. */ +internal fun selectedBlocksText(blocks: List, markdown: Boolean): String = + if (markdown) MarkdownBlocks.render(blocks) else blocks.plainText() + +/** + * Puts [blocks] on the clipboard as [markdown] or plain text through [SensitiveClipboard], and when they got there lets + * the editor remember them: pasting that exact text back inserts the blocks, not the text read again. True when copied. + */ +internal fun copyBlocks( + clipboard: SensitiveClipboard, + viewModel: EditNoteViewModel, + blocks: List, + markdown: Boolean, + label: String, +): Boolean { + val text = selectedBlocksText(blocks, markdown) + val copied = text.isNotEmpty() && clipboard.copy(label, text) + if (copied) viewModel.rememberCopied(text, blocks) + return copied +} + +/** + * Copies the picked blocks as [markdown] or plain text through [SensitiveClipboard]: marked sensitive and cleared + * after a minute. Block selection mode ends when the text reached the clipboard. Returns the confirmation to show + * ([confirmation]), or null when there is nothing to say: nothing was copied or the system already confirms it. + */ +internal fun copyPickedBlocks( + clipboard: SensitiveClipboard, + viewModel: EditNoteViewModel, + markdown: Boolean, + label: String, + confirmation: String, +): String? { + val copied = copyBlocks(clipboard, viewModel, viewModel.selectedBlocks.picked(), markdown, label) + if (copied) viewModel.selectedBlocks.clear() + return confirmation.takeIf { copied && !clipboard.systemConfirmsCopy } +} + +/** + * Cuts the picked blocks: their Markdown goes to the clipboard, and only when it got there are the blocks removed, as + * one undo step. The editor remembers the blocks too, so pasting that exact text back in this editor inserts them + * again as they were (tables, code and nested lists intact); the Markdown is what any other app gets. Does nothing in + * a note that cannot be changed. Returns the confirmation to show, or null. + */ +internal fun cutPickedBlocks( + clipboard: SensitiveClipboard, + viewModel: EditNoteViewModel, + label: String, + confirmation: String, +): String? { + if (!viewModel.canEditBlocks) return null + val copied = copyBlocks(clipboard, viewModel, viewModel.selectedBlocks.picked(), markdown = true, label) + if (copied) viewModel.deleteSelectedBlocks() + return confirmation.takeIf { copied && !clipboard.systemConfirmsCopy } +} + +/** + * The bar over the selected blocks: copy (plain or Markdown), cut and delete. A locked or trashed note can be + * copied from, not changed. [onMessage] shows the confirmation of a copy. + */ +@Composable +internal fun EditorSelectionBar(onMessage: (String) -> Unit, viewModel: EditNoteViewModel = metroViewModel()) { + val clipboard = LocalSensitiveClipboard.current + val label = stringResource(Res.string.markdown_copy) + val copied = stringResource(Res.string.selection_copied) + val copiedMarkdown = stringResource(Res.string.markdown_copied) + val locked by viewModel.lockEditor.collectAsState() + val actions = remember(viewModel, clipboard, label, copied, copiedMarkdown, locked) { + SelectedBlocksActions( + canEdit = viewModel.canEditBlocks, + onCopy = { copyPickedBlocks(clipboard, viewModel, false, label, copied)?.let(onMessage) }, + onCopyMarkdown = { copyPickedBlocks(clipboard, viewModel, true, label, copiedMarkdown)?.let(onMessage) }, + onCut = { cutPickedBlocks(clipboard, viewModel, label, copied)?.let(onMessage) }, + onDelete = { viewModel.deleteSelectedBlocks() }, + ) + } + SelectedBlocksBar(viewModel.selectedBlocks, actions) +} + +/** + * The bar over a text selection that runs across blocks: copy (plain or Markdown), cut and delete, through the same + * clipboard and rules as the keys. A locked or trashed note can be copied from, not changed. + */ +@Composable +internal fun EditorTextRangeBar( + onMessage: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: EditNoteViewModel = metroViewModel(), +) { + val clipboard = LocalSensitiveClipboard.current + val label = stringResource(Res.string.markdown_copy) + val copied = stringResource(Res.string.selection_copied) + val copiedMarkdown = stringResource(Res.string.markdown_copied) + val locked by viewModel.lockEditor.collectAsState() + val actions = remember(viewModel, clipboard, label, copied, copiedMarkdown, locked) { + fun copy(markdown: Boolean, message: String) { + if (copyBlocks(clipboard, viewModel, viewModel.textRangeBlocks(), markdown, label)) { + viewModel.textRange.clear() + message.takeIf { !clipboard.systemConfirmsCopy }?.let(onMessage) + } + } + SelectedBlocksActions( + canEdit = viewModel.canEditBlocks, + onCopy = { copy(markdown = false, message = copied) }, + onCopyMarkdown = { copy(markdown = true, message = copiedMarkdown) }, + onCut = { + // A selection that reaches a locked checklist is not cut: it would be copied but stay. + val cut = viewModel.canReplaceTextRange() && + copyBlocks(clipboard, viewModel, viewModel.textRangeBlocks(), markdown = true, label) + if (cut) viewModel.replaceTextRange("") + }, + onDelete = { viewModel.replaceTextRange("") }, + ) + } + TextRangeBar(viewModel.textRange, actions, modifier) +} + +/** Back leaves block selection mode, or a text selection across blocks, before it does anything else. */ +@Composable +internal fun LeaveBlockSelectionOnBack(viewModel: EditNoteViewModel = metroViewModel()) { + val picking by viewModel.selectedBlocks.ids.collectAsState() + val textRange by viewModel.textRange.ends.collectAsState() + BackHandler(enabled = picking.isNotEmpty() || (textRange != null && viewModel.textRange.isCrossField)) { + viewModel.selectedBlocks.clear() + viewModel.textRange.clear() + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt new file mode 100644 index 00000000..882b16f8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt @@ -0,0 +1,191 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.foundation.lazy.LazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.delete_from_trash +import com.pasich.encly.core.resources.edit_tags +import com.pasich.encly.core.resources.editor_lock +import com.pasich.encly.core.resources.more_options +import com.pasich.encly.core.resources.restore +import com.pasich.encly.editor.ui.sheets.EditorBlockSheet +import com.pasich.encly.presentation.components.editNote.NoteTagSheet +import com.pasich.encly.presentation.designsystem.EnclyBackButton +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTooltip +import com.pasich.encly.presentation.designsystem.IconWithTooltip +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +/** + * The sheet of one block (a link's, or a separator's); the editor shows it (`EditorBlockSheet`), + * the app only says which one is open and whether it may change the note. + */ +@Composable +internal fun EditorBlockSheets(sheet: BlockSheetState, canEdit: Boolean) { + val controller = rememberEditorController() + EditorBlockSheet( + kind = sheet.kind, + blockId = sheet.blockId, + canEdit = canEdit, + controller = controller, + onClose = sheet::close, + ) +} + +/** Chooses the note's tags (the sheet stays open); "Manage tags" leaves for the tags screen ([onManageTags]). */ +@Composable +internal fun EditorTagSheet( + onManageTags: () -> Unit, + onDismiss: () -> Unit, + viewModel: EditNoteViewModel = metroViewModel(), + tagViewModel: TagListViewModel = metroViewModel(), +) { + val tagState by tagViewModel.state.collectAsState() + val noteState by viewModel.state.collectAsState() + NoteTagSheet( + tags = tagState.listTags, + selectedTagIds = noteState.note.tagIds, + onToggle = viewModel::toggleTagNote, + onManageTags = onManageTags, + onDismiss = onDismiss, + ) +} + +/** What the editor bar shows: trash actions, or tags, lock (unless the note is unreadable) and more. */ +internal class EditorBarState( + val isReadTrashOnly: Boolean, + val lockEditor: Boolean, + val canToggleLock: Boolean, + val containerColor: Color, +) + +/** + * The editor bar: back (saves and closes), then Edit tags, Lock editing and More actions. In + * the trash: back, Restore and Delete. + */ +internal class EditorBarActions( + val onBack: () -> Unit, + val onRestore: () -> Unit, + val onDelete: () -> Unit, + val onTags: () -> Unit, + val onMenu: () -> Unit, + val onLockToggle: () -> Unit, +) + +/** Pinned above the note; a hairline appears once the note scrolls under it. */ +@Composable +internal fun EditorTopBar( + state: EditorBarState, + listState: LazyListState, + actions: EditorBarActions, + modifier: Modifier = Modifier, +) { + val scrolled by remember(listState) { derivedStateOf { listState.canScrollBackward } } + val colors = MaterialTheme.colorScheme + Surface(color = state.containerColor, modifier = modifier) { + Column( + modifier = Modifier.windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(EnclyTheme.spacing.topBarHeight) + .padding(horizontal = EnclyTheme.spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + ) { + EnclyBackButton(onClick = actions.onBack) + Spacer(modifier = Modifier.weight(1f)) + if (state.isReadTrashOnly) { + EnclyTextButton(text = stringResource(Res.string.restore), onClick = actions.onRestore) + EnclyTextButton( + text = stringResource(Res.string.delete_from_trash), + onClick = actions.onDelete, + destructive = true, + ) + } else { + EditorBarButtons(state, actions) + } + } + HorizontalDivider( + thickness = EnclyTheme.spacing.hairline, + color = if (scrolled) colors.outlineVariant else state.containerColor, + ) + } + } +} + +@Composable +private fun EditorBarButtons(state: EditorBarState, actions: EditorBarActions, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs)) { + if (state.canToggleLock) { + val tagsLabel = stringResource(Res.string.edit_tags) + EnclyTooltip(tagsLabel) { + IconButton( + onClick = actions.onTags, + enabled = !state.lockEditor, + colors = IconButtonDefaults.iconButtonColors(contentColor = colors.onSurface), + ) { + Icon(EnclyIcons.Tag, contentDescription = tagsLabel) + } + } + // A toggle, so TalkBack announces whether editing is locked. + val lockLabel = stringResource(Res.string.editor_lock) + EnclyTooltip(lockLabel) { + IconToggleButton( + checked = state.lockEditor, + onCheckedChange = { actions.onLockToggle() }, + colors = IconButtonDefaults.iconToggleButtonColors( + contentColor = colors.onSurface, + checkedContentColor = colors.primary, + ), + ) { + Icon( + if (state.lockEditor) EnclyIcons.Lock else EnclyIcons.LockOpen, + contentDescription = lockLabel, + ) + } + } + } + IconWithTooltip( + EnclyIcons.More, + stringResource(Res.string.more_options), + actions.onMenu, + tint = colors.onSurface, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommands.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommands.kt new file mode 100644 index 00000000..d66aad4e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommands.kt @@ -0,0 +1,54 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.pasich.encly.presentation.navigation.EditorCommands +import com.pasich.encly.presentation.navigation.EditorCommandsBinding +import com.pasich.encly.presentation.viewmodel.DocumentFormat +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel + +/** + * What the keyboard shortcuts and the host's menu do in this editor, registered while it is on screen: Find opens + * the find bar, Delete asks, then moves the note to the trash (not a note already in the trash, nor a blank new + * one, which has nothing to lose, nor one another window edits, which only shows it), Esc closes the find bar, and + * Print and Export as PDF start the document export. + */ +@Composable +internal fun EditorShortcutCommands( + overlays: EditorOverlaysState, + documents: EditorDocumentExport, + viewModel: EditNoteViewModel = metroViewModel(), +) { + val commands = remember(viewModel, overlays, documents) { + EditorCommands( + find = { true.also { viewModel.openFind() } }, + moveToTrash = { + val allowed = canMoveToTrashByShortcut( + viewModel.isReadTrashOnly, + viewModel.isBlankDraft, + viewModel.editedElsewhere.value, + ) + allowed.also { if (it) overlays.isTrashDialogVisible = true } + }, + dismiss = { dismissSelectionOrFind(viewModel) }, + print = { documents.start(DocumentFormat.PRINT) }, + exportPdf = { documents.start(DocumentFormat.PDF) }, + ) + } + EditorCommandsBinding(commands) +} + +/** Esc leaves block selection mode, else closes the find bar; true when it did either. */ +private fun dismissSelectionOrFind(viewModel: EditNoteViewModel): Boolean { + if (viewModel.selectedBlocks.isActive || viewModel.textRange.isActive) { + viewModel.selectedBlocks.clear() + viewModel.textRange.clear() + return true + } + return viewModel.find.value.isOpen.also { open -> if (open) viewModel.closeFind() } +} + +/** Whether Delete may ask to trash the note: not one in the trash, a blank draft, or one this window cannot change. */ +internal fun canMoveToTrashByShortcut(trashOnly: Boolean, blankDraft: Boolean, editedElsewhere: Boolean): Boolean = + !trashOnly && !blankDraft && !editedElsewhere diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/HistoryShortcut.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/HistoryShortcut.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/presentation/screen/editnote/HistoryShortcut.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/HistoryShortcut.kt index c91dabc4..6c6a865d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/HistoryShortcut.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/HistoryShortcut.kt @@ -45,5 +45,5 @@ internal fun EditNoteViewModel.handleHistoryKey(event: KeyEvent): Boolean { internal fun EditNoteViewModel.ownsHistoryKeys(): Boolean { val id = focusedBlockId.value ?: return false val block = blocks.firstOrNull { it.id == id } - return block != null && block !is com.pasich.encly.dynamicBlocks.Block.LinkBlock + return block != null && block !is com.pasich.encly.editor.model.Block.LinkBlock } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/LinkActions.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/LinkActions.kt new file mode 100644 index 00000000..4cdd7bd4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/LinkActions.kt @@ -0,0 +1,33 @@ +package com.pasich.encly.presentation.screen.editnote + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.link_blocked +import com.pasich.encly.core.resources.link_copied +import com.pasich.encly.core.resources.link_open_failed +import com.pasich.encly.editor.ui.blocks.isOpenableLink +import com.pasich.encly.platform.ExternalLinks +import com.pasich.encly.platform.SensitiveClipboard +import com.pasich.encly.platform.ToastDuration +import com.pasich.encly.platform.Toaster + +/** + * Opens a note's link in an app the user picks. The link leaves Encly: nothing is fetched by + * Encly itself, the chosen app does it. Only web and mail links are opened (a note restored from + * a crafted backup may hold any address), and a failure to open one never crashes the app. + */ +fun openNoteLink(links: ExternalLinks, toaster: Toaster, url: String) { + if (!isOpenableLink(url)) { + toaster.show(Res.string.link_blocked, ToastDuration.LONG) + return + } + if (!links.openUrl(url, chooser = true)) toaster.show(Res.string.link_open_failed, ToastDuration.LONG) +} + +/** + * Copies [text] (a note's link) under [label] with the app's clipboard policy ([SensitiveClipboard]: marked + * sensitive, cleared after a minute). Where the system shows no confirmation, a toast does. + */ +fun copyNoteLink(clipboard: SensitiveClipboard, toaster: Toaster, label: String, text: String) { + if (!clipboard.copy(label, text)) return + if (!clipboard.systemConfirmsCopy) toaster.show(Res.string.link_copied, ToastDuration.SHORT) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/NoteClaim.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/NoteClaim.kt new file mode 100644 index 00000000..63b8a342 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/NoteClaim.kt @@ -0,0 +1,50 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +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.setValue +import com.pasich.encly.platform.LocalNoteEditClaims +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.flow.first + +/** + * In a host with several windows, a note has one editor at a time (see `NoteEditRegistry`): this editor claims its + * note while it is on screen. When another window already edits it, this one becomes a read-only view of the + * stored note, which follows what the other window saves, and says so, instead of saving over the other window's + * work. When that window gives the note back this one claims it again and, if the stored note changed meanwhile, + * shows the stored text. The claim is given back only after this editor's last changes are saved. The note's id is + * only known once a new note was saved, so the claim follows it. Without claims (Android) nothing happens. + */ +@Composable +internal fun ClaimNoteForEditing(viewModel: EditNoteViewModel = metroViewModel()) { + val claims = LocalNoteEditClaims.current ?: return + val state = viewModel.state.collectAsState() + val noteId by remember(state) { derivedStateOf { state.value.note.id } } + if (viewModel.isReadTrashOnly || noteId <= 0L) return + var retry by remember(noteId) { mutableIntStateOf(0) } + var holding by remember(noteId) { mutableStateOf(true) } + var seenReleases by remember(noteId) { mutableLongStateOf(0L) } + DisposableEffect(claims, viewModel, noteId, retry) { + seenReleases = claims.releaseCount() + val granted = claims.claim(noteId) + holding = granted + viewModel.onClaimResult(granted) + onDispose { if (granted) viewModel.finishEditing { claims.release(noteId) } } + } + if (!holding) { + // Another window holds the note: try again as soon as any claim is given back. + LaunchedEffect(claims, noteId, seenReleases) { + claims.releases.first { it != seenReleases } + retry++ + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt new file mode 100644 index 00000000..4457d5e0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt @@ -0,0 +1,49 @@ +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 com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.template_start +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.state.NoteTemplate +import com.pasich.encly.editor.state.blocks +import com.pasich.encly.presentation.designsystem.EnclyChip +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +/** + * "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 = metroViewModel()) { + val strings = LocalEditorStrings.current + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + modifier = modifier.padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + ) { + SectionOverline(stringResource(Res.string.template_start)) + Row( + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + modifier = Modifier.horizontalScroll(rememberScrollState()), + ) { + NoteTemplate.entries.forEach { template -> + EnclyChip( + label = strings.text(template.label), + selected = template == NoteTemplate.BLANK, + onClick = { viewModel.applyTemplate(template.blocks(strings::text)) }, + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt new file mode 100644 index 00000000..22b7f61e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt @@ -0,0 +1,196 @@ +package com.pasich.encly.presentation.screen.handoff + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_count_notes +import com.pasich.encly.core.resources.backup_count_tags +import com.pasich.encly.core.resources.backup_count_tasks +import com.pasich.encly.core.resources.backup_import_done +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.close +import com.pasich.encly.core.resources.done +import com.pasich.encly.core.resources.handoff_done_hint +import com.pasich.encly.core.resources.handoff_done_title +import com.pasich.encly.core.resources.handoff_error_failed +import com.pasich.encly.core.resources.handoff_error_invalid +import com.pasich.encly.core.resources.handoff_error_title +import com.pasich.encly.core.resources.handoff_error_too_large +import com.pasich.encly.core.resources.handoff_error_unsupported +import com.pasich.encly.core.resources.handoff_error_vault +import com.pasich.encly.core.resources.handoff_import +import com.pasich.encly.core.resources.handoff_importing +import com.pasich.encly.core.resources.handoff_preview_attachments +import com.pasich.encly.core.resources.handoff_preview_body +import com.pasich.encly.core.resources.handoff_preview_pinned +import com.pasich.encly.core.resources.handoff_preview_title +import com.pasich.encly.core.resources.handoff_reading +import com.pasich.encly.core.resources.handoff_title +import com.pasich.encly.data.handoff.HandoffError +import com.pasich.encly.data.handoff.HandoffPreview +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.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.presentation.screen.LockExits +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.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource + +/** + * Import from My Notes: the lock screen while the vault is closed, then the hand-off's steps. + * [onClose] ends the activity with the result its current step stands for. + */ +@Composable +fun ImportFromMyNotesScreen( + onClose: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ImportFromMyNotesViewModel = metroViewModel(), +) { + val locked by viewModel.locked.collectAsState() + val step by viewModel.step.collectAsState() + // A failure shows even while the vault is closed: "nothing can open the vault" comes from + // the lock screen itself, and nothing of the vault is on that page. + if (locked && step !is HandoffStep.Failed) { + // Unlocking publishes itself to the session (LockViewModel); the steps then follow. + val exits = remember(viewModel, onClose) { + LockExits( + onUnlock = {}, + onRecoveryUnlock = {}, + onVaultLost = viewModel::onVaultLost, + onBack = onClose, + ) + } + LockScreen(exits = exits, modifier = modifier) + } else { + HandoffContent(step = step, onConfirm = viewModel::confirm, onClose = onClose, modifier = modifier) + } +} + +@Composable +private fun HandoffContent( + step: HandoffStep, + onConfirm: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + // The import is one transaction; leaving in the middle would only roll it back. + BackHandler(enabled = step == HandoffStep.Importing) {} + when (step) { + HandoffStep.Waiting -> AuthLoading(stringResource(Res.string.handoff_title), modifier) + + HandoffStep.Reading -> AuthLoading(stringResource(Res.string.handoff_reading), modifier) + + HandoffStep.Importing -> AuthLoading(stringResource(Res.string.handoff_importing), modifier) + + is HandoffStep.Preview -> HandoffPage(onClose = onClose, modifier = modifier) { + PreviewBody(step.preview) + EnclyButton(text = stringResource(Res.string.handoff_import), onClick = onConfirm) + EnclyTextButton( + text = stringResource(Res.string.cancel), + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) + } + + is HandoffStep.Done -> HandoffPage(onClose = onClose, modifier = modifier) { + val summary = step.summary + StepHeading( + title = stringResource(Res.string.handoff_done_title), + body = stringResource( + Res.string.backup_import_done, + summary.notesAdded, + summary.tasksAdded, + summary.tagsAdded, + step.skipped, + ), + ) + EnclyCallout(text = stringResource(Res.string.handoff_done_hint)) + EnclyButton(text = stringResource(Res.string.done), onClick = onClose) + } + + is HandoffStep.Failed -> HandoffPage(onClose = onClose, modifier = modifier) { + StepHeading(title = stringResource(Res.string.handoff_error_title), body = stringResource(errorText(step))) + EnclyButton(text = stringResource(Res.string.close), onClick = onClose) + } + } +} + +/** The counts, then what will not come over. */ +@Composable +private fun PreviewBody(preview: HandoffPreview, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section)) { + StepHeading( + title = stringResource(Res.string.handoff_preview_title), + body = stringResource( + Res.string.handoff_preview_body, + pluralStringResource(Res.plurals.backup_count_notes, preview.notes, preview.notes), + pluralStringResource(Res.plurals.backup_count_tasks, preview.tasks, preview.tasks), + pluralStringResource(Res.plurals.backup_count_tags, preview.tags, preview.tags), + ), + ) + if (preview.attachments > 0) { + EnclyCallout( + text = stringResource(Res.string.handoff_preview_attachments, preview.attachments), + tone = CalloutTone.WARNING, + ) + } + if (preview.pinned > 0) { + EnclyCallout(text = stringResource(Res.string.handoff_preview_pinned, preview.pinned)) + } + } +} + +/** A titled, scrolling page with a close button, for every step that waits for the user. */ +@Composable +private fun HandoffPage( + onClose: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column { + EnclyTopBar(title = stringResource(Res.string.handoff_title), onClose = onClose) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), + content = content, + ) + } + } +} + +private fun errorText(step: HandoffStep.Failed): StringResource = when { + step.vaultUnavailable -> Res.string.handoff_error_vault + step.error == HandoffError.UNSUPPORTED_SCHEMA -> Res.string.handoff_error_unsupported + step.error == HandoffError.TOO_LARGE -> Res.string.handoff_error_too_large + step.error == HandoffError.INVALID_PAYLOAD -> Res.string.handoff_error_invalid + else -> Res.string.handoff_error_failed +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesActions.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportNotesActions.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesActions.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportNotesActions.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt similarity index 80% rename from app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt index 3ff58e89..8dc94ffb 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt @@ -1,9 +1,5 @@ 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 @@ -17,16 +13,22 @@ 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.compose.ui.backhandler.BackHandler import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.import_importing +import com.pasich.encly.core.resources.import_reading +import com.pasich.encly.core.resources.import_title +import com.pasich.encly.platform.LocalDocumentPickers +import com.pasich.encly.platform.PickerUnavailableException 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource /** * Settings → Import notes. Works in a decoy session too: it imports into whichever vault is @@ -36,19 +38,16 @@ import com.pasich.encly.ui.theme.EnclyTheme fun ImportNotesScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: ImportNotesViewModel = enclyViewModel(), + viewModel: ImportNotesViewModel = metroViewModel(), ) { val step by viewModel.step.collectAsStateWithLifecycle() - val files = rememberLauncherForActivityResult( - ActivityResultContracts.OpenMultipleDocuments(), - viewModel::onFilesPicked, - ) - val folder = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree(), viewModel::onFolderPicked) + val pickers = LocalDocumentPickers.current + val files = pickers.rememberOpenDocuments(viewModel::onFilesPicked) + val folder = pickers.rememberOpenFolder(viewModel::onFolderPicked) val actions = remember(viewModel, files, folder) { ImportNotesActions( onChooseFiles = { launchPicker(viewModel) { files.launch(arrayOf("*/*")) } }, - onChooseFolder = { launchPicker(viewModel) { folder.launch(null) } }, + onChooseFolder = { launchPicker(viewModel) { folder.launch(Unit) } }, onImport = viewModel::confirm, onCancel = viewModel::cancel, onChooseSource = viewModel::chooseSource, @@ -73,7 +72,7 @@ fun ImportNotesScreen( private fun launchPicker(viewModel: ImportNotesViewModel, launch: () -> Unit) { try { launch() - } catch (_: ActivityNotFoundException) { + } catch (_: PickerUnavailableException) { viewModel.onPickerUnavailable() return } @@ -89,9 +88,9 @@ fun ImportNotesContent( modifier: Modifier = Modifier, ) { when (step) { - ImportStep.Reading -> AuthLoading(stringResource(R.string.import_reading), modifier) + ImportStep.Reading -> AuthLoading(stringResource(Res.string.import_reading), modifier) - ImportStep.Importing -> AuthLoading(stringResource(R.string.import_importing), modifier) + ImportStep.Importing -> AuthLoading(stringResource(Res.string.import_importing), modifier) is ImportStep.Preview -> ImportReviewScreen(step.preview, step.review, actions, modifier) @@ -100,7 +99,7 @@ fun ImportNotesContent( containerColor = MaterialTheme.colorScheme.surface, topBar = { EnclyTopBar( - title = stringResource(R.string.import_title), + title = stringResource(Res.string.import_title), onBack = if (step == ImportStep.Start) onBack else actions.onCancel, ) }, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt new file mode 100644 index 00000000..b71bfc22 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt @@ -0,0 +1,162 @@ +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 com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.done +import com.pasich.encly.core.resources.import_choose_again +import com.pasich.encly.core.resources.import_choose_files +import com.pasich.encly.core.resources.import_choose_folder +import com.pasich.encly.core.resources.import_choose_source_body +import com.pasich.encly.core.resources.import_choose_source_title +import com.pasich.encly.core.resources.import_done_body +import com.pasich.encly.core.resources.import_done_hint +import com.pasich.encly.core.resources.import_done_title +import com.pasich.encly.core.resources.import_error_encrypted_nn +import com.pasich.encly.core.resources.import_error_encrypted_sn +import com.pasich.encly.core.resources.import_error_failed +import com.pasich.encly.core.resources.import_error_no_picker +import com.pasich.encly.core.resources.import_error_nothing +import com.pasich.encly.core.resources.import_error_title +import com.pasich.encly.core.resources.import_error_too_large +import com.pasich.encly.core.resources.import_error_unreadable +import com.pasich.encly.core.resources.import_error_unsupported +import com.pasich.encly.core.resources.import_error_vault +import com.pasich.encly.core.resources.import_intro +import com.pasich.encly.core.resources.import_loss_archived +import com.pasich.encly.core.resources.import_loss_attachments +import com.pasich.encly.core.resources.import_loss_colors +import com.pasich.encly.core.resources.import_loss_files +import com.pasich.encly.core.resources.import_loss_nested +import com.pasich.encly.core.resources.import_loss_pinned +import com.pasich.encly.core.resources.import_loss_todos +import com.pasich.encly.core.resources.import_loss_unsupported +import com.pasich.encly.core.resources.import_section_supported +import com.pasich.encly.core.resources.import_stat_checklists +import com.pasich.encly.core.resources.import_stat_duplicates +import com.pasich.encly.core.resources.import_stat_repeated +import com.pasich.encly.core.resources.import_stat_tags +import com.pasich.encly.core.resources.import_stat_trashed +import com.pasich.encly.data.importer.ImportLosses +import com.pasich.encly.data.importer.ImportPreview +import com.pasich.encly.data.importer.ImportSource +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTonalButton +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.presentation.viewmodel.ImportFailure +import com.pasich.encly.presentation.viewmodel.ImportResult +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun StartPage(actions: ImportNotesActions) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section)) { + Text( + text = stringResource(Res.string.import_intro), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + EnclyButton(text = stringResource(Res.string.import_choose_files), onClick = actions.onChooseFiles) + EnclyTonalButton(text = stringResource(Res.string.import_choose_folder), onClick = actions.onChooseFolder) + } + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(text = stringResource(Res.string.import_section_supported)) + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.cardGap)) { + ImportSource.entries.forEach { SourceCard(it) } + } + } + } +} + +@Composable +internal fun Labelled(label: String, text: String, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap)) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = text, style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +internal fun previewLines(preview: ImportPreview): List = buildList { + add(stringResource(Res.string.import_stat_checklists, preview.checklists)) + add(stringResource(Res.string.import_stat_tags, preview.tags)) + if (preview.trashed > 0) add(stringResource(Res.string.import_stat_trashed, preview.trashed)) + if (preview.duplicates > 0) add(stringResource(Res.string.import_stat_duplicates, preview.duplicates)) + if (preview.duplicatesInSelection > 0) { + add(stringResource(Res.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(Res.string.import_loss_attachments, losses.attachments)) + if (losses.pinned > 0) add(stringResource(Res.string.import_loss_pinned, losses.pinned)) + if (losses.archived > 0) add(stringResource(Res.string.import_loss_archived, losses.archived)) + if (losses.colors > 0) add(stringResource(Res.string.import_loss_colors, losses.colors)) + if (losses.todos > 0) add(stringResource(Res.string.import_loss_todos, losses.todos)) + if (preview.unsupported > 0) add(stringResource(Res.string.import_loss_unsupported, preview.unsupported)) + if (preview.filesSkipped > 0) add(stringResource(Res.string.import_loss_files, preview.filesSkipped)) + if (preview.nestedArchives > 0) add(stringResource(Res.string.import_loss_nested, preview.nestedArchives)) + } +} + +@Composable +internal fun ChooseSourcePage(actions: ImportNotesActions) { + StepHeading( + title = stringResource(Res.string.import_choose_source_title), + body = stringResource(Res.string.import_choose_source_body), + ) + ImportSource.entries.forEach { source -> + EnclyTonalButton(text = stringResource(source.title), onClick = { actions.onChooseSource(source) }) + } + EnclyTextButton( + text = stringResource(Res.string.cancel), + onClick = actions.onCancel, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +internal fun DonePage(result: ImportResult, actions: ImportNotesActions) { + StepHeading( + title = stringResource(Res.string.import_done_title), + body = stringResource(Res.string.import_done_body, result.imported, result.duplicates, result.notImported), + ) + EnclyCallout(text = stringResource(Res.string.import_done_hint)) + EnclyButton(text = stringResource(Res.string.done), onClick = actions.onCancel) +} + +@Composable +internal fun FailedPage(failure: ImportFailure, actions: ImportNotesActions) { + StepHeading(title = stringResource(Res.string.import_error_title), body = stringResource(errorText(failure))) + EnclyButton(text = stringResource(Res.string.import_choose_again), onClick = actions.onCancel) +} + +internal fun errorText(failure: ImportFailure): StringResource = when (failure) { + ImportFailure.ENCRYPTED_STANDARD_NOTES -> Res.string.import_error_encrypted_sn + ImportFailure.ENCRYPTED_NOTESNOOK -> Res.string.import_error_encrypted_nn + ImportFailure.UNSUPPORTED -> Res.string.import_error_unsupported + ImportFailure.TOO_LARGE -> Res.string.import_error_too_large + ImportFailure.NOTHING_FOUND -> Res.string.import_error_nothing + ImportFailure.UNREADABLE -> Res.string.import_error_unreadable + ImportFailure.NO_PICKER -> Res.string.import_error_no_picker + ImportFailure.VAULT -> Res.string.import_error_vault + ImportFailure.IMPORT -> Res.string.import_error_failed +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt new file mode 100644 index 00000000..fe4f16f6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt @@ -0,0 +1,232 @@ +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 com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_count_notes +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.import_action_import_count +import com.pasich.encly.core.resources.import_badge_checklist +import com.pasich.encly.core.resources.import_badge_in_vault +import com.pasich.encly.core.resources.import_badge_trash +import com.pasich.encly.core.resources.import_detected +import com.pasich.encly.core.resources.import_filter_all +import com.pasich.encly.core.resources.import_filter_new +import com.pasich.encly.core.resources.import_filter_trash +import com.pasich.encly.core.resources.import_label_losses +import com.pasich.encly.core.resources.import_preview_body +import com.pasich.encly.core.resources.import_preview_title +import com.pasich.encly.core.resources.import_review_selected +import com.pasich.encly.core.resources.import_select_all +import com.pasich.encly.core.resources.import_select_none +import com.pasich.encly.core.resources.import_stat_destination +import com.pasich.encly.core.resources.import_title +import com.pasich.encly.core.resources.import_untitled +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 org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import kotlin.time.Instant + +/** + * 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(Res.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(Res.string.import_action_import_count, review.selected.size), + onClick = actions.onImport, + enabled = review.selected.isNotEmpty(), + ) + EnclyTextButton( + text = stringResource(Res.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(Res.string.import_preview_title), + body = stringResource( + Res.string.import_detected, + stringResource(preview.source.title), + pluralStringResource(Res.plurals.backup_count_notes, preview.notes, preview.notes), + ), + ) + EnclyCard { + previewLines(preview).forEach { Text(text = it, style = MaterialTheme.typography.bodyMedium) } + Text( + text = stringResource(Res.string.import_stat_destination), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val losses = lossLines(preview) + if (losses.isNotEmpty()) { + EnclyCallout( + title = stringResource(Res.string.import_label_losses), + text = losses.joinToString("\n"), + tone = CalloutTone.WARNING, + ) + } + Text( + text = stringResource(Res.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(Res.string.import_review_selected, review.selected.size, review.newCount), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + ) + // Nothing new to pick (every note is already here): no select-all to offer. + if (review.newCount > 0) { + val all = review.selected.size == review.newCount + EnclyTextButton( + text = stringResource(if (all) Res.string.import_select_none else Res.string.import_select_all), + onClick = actions.onToggleAll, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + FilterChip(ReviewFilter.ALL, Res.string.import_filter_all, review, actions) + FilterChip(ReviewFilter.NEW, Res.string.import_filter_new, review, actions) + FilterChip(ReviewFilter.TRASH, Res.string.import_filter_trash, review, actions) + } + } +} + +@Composable +private fun FilterChip(filter: ReviewFilter, label: StringResource, 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(Res.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, tags, in trash, already in the vault, then the date. */ +@Composable +private fun badges(note: ReviewNote): String = buildList { + if (note.hasChecklist) add(stringResource(Res.string.import_badge_checklist)) + note.tags.forEach { add("#$it") } + if (note.trashed) add(stringResource(Res.string.import_badge_trash)) + if (note.existing) add(stringResource(Res.string.import_badge_in_vault)) + add(formatNoteDate(Instant.fromEpochMilliseconds(note.date))) +}.joinToString(" · ") diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt index c241a165..1540454e 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt @@ -20,13 +20,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate -import androidx.compose.ui.res.stringResource -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.collapse +import com.pasich.encly.core.resources.expand +import com.pasich.encly.core.resources.import_label_how_to +import com.pasich.encly.core.resources.import_label_losses import com.pasich.encly.data.importer.ImportSource import com.pasich.encly.presentation.designsystem.EnclyCard import com.pasich.encly.presentation.designsystem.EnclyCardStyle import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource /** * One supported app on the Import notes page: its mark, its name and what to pick. A tap anywhere @@ -56,7 +60,7 @@ internal fun SourceCard(source: ImportSource, modifier: Modifier = Modifier) { } Icon( EnclyIcons.ChevronDown, - contentDescription = stringResource(if (expanded) R.string.collapse else R.string.expand), + contentDescription = stringResource(if (expanded) Res.string.collapse else Res.string.expand), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .size(EnclyTheme.spacing.iconSmall) @@ -71,8 +75,8 @@ internal fun SourceCard(source: ImportSource, modifier: Modifier = Modifier) { ), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), ) { - Labelled(stringResource(R.string.import_label_how_to), stringResource(source.howTo)) - Labelled(stringResource(R.string.import_label_losses), stringResource(source.losses)) + Labelled(stringResource(Res.string.import_label_how_to), stringResource(source.howTo)) + Labelled(stringResource(Res.string.import_label_losses), stringResource(source.losses)) } } } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingActions.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingActions.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingActions.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingActions.kt index 1163db42..6719bb91 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingActions.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingActions.kt @@ -19,15 +19,18 @@ internal interface OnboardingActions { val restore: RestoreActions val onRestore: () -> Unit val onOpenNotebook: () -> Unit + val onRetryBiometric: () -> Unit } /** The actions of [viewModel], plus those the screen itself handles. */ +@Suppress("LongParameterList") // One callback per action only the screen can perform (host, picker, navigation). internal fun onboardingActions( viewModel: OnboardingViewModel, restore: RestoreActions, onRestore: () -> Unit, onLanguage: () -> Unit, onOpenNotebook: () -> Unit, + onRetryBiometric: () -> Unit, ): OnboardingActions = object : OnboardingActions { override val onBack = viewModel::back override val onLanguage = onLanguage @@ -49,4 +52,5 @@ internal fun onboardingActions( override val restore = restore override val onRestore = onRestore override val onOpenNotebook = onOpenNotebook + override val onRetryBiometric = onRetryBiometric } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingComponents.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingComponents.kt similarity index 83% rename from app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingComponents.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingComponents.kt index baaa5bd7..7ea4ec12 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingComponents.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingComponents.kt @@ -1,9 +1,9 @@ package com.pasich.encly.presentation.screen.onboarding -import androidx.annotation.StringRes import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.StringResource /** Content padding of a step body: the 24 dp gutter. */ internal val StepPadding: PaddingValues @@ -11,11 +11,11 @@ internal val StepPadding: PaddingValues /** The footer of one step: the primary action and an optional text action under it. */ internal class FooterSpec( - @param:StringRes val primary: Int, + val primary: StringResource, val onPrimary: () -> Unit, val enabled: Boolean = true, val loading: Boolean = false, - @param:StringRes val secondary: Int? = null, + val secondary: StringResource? = null, val onSecondary: () -> Unit = {}, val secondaryMuted: Boolean = false, ) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingFooter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingFooter.kt new file mode 100644 index 00000000..ffc90f9d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingFooter.kt @@ -0,0 +1,125 @@ +package com.pasich.encly.presentation.screen.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.onboarding_get_started +import com.pasich.encly.core.resources.onboarding_have_backup +import com.pasich.encly.core.resources.onboarding_phrase_done +import com.pasich.encly.core.resources.onboarding_ready_biometric_retry +import com.pasich.encly.core.resources.onboarding_ready_open +import com.pasich.encly.core.resources.onboarding_recovery_create +import com.pasich.encly.core.resources.onboarding_recovery_skip +import com.pasich.encly.core.resources.onboarding_verify_confirm +import com.pasich.encly.core.resources.onboarding_verify_show_again +import com.pasich.encly.core.resources.restore +import com.pasich.encly.core.security.MIN_PIN_LENGTH +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.RecoveryPhraseState +import com.pasich.encly.presentation.viewmodel.OnboardingStep +import com.pasich.encly.presentation.viewmodel.OnboardingViewModel.OnboardingUiState +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +internal fun footerSpec( + state: OnboardingUiState, + actions: OnboardingActions, + restorePhrase: RecoveryPhraseState, + finishing: Boolean, +): FooterSpec { + val busy = state.isLoading + return when (state.step) { + OnboardingStep.WELCOME -> FooterSpec( + Res.string.onboarding_get_started, + actions.onGetStarted, + secondary = Res.string.onboarding_have_backup, + onSecondary = actions.onStartRestore, + ) + + OnboardingStep.PIN -> + FooterSpec(Res.string.action_continue, actions.pin.onSubmit, state.pinLength >= MIN_PIN_LENGTH, busy) + + OnboardingStep.RECOVERY_INFO -> FooterSpec( + Res.string.onboarding_recovery_create, + actions.onCreatePhrase, + loading = busy, + secondary = Res.string.onboarding_recovery_skip, + onSecondary = actions.onSkipPhrase, + secondaryMuted = true, + ) + + OnboardingStep.PHRASE -> + FooterSpec(Res.string.onboarding_phrase_done, actions.onWroteWords, state.words.isNotEmpty()) + + OnboardingStep.VERIFY -> FooterSpec( + Res.string.onboarding_verify_confirm, + actions.onConfirmWords, + state.isVerificationComplete, + busy, + secondary = Res.string.onboarding_verify_show_again, + onSecondary = actions.onShowWordsAgain, + ) + + OnboardingStep.READY -> FooterSpec( + Res.string.onboarding_ready_open, + actions.onOpenNotebook, + !state.biometricPending, + finishing, + secondary = Res.string.onboarding_ready_biometric_retry.takeIf { state.biometricRetry }, + onSecondary = actions.onRetryBiometric, + ) + + OnboardingStep.RESTORE -> FooterSpec( + Res.string.restore, + actions.onRestore, + state.restoreFileReady && restorePhrase.canSubmit, + busy, + ) + } +} + +/** + * The footer: the primary action, and below it a text action or the same space left empty, + * so the primary button never moves between steps. Text actions wait while the primary runs. + */ +@Composable +internal fun OnboardingFooter(spec: FooterSpec, modifier: Modifier = Modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + modifier = modifier + .fillMaxWidth() + .padding( + start = EnclyTheme.spacing.gutter, + end = EnclyTheme.spacing.gutter, + top = EnclyTheme.spacing.s, + bottom = EnclyTheme.spacing.stepGap, + ), + ) { + EnclyButton( + text = stringResource(spec.primary), + onClick = spec.onPrimary, + enabled = spec.enabled, + loading = spec.loading, + ) + val secondary = spec.secondary + if (secondary == null) { + Spacer(modifier = Modifier.height(EnclyTheme.spacing.textButtonHeight)) + } else { + EnclyTextButton( + text = stringResource(secondary), + onClick = spec.onSecondary, + enabled = !spec.loading, + muted = spec.secondaryMuted, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..166b92af --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt @@ -0,0 +1,292 @@ +package com.pasich.encly.presentation.screen.onboarding + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pasich.encly.core.common.asString +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.auth_setup_restore_failed +import com.pasich.encly.core.resources.auth_setup_restore_failed_title +import com.pasich.encly.core.resources.auth_setup_restore_retry +import com.pasich.encly.core.resources.auth_setup_restore_skip +import com.pasich.encly.core.resources.error_database_open +import com.pasich.encly.core.resources.language_dialog_title +import com.pasich.encly.core.resources.onboarding_restore_label +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.LocalAppInfo +import com.pasich.encly.platform.LocalAppLocales +import com.pasich.encly.platform.LocalBiometricHost +import com.pasich.encly.platform.LocalDocumentPickers +import com.pasich.encly.platform.PickerUnavailableException +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import com.pasich.encly.presentation.designsystem.EnclyProgressHeader +import com.pasich.encly.presentation.designsystem.LabelHeader +import com.pasich.encly.presentation.designsystem.RecoveryPhraseState +import com.pasich.encly.presentation.designsystem.WelcomeHeader +import com.pasich.encly.presentation.dialogs.LanguageDialog +import com.pasich.encly.presentation.effects.FadeTiming +import com.pasich.encly.presentation.effects.screenFade +import com.pasich.encly.presentation.viewmodel.AuthSetupViewModel +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.ui.theme.EnclyTheme +import com.pasich.encly.utils.currentLanguageTag +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +private const val SLIDE_MS = 500 +private val StepFadeIn = FadeTiming(delayMillis = 200, durationMillis = 300, easing = FastOutSlowInEasing) +private val StepFadeOut = FadeTiming(delayMillis = 0, durationMillis = 300, easing = FastOutSlowInEasing) + +/** + * First run, in one scaffold: a header (wordmark, progress or label), the step, and a footer + * whose primary button sits at the same place on every step. PIN and fingerprint come first, + * then the recovery phrase; the vault is created on the way to Ready and committed by "Open my + * notebook" ([AuthSetupViewModel.finishSetup]), which also imports a restored backup. + */ +@Composable +fun OnboardingScreen( + onComplete: () -> Unit, + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = metroViewModel(), + setupViewModel: AuthSetupViewModel = metroViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val finishing by setupViewModel.busy.collectAsStateWithLifecycle() + val restoreFailed by setupViewModel.restoreFailed.collectAsStateWithLifecycle() + val finishFailed by setupViewModel.finishFailed.collectAsStateWithLifecycle() + val host = LocalBiometricHost.current + val currentOnComplete by rememberUpdatedState(onComplete) + val pickBackupFile = rememberBackupFilePicker(viewModel::onRestoreFilePicked, viewModel::onRestorePickerUnavailable) + + // The phrase is never put in saved state; it lives only while the screen does. + val restorePhrase = remember { RecoveryPhraseState() } + var languageDialog by rememberSaveable { mutableStateOf(false) } + + val onFinished: (AuthSetupViewModel.FinishResult) -> Unit = { result -> + // Backgrounded: set up but re-locked; EnclyApp routes to the lock screen. + if (result.ok) currentOnComplete() + } + val onRestore = { + if (state.restoreFileReady && restorePhrase.canSubmit) viewModel.restoreBackup(restorePhrase.toCharArray()) + } + val actions = onboardingActions( + viewModel = viewModel, + restore = RestoreActions( + onPickFile = pickBackupFile, + onPhraseEdited = viewModel::onRestorePhraseEdited, + onSubmit = onRestore, + ), + onRestore = onRestore, + onLanguage = { languageDialog = true }, + onOpenNotebook = { setupViewModel.finishSetup(onFinished) }, + onRetryBiometric = { host?.let(viewModel::retryBiometric) }, + ) + + LaunchedEffect(state.biometricPending) { + if (state.biometricPending) { + if (host != null) viewModel.enrollBiometric(host) else viewModel.skipBiometric() + } + } + BackHandler(enabled = state.step != OnboardingStep.WELCOME) { viewModel.back() } + // Off the restore path (and once it reached Ready) the typed words are dropped. + LaunchedEffect(state.step) { + if (state.step != OnboardingStep.RESTORE && state.step != OnboardingStep.PIN) restorePhrase.clear() + } + + if (languageDialog) OnboardingLanguageDialog(onDismiss = { languageDialog = false }) + // Only over the restore path's Ready step, the one place a staged backup can have failed. + if (restoreFailed && state.step == OnboardingStep.READY && state.path == OnboardingPath.RESTORE) { + RestoreFailedDialog( + onRetry = { setupViewModel.retryRestore(onFinished) }, + onSkip = { setupViewModel.skipRestore(onFinished) }, + ) + } + + OnboardingScaffold( + state = state, + actions = actions, + restorePhrase = restorePhrase, + finishing = finishing, + error = if (finishFailed) stringResource(Res.string.error_database_open) else state.error?.asString(), + modifier = modifier, + ) +} + +@Composable +private fun OnboardingScaffold( + state: OnboardingUiState, + actions: OnboardingActions, + restorePhrase: RecoveryPhraseState, + finishing: Boolean, + error: String?, + modifier: Modifier = Modifier, +) { + Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .imePadding(), + ) { + OnboardingHeader(state = state, onBack = actions.onBack, onLanguage = actions.onLanguage) + StepContent( + state = state, + actions = actions, + restorePhrase = restorePhrase, + modifier = Modifier.weight(1f), + ) + if (error != null) { + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter), + ) + } + OnboardingFooter(footerSpec(state, actions, restorePhrase, finishing)) + } + } +} + +@Composable +private fun OnboardingLanguageDialog(onDismiss: () -> Unit) { + val locales = LocalAppLocales.current + LanguageDialog( + current = locales.current(), + onDismiss = onDismiss, + onConfirm = { language -> + onDismiss() + if (language != locales.current()) locales.apply(language) + }, + ) +} + +@Composable +private fun OnboardingHeader(state: OnboardingUiState, onBack: () -> Unit, onLanguage: () -> Unit) { + val progress = state.progress + when { + state.step == OnboardingStep.WELCOME -> WelcomeHeader( + appName = LocalAppInfo.current.appName, + language = stringResource(shownLanguage().nativeName), + languageDescription = stringResource(Res.string.language_dialog_title), + onLanguage = onLanguage, + ) + + progress == null -> LabelHeader(label = stringResource(Res.string.onboarding_restore_label), onBack = onBack) + + else -> EnclyProgressHeader( + step = progress.step, + totalSteps = progress.total, + onBack = if (state.canGoBack) onBack else null, + ) + } +} + +/** The language the UI is shown in: the in-app choice, else the device's if translated, else English. */ +@Composable +private fun shownLanguage(): AppLanguage { + val chosen = LocalAppLocales.current.current() + if (chosen != AppLanguage.SYSTEM) return chosen + val device = AppLanguage.fromTag(currentLanguageTag()) + return if (device == AppLanguage.SYSTEM) AppLanguage.ENGLISH else device +} + +@Composable +private fun StepContent( + state: OnboardingUiState, + actions: OnboardingActions, + restorePhrase: RecoveryPhraseState, + modifier: Modifier = Modifier, +) { + AnimatedContent( + targetState = state.step, + modifier = modifier.fillMaxWidth(), + transitionSpec = { + val forward = targetState.ordinal > initialState.ordinal + val direction = if (forward) SlideDirection.Start else SlideDirection.End + slideIntoContainer( + towards = direction, + animationSpec = tween(SLIDE_MS, easing = FastOutSlowInEasing), + ) togetherWith slideOutOfContainer( + towards = direction, + animationSpec = tween(SLIDE_MS, easing = FastOutSlowInEasing), + ) + }, + label = "onboarding_step", + ) { step -> + // The fade is drawn per draw call, not as fadeIn/fadeOut would: those put the step in an offscreen layer. + val body = Modifier + .screenFade(this, enter = { StepFadeIn }, exit = { StepFadeOut }) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = EnclyTheme.spacing.l) + when (step) { + OnboardingStep.WELCOME -> WelcomeStep(body) + OnboardingStep.PIN -> PinStep(state, actions.pin, body) + OnboardingStep.RECOVERY_INFO -> RecoveryInfoStep(body) + OnboardingStep.PHRASE -> PhraseStep(state, onToggleWords = actions.onToggleWords, modifier = body) + OnboardingStep.VERIFY -> VerifyStep(state, onAnswer = actions.onAnswer, modifier = body) + OnboardingStep.READY -> ReadyStep(state, body) + OnboardingStep.RESTORE -> RestoreStep(state, restorePhrase, actions.restore, body) + } + } +} + +/** The staged backup did not import; nothing is committed until the user chooses. */ +@Composable +private fun RestoreFailedDialog(onRetry: () -> Unit, onSkip: () -> Unit) { + EnclyDialog( + title = stringResource(Res.string.auth_setup_restore_failed_title), + text = stringResource(Res.string.auth_setup_restore_failed), + onDismissRequest = {}, + confirm = DialogAction(stringResource(Res.string.auth_setup_restore_retry), onRetry), + dismiss = DialogAction(stringResource(Res.string.auth_setup_restore_skip), onSkip), + ) +} + +/** + * The Storage Access Framework "open document" picker for backup files. [onUnavailable] runs + * instead of a crash on a device with no documents app to handle it. + */ +@Composable +private fun rememberBackupFilePicker(onPick: (DocumentRef?) -> Unit, onUnavailable: () -> Unit): () -> Unit { + val launcher = LocalDocumentPickers.current.rememberOpenDocument(onPick) + return { + try { + launcher.launch(arrayOf("*/*")) + } catch (_: PickerUnavailableException) { + onUnavailable() + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingSteps.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingSteps.kt new file mode 100644 index 00000000..7a97333a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingSteps.kt @@ -0,0 +1,469 @@ +package com.pasich.encly.presentation.screen.onboarding + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +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.runtime.remember +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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import com.pasich.encly.core.common.asString +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.onboarding_biometric_desc +import com.pasich.encly.core.resources.onboarding_fact_account_desc +import com.pasich.encly.core.resources.onboarding_fact_account_title +import com.pasich.encly.core.resources.onboarding_fact_encrypted_desc +import com.pasich.encly.core.resources.onboarding_fact_encrypted_title +import com.pasich.encly.core.resources.onboarding_fact_offline_desc +import com.pasich.encly.core.resources.onboarding_fact_offline_title +import com.pasich.encly.core.resources.onboarding_phrase_body +import com.pasich.encly.core.resources.onboarding_phrase_hide +import com.pasich.encly.core.resources.onboarding_phrase_show +import com.pasich.encly.core.resources.onboarding_phrase_title +import com.pasich.encly.core.resources.onboarding_pin_again_body +import com.pasich.encly.core.resources.onboarding_pin_again_title +import com.pasich.encly.core.resources.onboarding_pin_body +import com.pasich.encly.core.resources.onboarding_pin_title +import com.pasich.encly.core.resources.onboarding_ready_biometric_desc +import com.pasich.encly.core.resources.onboarding_ready_biometric_title +import com.pasich.encly.core.resources.onboarding_ready_body +import com.pasich.encly.core.resources.onboarding_ready_no_phrase_desc +import com.pasich.encly.core.resources.onboarding_ready_no_phrase_title +import com.pasich.encly.core.resources.onboarding_ready_phrase_desc +import com.pasich.encly.core.resources.onboarding_ready_phrase_title +import com.pasich.encly.core.resources.onboarding_ready_pin_desc +import com.pasich.encly.core.resources.onboarding_ready_pin_title +import com.pasich.encly.core.resources.onboarding_ready_restored_desc +import com.pasich.encly.core.resources.onboarding_ready_restored_title +import com.pasich.encly.core.resources.onboarding_ready_tip +import com.pasich.encly.core.resources.onboarding_ready_title +import com.pasich.encly.core.resources.onboarding_recovery_backups_desc +import com.pasich.encly.core.resources.onboarding_recovery_backups_title +import com.pasich.encly.core.resources.onboarding_recovery_body +import com.pasich.encly.core.resources.onboarding_recovery_phone_desc +import com.pasich.encly.core.resources.onboarding_recovery_phone_title +import com.pasich.encly.core.resources.onboarding_recovery_pin_desc +import com.pasich.encly.core.resources.onboarding_recovery_pin_title +import com.pasich.encly.core.resources.onboarding_recovery_title +import com.pasich.encly.core.resources.onboarding_recovery_warning +import com.pasich.encly.core.resources.onboarding_restore_file_section +import com.pasich.encly.core.resources.onboarding_restore_intro +import com.pasich.encly.core.resources.onboarding_restore_phrase_helper +import com.pasich.encly.core.resources.onboarding_restore_phrase_section +import com.pasich.encly.core.resources.onboarding_restore_pick +import com.pasich.encly.core.resources.onboarding_restore_tap_other +import com.pasich.encly.core.resources.onboarding_restore_title +import com.pasich.encly.core.resources.onboarding_verify_body +import com.pasich.encly.core.resources.onboarding_verify_title +import com.pasich.encly.core.resources.onboarding_welcome_headline +import com.pasich.encly.core.resources.onboarding_welcome_sub +import com.pasich.encly.core.resources.pin_unlock_with_fingerprint +import com.pasich.encly.core.resources.recovery_phrase_entry_label +import com.pasich.encly.presentation.designsystem.CalloutTone +import com.pasich.encly.presentation.designsystem.DoneRow +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclyCard +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyPillButton +import com.pasich.encly.presentation.designsystem.EnclySwitchRow +import com.pasich.encly.presentation.designsystem.FactRow +import com.pasich.encly.presentation.designsystem.FilePickerButton +import com.pasich.encly.presentation.designsystem.FormSection +import com.pasich.encly.presentation.designsystem.KeypadSize +import com.pasich.encly.presentation.designsystem.PinDots +import com.pasich.encly.presentation.designsystem.PinKeypad +import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput +import com.pasich.encly.presentation.designsystem.RecoveryPhraseState +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.presentation.designsystem.UseRow +import com.pasich.encly.presentation.designsystem.WordGrid +import com.pasich.encly.presentation.viewmodel.OnboardingPath +import com.pasich.encly.presentation.viewmodel.OnboardingViewModel.OnboardingUiState +import com.pasich.encly.presentation.viewmodel.SecurityType +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +/** Step 0: what Encly is, in three facts. */ +@Composable +internal fun WelcomeStep(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(StepPadding) + .padding(top = EnclyTheme.spacing.heroTop), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.heroGap), + ) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.m)) { + Text( + text = stringResource(Res.string.onboarding_welcome_headline), + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.onboarding_welcome_sub), + style = EnclyTheme.typography.heroSub, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.factGap)) { + FactRow( + EnclyIcons.UserOff, + stringResource(Res.string.onboarding_fact_account_title), + stringResource(Res.string.onboarding_fact_account_desc), + ) + FactRow( + EnclyIcons.CloudOff, + stringResource(Res.string.onboarding_fact_offline_title), + stringResource(Res.string.onboarding_fact_offline_desc), + ) + FactRow( + EnclyIcons.Shield, + stringResource(Res.string.onboarding_fact_encrypted_title), + stringResource(Res.string.onboarding_fact_encrypted_desc), + ) + } + } +} + +/** What the PIN step reports back to the ViewModel. */ +internal class PinActions( + val onDigit: (Int) -> Unit, + val onBackspace: () -> Unit, + val onBiometricChange: (Boolean) -> Unit, + val onSubmit: () -> Unit, +) + +/** + * Step 1: choose the PIN, then type it again; the biometric switch below the keypad. Digits + * from a hardware keyboard work too. + */ +@Composable +internal fun PinStep(state: OnboardingUiState, actions: PinActions, modifier: Modifier = Modifier) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + Column( + modifier = modifier + .focusRequester(focus) + .focusable() + .onKeyEvent { event -> event.type == KeyEventType.KeyDown && handleKey(event.key, actions) }, + ) { + Column( + modifier = Modifier + .padding(StepPadding) + .padding(top = EnclyTheme.spacing.m), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.stepGap), + ) { + StepHeading( + title = stringResource( + if (state.isConfirmingPin) { + Res.string.onboarding_pin_again_title + } else { + Res.string.onboarding_pin_title + }, + ), + body = stringResource( + if (state.isConfirmingPin) Res.string.onboarding_pin_again_body else Res.string.onboarding_pin_body, + ), + ) + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + PinDots(entered = state.pinLength, error = state.pinError != null) + val error = state.pinError?.asString() + if (error != null) { + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } + } + PinKeypad( + onDigit = actions.onDigit, + onBackspace = actions.onBackspace, + size = KeypadSize.SETUP, + enabled = !state.isLoading, + modifier = Modifier.padding(top = EnclyTheme.spacing.stepGap), + ) + if (state.biometricAvailable) { + BiometricCallout( + checked = state.biometricRequested, + onCheckedChange = actions.onBiometricChange, + modifier = Modifier + .padding(StepPadding) + .padding(top = EnclyTheme.spacing.factGap), + ) + } + } +} + +private fun handleKey(key: Key, actions: PinActions): Boolean { + val digit = DIGIT_KEYS.indexOf(key) + return when { + digit >= 0 -> { + actions.onDigit(digit) + true + } + + key == Key.Backspace -> { + actions.onBackspace() + true + } + + key == Key.Enter || key == Key.NumPadEnter -> { + actions.onSubmit() + true + } + + else -> false + } +} + +private val DIGIT_KEYS = listOf( + Key.Zero, Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, +) + +@Composable +private fun BiometricCallout(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = modifier.fillMaxWidth(), + ) { + EnclySwitchRow( + title = stringResource(Res.string.pin_unlock_with_fingerprint), + supporting = stringResource(Res.string.onboarding_biometric_desc), + icon = EnclyIcons.Fingerprint, + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + } +} + +/** Step 2: why a recovery phrase, and what losing it means. */ +@Composable +internal fun RecoveryInfoStep(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(StepPadding) + .padding(top = EnclyTheme.spacing.m), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.l), + ) { + StepHeading( + title = stringResource(Res.string.onboarding_recovery_title), + body = stringResource(Res.string.onboarding_recovery_body), + ) + Column { + UseRow( + EnclyIcons.Key, + stringResource(Res.string.onboarding_recovery_pin_title), + stringResource(Res.string.onboarding_recovery_pin_desc), + ) + UseRow( + EnclyIcons.File, + stringResource(Res.string.onboarding_recovery_backups_title), + stringResource(Res.string.onboarding_recovery_backups_desc), + ) + UseRow( + EnclyIcons.Phone, + stringResource(Res.string.onboarding_recovery_phone_title), + stringResource(Res.string.onboarding_recovery_phone_desc), + ) + } + EnclyCallout(text = stringResource(Res.string.onboarding_recovery_warning), tone = CalloutTone.WARNING) + } +} + +/** Step 3: the twelve words to write down. */ +@Composable +internal fun PhraseStep(state: OnboardingUiState, onToggleWords: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(StepPadding) + .padding(top = EnclyTheme.spacing.m), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.l), + ) { + StepHeading( + title = stringResource(Res.string.onboarding_phrase_title), + body = stringResource(Res.string.onboarding_phrase_body), + ) + if (state.words.isEmpty()) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) { + if (state.error == null) CircularProgressIndicator() + } + } else { + WordGrid(words = state.words, hidden = state.wordsHidden) + EnclyPillButton( + text = stringResource( + if (state.wordsHidden) Res.string.onboarding_phrase_show else Res.string.onboarding_phrase_hide, + ), + leadingIcon = if (state.wordsHidden) EnclyIcons.Eye else EnclyIcons.EyeOff, + onClick = onToggleWords, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } +} + +/** Step 3b: type three of the words back. */ +@Composable +internal fun VerifyStep(state: OnboardingUiState, onAnswer: (Int, String) -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(StepPadding) + .padding(top = EnclyTheme.spacing.m), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.stepGap), + ) { + StepHeading( + title = stringResource(Res.string.onboarding_verify_title), + body = stringResource(Res.string.onboarding_verify_body), + ) + PhraseCheckFields( + checks = state.verificationWords, + answers = state.userAnswers, + onAnswer = onAnswer, + enabled = !state.isLoading, + ) + } +} + +/** Step 4: what was set up. */ +@Composable +internal fun ReadyStep(state: OnboardingUiState, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(StepPadding) + .padding(top = EnclyTheme.spacing.readyTop), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xl), + ) { + StepHeading( + title = stringResource(Res.string.onboarding_ready_title), + body = stringResource(Res.string.onboarding_ready_body), + large = true, + ) + EnclyCard(contentPadding = PaddingValues(EnclyTheme.spacing.factGap)) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.fieldGap)) { + DoneRow( + stringResource(Res.string.onboarding_ready_pin_title), + stringResource(Res.string.onboarding_ready_pin_desc), + ) + if (state.biometricEnabled) { + DoneRow( + stringResource(Res.string.onboarding_ready_biometric_title), + stringResource(Res.string.onboarding_ready_biometric_desc), + ) + } + when { + state.path == OnboardingPath.RESTORE -> DoneRow( + stringResource(Res.string.onboarding_ready_restored_title), + stringResource(Res.string.onboarding_ready_restored_desc), + ) + + state.securityType == SecurityType.AUTO_MANAGED -> DoneRow( + stringResource(Res.string.onboarding_ready_no_phrase_title), + stringResource(Res.string.onboarding_ready_no_phrase_desc), + done = false, + ) + + else -> DoneRow( + stringResource(Res.string.onboarding_ready_phrase_title), + stringResource(Res.string.onboarding_ready_phrase_desc), + ) + } + } + } + if (state.securityType != SecurityType.AUTO_MANAGED) { + Row(horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.rowGap)) { + Icon( + EnclyIcons.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(EnclyTheme.spacing.iconMedium), + ) + Text( + text = stringResource(Res.string.onboarding_ready_tip), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** What the Restore step reports back. */ +internal class RestoreActions(val onPickFile: () -> Unit, val onPhraseEdited: () -> Unit, val onSubmit: () -> Unit) + +/** Alt: restore from a backup file and the 12 words it was made with, one numbered cell each. */ +@Composable +internal fun RestoreStep( + state: OnboardingUiState, + phrase: RecoveryPhraseState, + actions: RestoreActions, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(StepPadding) + .padding(top = EnclyTheme.spacing.m), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.l), + ) { + StepHeading( + title = stringResource(Res.string.onboarding_restore_title), + body = stringResource(Res.string.onboarding_restore_intro), + ) + FormSection(title = stringResource(Res.string.onboarding_restore_file_section)) { + FilePickerButton( + title = state.restoreFileName.takeIf { state.restoreFileReady } + ?: stringResource(Res.string.onboarding_restore_pick), + hint = if (state.restoreFileReady) stringResource(Res.string.onboarding_restore_tap_other) else null, + enabled = !state.isLoading, + onClick = actions.onPickFile, + ) + state.restoreFileError?.let { ErrorText(it.asString()) } + } + FormSection(title = stringResource(Res.string.onboarding_restore_phrase_section)) { + Text( + text = stringResource(Res.string.recovery_phrase_entry_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = EnclyTheme.spacing.xxs), + ) + RecoveryPhraseInput( + state = phrase, + enabled = !state.isLoading, + error = state.restorePhraseError?.asString(), + onEdit = actions.onPhraseEdited, + onDone = actions.onSubmit, + ) + Text( + text = stringResource(Res.string.onboarding_restore_phrase_helper), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ErrorText(text: String) { + Text(text = text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt similarity index 80% rename from app/src/main/java/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt index be40930a..4c7cc20d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt @@ -8,16 +8,21 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import com.pasich.encly.R +import androidx.compose.ui.text.style.TextDirection +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.onboarding_verify_label +import com.pasich.encly.core.resources.onboarding_verify_placeholder +import com.pasich.encly.core.resources.onboarding_verify_wrong import com.pasich.encly.presentation.designsystem.EnclyTextField import com.pasich.encly.presentation.designsystem.FieldState +import com.pasich.encly.presentation.designsystem.ProtectFromCapture import com.pasich.encly.presentation.viewmodel.AnswerState import com.pasich.encly.presentation.viewmodel.phraseAnswerState import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource /** * "Word #n" fields for typing back words of a new phrase ([checks]: 0-based position and the @@ -32,6 +37,7 @@ internal fun PhraseCheckFields( enabled: Boolean, modifier: Modifier = Modifier, ) { + ProtectFromCapture() val focusManager = LocalFocusManager.current Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.fieldGap)) { checks.forEachIndexed { position, (index, word) -> @@ -40,17 +46,17 @@ internal fun PhraseCheckFields( val answer = answers[index].orEmpty() val fieldState = when (phraseAnswerState(word, answer)) { AnswerState.CORRECT -> FieldState.Valid - AnswerState.WRONG -> FieldState.Error(stringResource(R.string.onboarding_verify_wrong, number)) + AnswerState.WRONG -> FieldState.Error(stringResource(Res.string.onboarding_verify_wrong, number)) AnswerState.EMPTY, AnswerState.TYPING -> FieldState.Default } EnclyTextField( value = answer, onValueChange = { onAnswer(index, it) }, - label = stringResource(R.string.onboarding_verify_label, number), - placeholder = stringResource(R.string.onboarding_verify_placeholder), + label = stringResource(Res.string.onboarding_verify_label, number), + placeholder = stringResource(Res.string.onboarding_verify_placeholder), state = fieldState, enabled = enabled, - textStyle = EnclyTheme.typography.dataLarge, + textStyle = EnclyTheme.typography.dataLarge.copy(textDirection = TextDirection.Ltr), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, autoCorrectEnabled = false, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt new file mode 100644 index 00000000..b4cc5563 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt @@ -0,0 +1,55 @@ +package com.pasich.encly.presentation.screen.pincode + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import com.pasich.encly.core.security.MAX_PIN_LENGTH +import com.pasich.encly.core.security.MIN_PIN_LENGTH +import com.pasich.encly.core.security.SensitiveDataCleaner + +/** + * The PIN digits typed on a keypad. They live in a CharArray that is wiped when taken or + * cleared, never in a String (a String cannot be wiped): only their count is Compose state. + */ +@Stable +internal class PinBuffer { + private val digits = CharArray(MAX_PIN_LENGTH) + + /** How many digits are typed. */ + var length by mutableIntStateOf(0) + private set + + val isFull: Boolean get() = length == MAX_PIN_LENGTH + + /** Enough digits are typed to be a PIN. Nothing is submitted by itself: the user presses the button. */ + val canSubmit: Boolean get() = length >= MIN_PIN_LENGTH + + /** Adds [digit] (0-9); ignored once the longest PIN is typed. Returns whether it was added. */ + fun add(digit: Int): Boolean { + if (length >= MAX_PIN_LENGTH || digit !in 0..MAX_DIGIT) return false + digits[length] = '0' + digit + length++ + return true + } + + fun deleteLast() { + if (length > 0) { + length-- + digits[length] = '\u0000' + } + } + + /** The typed digits, taken out of the buffer, which is cleared. The caller wipes the copy. */ + fun take(): CharArray = digits.copyOf(length).also { clear() } + + /** Forgets the typed digits. */ + fun clear() { + SensitiveDataCleaner.clear(digits) + length = 0 + } + + private companion object { + const val MAX_DIGIT = 9 + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt index 5b3dc28a..5d37386e 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt @@ -32,11 +32,13 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextAlign +import com.pasich.encly.presentation.designsystem.EnclyButton import com.pasich.encly.presentation.designsystem.EnclyIconTile import com.pasich.encly.presentation.designsystem.EnclyLogoMark import com.pasich.encly.presentation.designsystem.KeypadSize import com.pasich.encly.presentation.designsystem.PinDots import com.pasich.encly.presentation.designsystem.PinKeypad +import com.pasich.encly.presentation.designsystem.ProtectFromCapture import com.pasich.encly.presentation.effects.LocalUnlockReveal import com.pasich.encly.ui.theme.EnclyTheme @@ -87,6 +89,7 @@ fun PinEntryScaffold( footer: (@Composable ColumnScope.() -> Unit)? = null, body: @Composable ColumnScope.() -> Unit, ) { + ProtectFromCapture() val spacing = EnclyTheme.spacing Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { Column( @@ -189,8 +192,14 @@ class PinEntryActions( ) /** - * The PIN dots and the lock-size keypad. [error] turns the dots `error`; a new [shakeKey] shakes - * them. [enabled] false (a lockout) dims and disables the keys. + * The button under a PIN keypad that submits what is typed. A PIN has no fixed length, so nothing is submitted by + * itself: [enabled] is true from the shortest PIN's length on. + */ +class PinSubmit(val text: String, val enabled: Boolean, val onClick: () -> Unit) + +/** + * The PIN dots and the lock-size keypad, and the [submit] button under them. [error] turns the dots `error`; a new + * [shakeKey] shakes them. [enabled] false (a lockout) dims and disables the keys and the button. */ @Composable fun PinEntry( @@ -200,6 +209,7 @@ fun PinEntry( error: Boolean = false, shakeKey: Int = 0, enabled: Boolean = true, + submit: PinSubmit? = null, ) { Column(modifier = modifier.fillMaxWidth()) { PinDots( @@ -219,6 +229,18 @@ fun PinEntry( onBiometric = actions.onBiometric, onClear = actions.onClear, ) + if (submit != null) { + EnclyButton( + text = submit.text, + onClick = submit.onClick, + enabled = enabled && submit.enabled, + modifier = Modifier.padding( + start = EnclyTheme.spacing.gutter, + end = EnclyTheme.spacing.gutter, + top = EnclyTheme.spacing.l, + ), + ) + } } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinLockout.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinLockout.kt new file mode 100644 index 00000000..3cccce46 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinLockout.kt @@ -0,0 +1,63 @@ +package com.pasich.encly.presentation.screen.pincode + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_lockout_hours +import com.pasich.encly.core.resources.lock_lockout_minutes +import com.pasich.encly.core.resources.lock_lockout_seconds +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.PluralStringResource +import org.jetbrains.compose.resources.pluralStringResource + +private const val MILLIS_PER_SECOND = 1_000L +private const val LOCKOUT_ROUNDING_MILLIS = MILLIS_PER_SECOND - 1L + +/** Whole seconds left in a PIN lockout, rounded up so "0 s" is never shown while locked. */ +fun lockoutSecondsLeft(remainingMillis: Long): Long = + (remainingMillis.coerceAtLeast(0L) + LOCKOUT_ROUNDING_MILLIS) / MILLIS_PER_SECOND + +/** + * "Too many attempts. Try again in N seconds / minutes / hours", shared by every PIN entry. + * Rounded up, so a running lockout never reads as zero. + */ +@Composable +fun pinLockoutText(seconds: Long): String { + val (plural, count) = lockoutUnit(seconds) + return pluralStringResource(plural, count, count) +} + +/** The plural and count [pinLockoutText] shows for [seconds]. */ +internal fun lockoutUnit(seconds: Long): Pair = when { + seconds < 2 * SECONDS_PER_MINUTE -> Res.plurals.lock_lockout_seconds to seconds.toInt() + + seconds < 2 * SECONDS_PER_HOUR -> + Res.plurals.lock_lockout_minutes to ((seconds + SECONDS_PER_MINUTE - 1) / SECONDS_PER_MINUTE).toInt() + + else -> Res.plurals.lock_lockout_hours to ((seconds + SECONDS_PER_HOUR - 1) / SECONDS_PER_HOUR).toInt() +} + +private const val SECONDS_PER_MINUTE = 60L +private const val SECONDS_PER_HOUR = 3_600L + +/** + * Reports the remaining PIN lockout every second until it ends. Restarts whenever [key] + * changes: pass `seconds > 0`, so a lockout that a failed attempt just started is counted + * down too, not only one already running when the screen opened. + */ +@Composable +fun PinLockoutTicker(key: Any?, remainingMillis: () -> Long, onSecondsChange: (Long) -> Unit) { + // The loop outlives recompositions (it restarts only on [key]); always call the latest lambdas. + val currentRemainingMillis by rememberUpdatedState(remainingMillis) + val currentOnSecondsChange by rememberUpdatedState(onSecondsChange) + LaunchedEffect(key) { + while (true) { + val remaining = currentRemainingMillis() + currentOnSecondsChange(lockoutSecondsLeft(remaining)) + if (remaining <= 0) break + delay(MILLIS_PER_SECOND) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt new file mode 100644 index 00000000..64ebee8d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt @@ -0,0 +1,351 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.isSystemInDarkTheme +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.appearance_dynamic_on_note +import com.pasich.encly.core.resources.appearance_font +import com.pasich.encly.core.resources.appearance_mode +import com.pasich.encly.core.resources.appearance_mode_note_dark +import com.pasich.encly.core.resources.appearance_mode_note_light +import com.pasich.encly.core.resources.appearance_mode_note_midnight +import com.pasich.encly.core.resources.appearance_mode_note_system_dark +import com.pasich.encly.core.resources.appearance_mode_note_system_light +import com.pasich.encly.core.resources.appearance_preview_body +import com.pasich.encly.core.resources.appearance_preview_label +import com.pasich.encly.core.resources.appearance_preview_tag +import com.pasich.encly.core.resources.appearance_preview_title +import com.pasich.encly.core.resources.appearance_text_desc +import com.pasich.encly.core.resources.appearance_text_section +import com.pasich.encly.core.resources.dynamic_color +import com.pasich.encly.core.resources.dynamic_color_desc +import com.pasich.encly.core.resources.font_sample +import com.pasich.encly.core.resources.font_style_cozy +import com.pasich.encly.core.resources.font_style_cozy_desc +import com.pasich.encly.core.resources.font_style_modern +import com.pasich.encly.core.resources.font_style_modern_desc +import com.pasich.encly.core.resources.font_style_tech +import com.pasich.encly.core.resources.font_style_tech_desc +import com.pasich.encly.core.resources.palette_forest +import com.pasich.encly.core.resources.palette_graphite +import com.pasich.encly.core.resources.palette_midnight +import com.pasich.encly.core.resources.palette_ocean +import com.pasich.encly.core.resources.palette_paper +import com.pasich.encly.core.resources.settings_appearance +import com.pasich.encly.core.resources.settings_editor_toolbar +import com.pasich.encly.core.resources.settings_editor_toolbar_desc +import com.pasich.encly.core.resources.theme +import com.pasich.encly.core.resources.theme_type_dark +import com.pasich.encly.core.resources.theme_type_light +import com.pasich.encly.core.resources.theme_type_system +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 com.pasich.encly.presentation.components.FontSizeSlider +import com.pasich.encly.presentation.components.NoteLineSpacingSlider +import com.pasich.encly.presentation.designsystem.EnclyCard +import com.pasich.encly.presentation.designsystem.EnclyCardStyle +import com.pasich.encly.presentation.designsystem.EnclyGroup +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyNavigationRow +import com.pasich.encly.presentation.designsystem.EnclyRadioRow +import com.pasich.encly.presentation.designsystem.EnclyScrollColumn +import com.pasich.encly.presentation.designsystem.EnclySegmentedControl +import com.pasich.encly.presentation.designsystem.EnclySwitchRow +import com.pasich.encly.presentation.designsystem.EnclyTagPill +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.designsystem.Segment +import com.pasich.encly.presentation.designsystem.ThemeSwatch +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.viewmodel.AppearanceViewModel +import com.pasich.encly.presentation.viewmodel.ModeNote +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.ui.theme.colorSchemeFor +import com.pasich.encly.ui.theme.fontSet +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** Swatches render at this opacity while dynamic colour overrides the palette accent. */ +private const val DYNAMIC_SWATCH_ALPHA = 0.38f + +@Composable +fun AppearanceScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + viewModel: AppearanceViewModel = metroViewModel(), +) { + val settings by viewModel.themeSettings.collectAsStateWithLifecycle() + val fontSize by viewModel.defaultFontSize.collectAsStateWithLifecycle() + val lineSpacing by viewModel.defaultLineSpacing.collectAsStateWithLifecycle() + AppearanceContent( + settings = settings, + textDefaults = TextDefaults( + fontSize, + lineSpacing, + viewModel::setDefaultFontSize, + viewModel::setDefaultLineSpacing, + ), + supportsDynamicColor = viewModel.supportsDynamicColors(), + actions = AppearanceActions( + onBack = { navController.popBackStack() }, + onPalette = viewModel::selectPalette, + onMode = viewModel::selectMode, + onDynamicColor = viewModel::setDynamicColor, + onFontStyle = viewModel::selectFontStyle, + onEditorToolbar = { navController.navigate(NavRoutes.EditorToolbarRoute.name) }, + ), + modifier = modifier, + ) +} + +@Composable +private fun AppearanceContent( + settings: ThemeSettings, + textDefaults: TextDefaults, + supportsDynamicColor: Boolean, + actions: AppearanceActions, + modifier: Modifier = Modifier, +) { + val spacing = EnclyTheme.spacing + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { EnclyTopBar(title = stringResource(Res.string.settings_appearance), onBack = actions.onBack) }, + ) { padding -> + EnclyScrollColumn( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues( + start = spacing.gutter, + end = spacing.gutter, + top = spacing.s, + bottom = spacing.l, + ), + ) { + ThemeSection( + settings = settings, + dynamicActive = settings.dynamic && supportsDynamicColor, + onPalette = actions.onPalette, + ) + ModeSection(settings = settings, onMode = actions.onMode) + if (supportsDynamicColor) { + EnclySwitchRow( + title = stringResource(Res.string.dynamic_color), + supporting = stringResource(Res.string.dynamic_color_desc), + checked = settings.dynamic, + onCheckedChange = actions.onDynamicColor, + ) + } + FontSection(selected = settings.fontStyle, onFontStyle = actions.onFontStyle) + TextDefaultsSection(textDefaults) + EnclyGroup { + EnclyNavigationRow( + title = stringResource(Res.string.settings_editor_toolbar), + supporting = stringResource(Res.string.settings_editor_toolbar_desc), + icon = EnclyIcons.Checklist, + onClick = actions.onEditorToolbar, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + } + PreviewCard() + } + } +} + +/** The default text size and line spacing of notes, and how to change them. */ +private class TextDefaults( + val fontSize: Int, + val lineSpacing: NoteLineSpacing, + val onFontSize: (Int) -> Unit, + val onLineSpacing: (NoteLineSpacing) -> Unit, +) + +/** The size and spacing every note starts with; a note can set its own from its menu. */ +@Composable +private fun TextDefaultsSection(defaults: TextDefaults, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(stringResource(Res.string.appearance_text_section)) + FontSizeSlider(currentSize = defaults.fontSize, onSizeChange = defaults.onFontSize) + NoteLineSpacingSlider(selected = defaults.lineSpacing, onSelect = defaults.onLineSpacing) + Text( + text = stringResource(Res.string.appearance_text_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** What the Appearance screen can ask for; every choice is saved at once. */ +private class AppearanceActions( + val onBack: () -> Unit, + val onPalette: (ThemePalette) -> Unit, + val onMode: (ThemeType) -> Unit, + val onDynamicColor: (Boolean) -> Unit, + val onFontStyle: (FontStyleType) -> Unit, + val onEditorToolbar: () -> Unit, +) + +private fun ThemePalette.labelRes(): StringResource = when (this) { + ThemePalette.PAPER -> Res.string.palette_paper + ThemePalette.FOREST -> Res.string.palette_forest + ThemePalette.OCEAN -> Res.string.palette_ocean + ThemePalette.GRAPHITE -> Res.string.palette_graphite + ThemePalette.MIDNIGHT -> Res.string.palette_midnight +} + +@Composable +private fun ThemeSection( + settings: ThemeSettings, + dynamicActive: Boolean, + onPalette: (ThemePalette) -> Unit, + modifier: Modifier = Modifier, +) { + val dark = settings.isDark(isSystemInDarkTheme()) + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(stringResource(Res.string.theme)) + Row( + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + modifier = Modifier + .fillMaxWidth() + .selectableGroup(), + ) { + ThemePalette.entries.forEach { palette -> + ThemeSwatch( + // A miniature of the palette in the resolved mode (Midnight is always dark). + preview = colorSchemeFor(palette, dark || palette.isAlwaysDark), + label = stringResource(palette.labelRes()), + selected = palette == settings.palette, + onClick = { onPalette(palette) }, + modifier = Modifier + .weight(1f) + .alpha(if (dynamicActive) DYNAMIC_SWATCH_ALPHA else 1f), + ) + } + } + if (dynamicActive) { + Text( + text = stringResource(Res.string.appearance_dynamic_on_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ModeSection(settings: ThemeSettings, onMode: (ThemeType) -> Unit, modifier: Modifier = Modifier) { + val selectable = AppearanceViewModel.isModeSelectable(settings.palette) + val note = when (AppearanceViewModel.modeNote(settings, isSystemInDarkTheme())) { + ModeNote.SYSTEM_SHOWING_LIGHT -> Res.string.appearance_mode_note_system_light + ModeNote.SYSTEM_SHOWING_DARK -> Res.string.appearance_mode_note_system_dark + ModeNote.ALWAYS_LIGHT -> Res.string.appearance_mode_note_light + ModeNote.ALWAYS_DARK -> Res.string.appearance_mode_note_dark + ModeNote.ALWAYS_DARK_PALETTE -> Res.string.appearance_mode_note_midnight + } + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(stringResource(Res.string.appearance_mode)) + EnclySegmentedControl( + segments = listOf( + Segment(ThemeType.SYSTEM, stringResource(Res.string.theme_type_system), enabled = selectable), + Segment(ThemeType.LIGHT, stringResource(Res.string.theme_type_light), enabled = selectable), + // Dark stays tappable-looking for Midnight: it is the mode actually shown. + Segment(ThemeType.DARK, stringResource(Res.string.theme_type_dark)), + ), + selected = if (selectable) settings.type else ThemeType.DARK, + onSelect = onMode, + ) + Text( + text = stringResource(note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private data class FontOption(val style: FontStyleType, val title: StringResource, val description: StringResource) + +private val fontOptions = listOf( + FontOption(FontStyleType.COZY_EDITOR, Res.string.font_style_cozy, Res.string.font_style_cozy_desc), + FontOption(FontStyleType.MODERN_SIMPLE, Res.string.font_style_modern, Res.string.font_style_modern_desc), + FontOption(FontStyleType.TECH_MINIMAL, Res.string.font_style_tech, Res.string.font_style_tech_desc), +) + +@Composable +private fun FontSection(selected: FontStyleType, onFontStyle: (FontStyleType) -> Unit, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(stringResource(Res.string.appearance_font)) + EnclyGroup(modifier = Modifier.selectableGroup()) { + fontOptions.forEach { option -> + FontRow(option = option, selected = option.style == selected, onClick = { onFontStyle(option.style) }) + } + } + } +} + +@Composable +private fun FontRow(option: FontOption, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + EnclyRadioRow( + title = stringResource(option.title), + description = stringResource(option.description), + selected = selected, + onClick = onClick, + modifier = modifier, + leading = { + Text( + text = stringResource(Res.string.font_sample), + style = EnclyTheme.typography.fontSample.copy(fontFamily = option.style.fontSet().display), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.widthIn(min = EnclyTheme.spacing.xxl), + ) + }, + ) +} + +@Composable +private fun PreviewCard(modifier: Modifier = Modifier) { + EnclyCard(modifier = modifier, style = EnclyCardStyle.OUTLINED) { + Text(stringResource(Res.string.appearance_preview_title), style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(Res.string.appearance_preview_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + ) { + val meta = EnclyTheme.typography.meta.copy(fontWeight = FontWeight.SemiBold) + EnclyTagPill(text = stringResource(Res.string.appearance_preview_tag)) + Text( + stringResource(Res.string.appearance_preview_label), + style = meta, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/AutoLockSetting.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/AutoLockSetting.kt new file mode 100644 index 00000000..4c293647 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/AutoLockSetting.kt @@ -0,0 +1,107 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectableGroup +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.auto_lock_10m +import com.pasich.encly.core.resources.auto_lock_15m +import com.pasich.encly.core.resources.auto_lock_15s +import com.pasich.encly.core.resources.auto_lock_1m +import com.pasich.encly.core.resources.auto_lock_2m +import com.pasich.encly.core.resources.auto_lock_30m +import com.pasich.encly.core.resources.auto_lock_30s +import com.pasich.encly.core.resources.auto_lock_5m +import com.pasich.encly.core.resources.auto_lock_desc +import com.pasich.encly.core.resources.auto_lock_immediately +import com.pasich.encly.core.resources.auto_lock_long_delay_warning +import com.pasich.encly.core.resources.auto_lock_title +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.done +import com.pasich.encly.core.security.AutoLockDelay +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyNavigationRow +import com.pasich.encly.presentation.designsystem.EnclyRadioRow +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** "Lock after leaving": the current delay; a tap opens the choice. */ +@Composable +internal fun AutoLockRow(current: AutoLockDelay, onSelect: (AutoLockDelay) -> Unit) { + var choosing by rememberSaveable { mutableStateOf(false) } + EnclyNavigationRow( + title = stringResource(Res.string.auto_lock_title), + supporting = stringResource(current.label()), + icon = EnclyIcons.Lock, + onClick = { choosing = true }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + if (choosing) { + AutoLockDialog( + current = current, + onDismiss = { choosing = false }, + onConfirm = { + onSelect(it) + choosing = false + }, + ) + } +} + +@Composable +private fun AutoLockDialog(current: AutoLockDelay, onDismiss: () -> Unit, onConfirm: (AutoLockDelay) -> Unit) { + var selected by rememberSaveable { mutableStateOf(current) } + EnclyDialog( + title = stringResource(Res.string.auto_lock_title), + onDismissRequest = onDismiss, + confirm = DialogAction(stringResource(Res.string.done), { onConfirm(selected) }), + dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), + ) { + Column(modifier = Modifier.selectableGroup()) { + Text( + text = stringResource(Res.string.auto_lock_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = EnclyTheme.spacing.xs), + ) + AutoLockDelay.entries.forEach { delay -> + EnclyRadioRow( + title = stringResource(delay.label()), + selected = delay == selected, + onClick = { selected = delay }, + ) + } + if (selected == AutoLockDelay.MINUTES_15 || selected == AutoLockDelay.MINUTES_30) { + Text( + text = stringResource(Res.string.auto_lock_long_delay_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = EnclyTheme.spacing.xs), + ) + } + } + } +} + +private fun AutoLockDelay.label(): StringResource = when (this) { + AutoLockDelay.IMMEDIATELY -> Res.string.auto_lock_immediately + AutoLockDelay.SECONDS_15 -> Res.string.auto_lock_15s + AutoLockDelay.SECONDS_30 -> Res.string.auto_lock_30s + AutoLockDelay.MINUTE_1 -> Res.string.auto_lock_1m + AutoLockDelay.MINUTES_2 -> Res.string.auto_lock_2m + AutoLockDelay.MINUTES_5 -> Res.string.auto_lock_5m + AutoLockDelay.MINUTES_10 -> Res.string.auto_lock_10m + AutoLockDelay.MINUTES_15 -> Res.string.auto_lock_15m + AutoLockDelay.MINUTES_30 -> Res.string.auto_lock_30m +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt new file mode 100644 index 00000000..440bf4e5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt @@ -0,0 +1,198 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.duress_pin_change +import com.pasich.encly.core.resources.duress_pin_confirm_subtitle +import com.pasich.encly.core.resources.duress_pin_confirm_title +import com.pasich.encly.core.resources.duress_pin_current_subtitle +import com.pasich.encly.core.resources.duress_pin_failed +import com.pasich.encly.core.resources.duress_pin_intro +import com.pasich.encly.core.resources.duress_pin_limits +import com.pasich.encly.core.resources.duress_pin_new_subtitle +import com.pasich.encly.core.resources.duress_pin_new_title +import com.pasich.encly.core.resources.duress_pin_real_vault +import com.pasich.encly.core.resources.duress_pin_remove +import com.pasich.encly.core.resources.duress_pin_remove_confirm +import com.pasich.encly.core.resources.duress_pin_remove_confirm_body +import com.pasich.encly.core.resources.duress_pin_remove_confirm_title +import com.pasich.encly.core.resources.duress_pin_removed +import com.pasich.encly.core.resources.duress_pin_same_as_pin +import com.pasich.encly.core.resources.duress_pin_saved +import com.pasich.encly.core.resources.duress_pin_saved_kept +import com.pasich.encly.core.resources.duress_pin_set +import com.pasich.encly.core.resources.duress_pin_status_off +import com.pasich.encly.core.resources.duress_pin_status_on +import com.pasich.encly.core.resources.duress_pin_title +import com.pasich.encly.core.resources.duress_pin_turned_off +import com.pasich.encly.core.resources.pin_in_use +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.EnclyScrollColumn +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +private val DuressPinTexts = PinFlowTexts( + title = Res.string.duress_pin_title, + currentSubtitle = Res.string.duress_pin_current_subtitle, + newTitle = Res.string.duress_pin_new_title, + newSubtitle = Res.string.duress_pin_new_subtitle, + confirmTitle = Res.string.duress_pin_confirm_title, + confirmSubtitle = Res.string.duress_pin_confirm_subtitle, +) + +/** + * Settings → Security → Duress PIN. Same flow as the wipe PIN: the real PIN first, then what the + * duress PIN does and whether one is set, then the new PIN typed twice. In a decoy session it + * opens too, and setting or turning it off fails like a storage error (see SecurityManager). + */ +@Composable +fun DuressPinScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + securityViewModel: SecuritySettingsViewModel = metroViewModel(), +) { + 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) Res.string.duress_pin_removed else Res.string.duress_pin_failed + } + }, + ), + modifier = Modifier.padding(paddingValues), + ) + } +} + +private fun PinFlowState.onDuressPinResult(result: DuressPinChange) { + when (result) { + DuressPinChange.SET -> saved(Res.string.duress_pin_saved_kept) + DuressPinChange.SET_NEW_VAULT -> saved(Res.string.duress_pin_saved) + DuressPinChange.SAME_AS_PIN -> restartNew(Res.string.duress_pin_same_as_pin) + DuressPinChange.IN_USE -> restartNew(Res.string.pin_in_use) + DuressPinChange.FAILED -> restartNew(Res.string.duress_pin_failed) + } +} + +/** What the options page shows besides the actions; [isSet]: a decoy vault exists, live or [turnedOff]. */ +private class DuressPinInfo(val turnedOff: Boolean, val isSet: Boolean) + +private class DuressPinActions(val onSet: () -> Unit, val onRemove: () -> Unit) + +@Composable +private fun DuressPinInfo.statusText(): String = stringResource( + if (isSet && !turnedOff) Res.string.duress_pin_status_on else Res.string.duress_pin_status_off, +) + +@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 }, + ) + } + EnclyScrollColumn( + verticalArrangement = Arrangement.spacedBy(spacing.m), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = spacing.gutter, vertical = spacing.s), + ) { + if (info.turnedOff) { + EnclyCallout(text = stringResource(Res.string.duress_pin_turned_off), tone = CalloutTone.WARNING) + } + Text( + text = stringResource(Res.string.duress_pin_intro), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + EnclyCallout(text = stringResource(Res.string.duress_pin_real_vault)) + Text( + text = stringResource(Res.string.duress_pin_limits), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = info.statusText(), + 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) Res.string.duress_pin_change else Res.string.duress_pin_set), + onClick = actions.onSet, + ) + if (info.isSet) { + EnclyTextButton( + text = stringResource(Res.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(Res.string.duress_pin_remove_confirm_title), + text = stringResource(Res.string.duress_pin_remove_confirm_body), + onDismissRequest = onDismiss, + confirm = DialogAction(stringResource(Res.string.duress_pin_remove_confirm), onConfirm, destructive = true), + dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/EditorToolbarScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/EditorToolbarScreen.kt new file mode 100644 index 00000000..8895f786 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/EditorToolbarScreen.kt @@ -0,0 +1,268 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.settings_editor_toolbar +import com.pasich.encly.core.resources.toolbar_hint +import com.pasich.encly.core.resources.toolbar_move_down +import com.pasich.encly.core.resources.toolbar_move_up +import com.pasich.encly.core.resources.toolbar_reset +import com.pasich.encly.core.resources.toolbar_section_hidden +import com.pasich.encly.core.resources.toolbar_section_main +import com.pasich.encly.core.resources.toolbar_section_more +import com.pasich.encly.core.resources.toolbar_tool_hide +import com.pasich.encly.core.resources.toolbar_tool_show +import com.pasich.encly.domain.model.ToolbarLayout +import com.pasich.encly.domain.model.ToolbarSection +import com.pasich.encly.domain.model.ToolbarTool +import com.pasich.encly.presentation.components.editNote.toBlockTool +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyScrollColumn +import com.pasich.encly.presentation.designsystem.EnclyToolButton +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.viewmodel.EditorToolbarViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource +import kotlin.math.roundToInt + +/** + * Settings → Appearance → Editor toolbar: which block tools the editing toolbar offers, which of them sit behind + * its chevron, and in what order. A tool is dragged by its handle (up or down, across the section titles), and + * its eye hides it or brings it back. Every change is saved at once; "Reset" restores the toolbar as it ships. + */ +@Composable +fun EditorToolbarScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + viewModel: EditorToolbarViewModel = metroViewModel(), +) { + val layout by viewModel.layout.collectAsStateWithLifecycle() + EditorToolbarContent( + layout = layout, + actions = ToolbarActions( + onBack = { navController.popBackStack() }, + onMove = viewModel::move, + onToggle = viewModel::toggle, + onReset = viewModel::reset, + ), + modifier = modifier, + ) +} + +/** What the screen can ask for. */ +private class ToolbarActions( + val onBack: () -> Unit, + val onMove: (ToolbarTool, Int) -> Unit, + val onToggle: (ToolbarTool) -> Unit, + val onReset: () -> Unit, +) + +/** One line of the list: a section title or a tool. */ +private sealed interface ToolbarEntry { + data class Title(val section: ToolbarSection) : ToolbarEntry + + data class Tool(val tool: ToolbarTool) : ToolbarEntry +} + +private fun ToolbarLayout.entries(): List = ToolbarSection.entries.flatMap { section -> + listOf(ToolbarEntry.Title(section)) + tools(section).map { ToolbarEntry.Tool(it) } +} + +@Composable +private fun EditorToolbarContent(layout: ToolbarLayout, actions: ToolbarActions, modifier: Modifier = Modifier) { + val spacing = EnclyTheme.spacing + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar( + title = stringResource(Res.string.settings_editor_toolbar), + onBack = actions.onBack, + actions = { + TextButton(onClick = actions.onReset, enabled = layout != ToolbarLayout.DEFAULT) { + Text(stringResource(Res.string.toolbar_reset)) + } + }, + ) + }, + ) { padding -> + EnclyScrollColumn( + verticalArrangement = Arrangement.spacedBy(spacing.s), + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues( + start = spacing.gutter, + end = spacing.gutter, + top = spacing.s, + bottom = spacing.l, + ), + ) { + Text( + text = stringResource(Res.string.toolbar_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ToolList(layout, actions) + } + } +} + +/** The tools in their sections as rows of one height, so a drag of so many rows moves a tool that many places. */ +@Composable +private fun ToolList(layout: ToolbarLayout, actions: ToolbarActions) { + val rowPx = with(LocalDensity.current) { EnclyTheme.spacing.rowHeight.toPx() } + layout.entries().forEach { entry -> + when (entry) { + is ToolbarEntry.Title -> SectionTitle(entry.section) + + is ToolbarEntry.Tool -> key(entry.tool) { + ToolRow(entry.tool, hidden = entry.tool in layout.hidden, rowPx = rowPx, actions = actions) + } + } + } +} + +@Composable +private fun SectionTitle(section: ToolbarSection) { + val title = when (section) { + ToolbarSection.MAIN -> Res.string.toolbar_section_main + ToolbarSection.MORE -> Res.string.toolbar_section_more + ToolbarSection.HIDDEN -> Res.string.toolbar_section_hidden + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(EnclyTheme.spacing.rowHeight), + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = EnclyTheme.spacing.xs), + ) + } +} + +/** How far a row has been pulled by its handle, and whether it is being pulled. */ +private class DragState { + var offset by mutableFloatStateOf(0f) + var active by mutableStateOf(false) +} + +@Composable +private fun ToolRow(tool: ToolbarTool, hidden: Boolean, rowPx: Float, actions: ToolbarActions) { + val block = tool.toBlockTool() + val label = block.headingLevel?.let { stringResource(block.label, it) } ?: stringResource(block.label) + val drag = remember { DragState() } + val spacing = EnclyTheme.spacing + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.s), + modifier = Modifier + .fillMaxWidth() + .height(spacing.rowHeight) + .zIndex(if (drag.active) 1f else 0f) + .graphicsLayer { translationY = drag.offset } + .background( + if (drag.active) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surface, + MaterialTheme.shapes.medium, + ) + .moveActions(tool, actions.onMove) + .alpha(if (hidden) HIDDEN_ALPHA else 1f), + ) { + Icon( + EnclyIcons.Grip, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(spacing.iconMedium) + .dragToMove(drag, rowPx) { steps -> actions.onMove(tool, steps) }, + ) + Icon(block.icon, contentDescription = null, modifier = Modifier.size(spacing.iconMedium)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + val eye = if (hidden) Res.string.toolbar_tool_show else Res.string.toolbar_tool_hide + EnclyToolButton( + icon = if (hidden) EnclyIcons.EyeOff else EnclyIcons.Eye, + contentDescription = stringResource(eye), + onClick = { actions.onToggle(tool) }, + ) + } +} + +/** TalkBack's way to reorder: "Move up" and "Move down" on the row of [tool]. */ +@Composable +private fun Modifier.moveActions(tool: ToolbarTool, onMove: (ToolbarTool, Int) -> Unit): Modifier { + val up = stringResource(Res.string.toolbar_move_up) + val down = stringResource(Res.string.toolbar_move_down) + return semantics { + customActions = listOf( + CustomAccessibilityAction(up) { true.also { onMove(tool, -1) } }, + CustomAccessibilityAction(down) { true.also { onMove(tool, 1) } }, + ) + } +} + +/** Pulling a row by its handle: it follows the finger, and where it is let go it moves that many rows. */ +private fun Modifier.dragToMove(drag: DragState, rowPx: Float, onMove: (steps: Int) -> Unit): Modifier = + pointerInput(rowPx) { + detectDragGestures( + onDragStart = { drag.active = true }, + onDrag = { change, amount -> + change.consume() + drag.offset += amount.y + }, + onDragEnd = { + val steps = (drag.offset / rowPx).roundToInt() + drag.active = false + drag.offset = 0f + if (steps != 0) onMove(steps) + }, + onDragCancel = { + drag.active = false + drag.offset = 0f + }, + ) + } + +private const val HIDDEN_ALPHA = 0.5f diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/EncryptionStatusCard.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/EncryptionStatusCard.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/screen/settings/EncryptionStatusCard.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/EncryptionStatusCard.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/NotePreviewsSetting.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/NotePreviewsSetting.kt new file mode 100644 index 00000000..54c7a7e9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/NotePreviewsSetting.kt @@ -0,0 +1,33 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.security_previews_desc +import com.pasich.encly.core.resources.security_previews_title +import com.pasich.encly.presentation.designsystem.EnclyGroup +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclySwitchRow +import com.pasich.encly.presentation.viewmodel.NotePreviewsViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +/** "Show note previews" (see NotePreviews): on by default; off, the note lists show no text of the notes. */ +@Composable +internal fun NotePreviewsSetting(modifier: Modifier = Modifier, viewModel: NotePreviewsViewModel = metroViewModel()) { + val show by viewModel.show.collectAsState() + EnclyGroup(modifier = modifier) { + EnclySwitchRow( + title = stringResource(Res.string.security_previews_title), + supporting = stringResource(Res.string.security_previews_desc), + icon = EnclyIcons.Eye, + checked = show, + onCheckedChange = viewModel::setShow, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/PinFlow.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt similarity index 83% rename from app/src/main/java/com/pasich/encly/presentation/screen/settings/PinFlow.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt index 2f27c8e5..009bd264 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/PinFlow.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt @@ -1,7 +1,5 @@ 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.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -20,8 +18,12 @@ 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 androidx.compose.ui.backhandler.BackHandler +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.pin_change_current_title +import com.pasich.encly.core.resources.pin_current_wrong +import com.pasich.encly.core.resources.pin_mismatch_retry import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.presentation.designsystem.EnclySnackbarHost import com.pasich.encly.presentation.designsystem.EnclyTopBar @@ -30,9 +32,12 @@ 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.PinSubmit import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft import com.pasich.encly.presentation.screen.pincode.pinLockoutText import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource internal enum class PinStep { VERIFY, OPTIONS, NEW, CONFIRM } @@ -48,7 +53,7 @@ internal class PinFlowState { private var firstPin: CharArray? = null val input = PinBuffer() - var errorText by mutableStateOf(null) + var errorText by mutableStateOf(null) var lockoutSeconds by mutableLongStateOf(0L) /** Changes on every wrong entry, so the dots shake again. */ @@ -57,7 +62,7 @@ internal class PinFlowState { val snackbarHostState = SnackbarHostState() /** A finished action to announce on the options page. */ - var message by mutableStateOf(null) + var message by mutableStateOf(null) val keysEnabled: Boolean get() = !(step == PinStep.VERIFY && lockoutSeconds > 0L) @@ -91,13 +96,13 @@ internal class PinFlowState { } /** The new PIN was saved: announce [text] on the options page. */ - fun saved(@StringRes text: Int) { + fun saved(text: StringResource) { message = text step = PinStep.OPTIONS } /** The new PIN was refused: ask for another, with [error]. */ - fun restartNew(@StringRes error: Int) { + fun restartNew(error: StringResource) { errorText = error shakeKey++ step = PinStep.NEW @@ -132,7 +137,7 @@ internal class PinFlowState { } else -> { - errorText = R.string.pin_current_wrong + errorText = Res.string.pin_current_wrong shakeKey++ } } @@ -145,7 +150,7 @@ internal class PinFlowState { first?.let(SensitiveDataCleaner::clear) if (!matches) { SensitiveDataCleaner.clear(pin) - restartNew(R.string.pin_mismatch_retry) + restartNew(Res.string.pin_mismatch_retry) return } save(pin) @@ -162,12 +167,12 @@ internal class PinFlowActions( /** 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, + val title: StringResource, + val currentSubtitle: StringResource, + val newTitle: StringResource, + val newSubtitle: StringResource, + val confirmTitle: StringResource, + val confirmSubtitle: StringResource, ) /** @@ -184,9 +189,6 @@ internal fun PinFlowScaffold( ) { 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 }) @@ -202,6 +204,7 @@ internal fun PinFlowScaffold( PinFlowEntry( text = pinFlowStepText(state, texts), state = state, + onSubmit = { if (state.input.canSubmit) state.onPinComplete(actions) }, modifier = Modifier.padding(paddingValues).consumeWindowInsets(paddingValues), ) } @@ -210,7 +213,7 @@ internal fun PinFlowScaffold( /** Shows [message] once as a snackbar; [onShow] clears it. */ @Composable -private fun PinFlowMessageEffect(@StringRes message: Int?, snackbarHostState: SnackbarHostState, onShow: () -> Unit) { +private fun PinFlowMessageEffect(message: StringResource?, 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. @@ -223,7 +226,7 @@ private fun PinFlowMessageEffect(@StringRes message: Int?, snackbarHostState: Sn } /** 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?) +private class PinStepText(val title: StringResource, val subtitle: StringResource, val message: String?) @Composable private fun pinFlowStepText(state: PinFlowState, texts: PinFlowTexts): PinStepText { @@ -232,14 +235,14 @@ private fun pinFlowStepText(state: PinFlowState, texts: PinFlowTexts): PinStepTe ?.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.VERIFY -> PinStepText(Res.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) { +private fun PinFlowEntry(text: PinStepText, state: PinFlowState, onSubmit: () -> Unit, modifier: Modifier = Modifier) { val message = text.message PinEntryScaffold( title = stringResource(text.title), @@ -256,6 +259,11 @@ private fun PinFlowEntry(text: PinStepText, state: PinFlowState, modifier: Modif shakeKey = state.shakeKey, enabled = state.keysEnabled, actions = PinEntryActions(onDigit = state::typeDigit, onBackspace = state.input::deleteLast), + submit = PinSubmit( + text = stringResource(Res.string.action_continue), + enabled = state.input.canSubmit, + onClick = onSubmit, + ), ) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySections.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySections.kt new file mode 100644 index 00000000..a640720a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySections.kt @@ -0,0 +1,325 @@ +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 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.action_continue +import com.pasich.encly.core.resources.backup_needs_phrase_title +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.resources.duress_pin_row_desc +import com.pasich.encly.core.resources.duress_pin_row_turned_off +import com.pasich.encly.core.resources.duress_pin_title +import com.pasich.encly.core.resources.security_danger_section +import com.pasich.encly.core.resources.security_erase_confirm +import com.pasich.encly.core.resources.security_erase_confirm_body +import com.pasich.encly.core.resources.security_erase_confirm_title +import com.pasich.encly.core.resources.security_erase_desc +import com.pasich.encly.core.resources.security_erase_hold +import com.pasich.encly.core.resources.security_erase_title +import com.pasich.encly.core.resources.security_keyboard_strict_desc +import com.pasich.encly.core.resources.security_keyboard_strict_title +import com.pasich.encly.core.resources.security_privacy_section +import com.pasich.encly.core.resources.security_protection_key_desc +import com.pasich.encly.core.resources.security_protection_key_title +import com.pasich.encly.core.resources.security_protection_pin_desc +import com.pasich.encly.core.resources.security_protection_pin_title +import com.pasich.encly.core.resources.security_protection_section +import com.pasich.encly.core.resources.security_protection_slots_desc +import com.pasich.encly.core.resources.security_protection_slots_title +import com.pasich.encly.core.resources.security_recovery_missing +import com.pasich.encly.core.resources.security_recovery_replace_desc +import com.pasich.encly.core.resources.security_recovery_replace_title +import com.pasich.encly.core.resources.security_recovery_set_desc +import com.pasich.encly.core.resources.security_recovery_status_set +import com.pasich.encly.core.resources.security_screenshots_desc +import com.pasich.encly.core.resources.security_screenshots_title +import com.pasich.encly.core.resources.security_screenshots_warning_body +import com.pasich.encly.core.resources.security_screenshots_warning_title +import com.pasich.encly.core.resources.wipe_pin_row_desc +import com.pasich.encly.core.resources.wipe_pin_row_turned_off +import com.pasich.encly.core.resources.wipe_pin_title +import com.pasich.encly.platform.LocalKeyboardPrivacyOffered +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyDialog +import com.pasich.encly.presentation.designsystem.EnclyGroup +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 +import org.jetbrains.compose.resources.stringResource + +/** Erasing everything is held for five seconds, then re-authenticated. */ +private const val ERASE_HOLD_MS = 5_000 + +/** How Encly protects the notes: three facts in a group, not a wall of bullets. */ +@Composable +internal fun ProtectionSection(modifier: Modifier = Modifier) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), modifier = modifier) { + SectionOverline( + text = stringResource(Res.string.security_protection_section), + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + EnclyGroup { + EnclyListRow( + title = stringResource(Res.string.security_protection_key_title), + supporting = stringResource(Res.string.security_protection_key_desc), + icon = EnclyIcons.Key, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + EnclyGroupDivider() + EnclyListRow( + title = stringResource(Res.string.security_protection_pin_title), + supporting = stringResource(Res.string.security_protection_pin_desc), + icon = EnclyIcons.Shield, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + EnclyGroupDivider() + EnclyListRow( + title = stringResource(Res.string.security_protection_slots_title), + supporting = stringResource(Res.string.security_protection_slots_desc), + icon = EnclyIcons.Fingerprint, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + } + } +} + +/** + * The danger zone, last on the page: what erasing does, and a full-width `error` button held + * for five seconds. Then a warning dialog, and only after it the PIN or fingerprint ([onErase] + * starts the re-authenticated erase). + */ +@Composable +internal fun EraseSection(busy: Boolean, onErase: () -> Unit, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + var confirming by rememberSaveable { mutableStateOf(false) } + if (confirming) { + EnclyDialog( + title = stringResource(Res.string.security_erase_confirm_title), + text = stringResource(Res.string.security_erase_confirm_body), + onDismissRequest = { confirming = false }, + confirm = DialogAction( + stringResource(Res.string.security_erase_confirm), + { + confirming = false + onErase() + }, + destructive = true, + ), + dismiss = DialogAction(stringResource(Res.string.cancel), { confirming = false }), + ) + } + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), modifier = modifier) { + SectionOverline( + text = stringResource(Res.string.security_danger_section), + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + Surface( + shape = MaterialTheme.shapes.large, + color = colors.surface, + border = BorderStroke(EnclyTheme.spacing.hairline, colors.error), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), + modifier = Modifier.padding(EnclyTheme.spacing.m), + ) { + Text( + text = stringResource(Res.string.security_erase_title), + style = MaterialTheme.typography.titleMedium, + color = colors.error, + ) + Text( + text = stringResource(Res.string.security_erase_desc), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + ) + EnclyHoldToConfirmButton( + text = stringResource(Res.string.security_erase_hold), + onConfirm = { if (!busy) confirming = true }, + holdMillis = ERASE_HOLD_MS, + ) + } + } + } +} + +/** + * "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(Res.string.wipe_pin_title), + supporting = stringResource( + if (turnedOff) Res.string.wipe_pin_row_turned_off else Res.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(Res.string.duress_pin_title), + supporting = stringResource( + if (turnedOff) Res.string.duress_pin_row_turned_off else Res.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(Res.string.backup_needs_phrase_title) + val modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s) + if (hasRecoveryPhrase) { + EnclyListRow( + title = title, + supporting = stringResource(Res.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(Res.string.security_recovery_status_set), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } else { + EnclyNavigationRow( + title = title, + supporting = stringResource(Res.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(Res.string.security_recovery_replace_title), + supporting = stringResource(Res.string.security_recovery_replace_desc), + icon = EnclyIcons.Restore, + onClick = { if (!busy) onStart(BackupAction.REPLACE_PHRASE) }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} + +/** + * "Allow screenshots until the app locks": whether it is [allowed] now, [onTurnOn] to start it (after the + * warning; the caller re-authenticates) and [onTurnOff]. + */ +internal class ScreenshotsSetting(val allowed: Boolean, val onTurnOn: () -> Unit, val onTurnOff: () -> Unit) + +/** + * Privacy options that are not about unlocking: strict keyboard privacy (off by default), and, where the host + * offers it ([screenshots]), the temporary permission to take screenshots. + */ +@Composable +internal fun PrivacySection( + strictKeyboard: Boolean, + onStrictKeyboard: (Boolean) -> Unit, + screenshots: ScreenshotsSetting? = null, +) { + // A host with no on-screen keyboard to restrain (the desktop) offers neither, and shows no empty section. + val keyboardOffered = LocalKeyboardPrivacyOffered.current + if (!keyboardOffered && screenshots == null) return + SectionOverline( + text = stringResource(Res.string.security_privacy_section), + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + EnclyGroup { + if (keyboardOffered) { + EnclySwitchRow( + title = stringResource(Res.string.security_keyboard_strict_title), + supporting = stringResource(Res.string.security_keyboard_strict_desc), + icon = EnclyIcons.Keyboard, + checked = strictKeyboard, + onCheckedChange = onStrictKeyboard, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + } + if (screenshots != null) { + if (keyboardOffered) EnclyGroupDivider() + ScreenshotsRow(screenshots) + } + } +} + +/** The switch for [ScreenshotsSetting]: turning it on first shows what screenshots leave behind. */ +@Composable +private fun ScreenshotsRow(setting: ScreenshotsSetting) { + var warning by rememberSaveable { mutableStateOf(false) } + if (warning) { + EnclyDialog( + title = stringResource(Res.string.security_screenshots_warning_title), + text = stringResource(Res.string.security_screenshots_warning_body), + onDismissRequest = { warning = false }, + confirm = DialogAction( + stringResource(Res.string.action_continue), + { + warning = false + setting.onTurnOn() + }, + ), + dismiss = DialogAction(stringResource(Res.string.cancel), { warning = false }), + ) + } + EnclySwitchRow( + title = stringResource(Res.string.security_screenshots_title), + supporting = stringResource(Res.string.security_screenshots_desc), + icon = EnclyIcons.Eye, + checked = setting.allowed, + onCheckedChange = { on -> if (on) warning = true else setting.onTurnOff() }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt new file mode 100644 index 00000000..6212ffc2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt @@ -0,0 +1,303 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.core.common.UiText +import com.pasich.encly.core.common.resolve +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.auth_method_pin_desc +import com.pasich.encly.core.resources.auth_method_pin_title +import com.pasich.encly.core.resources.auth_title +import com.pasich.encly.core.resources.security_biometric_available +import com.pasich.encly.core.resources.security_biometric_not_enrolled +import com.pasich.encly.core.resources.security_biometric_title +import com.pasich.encly.core.resources.security_biometric_unavailable +import com.pasich.encly.core.resources.security_encryption_active +import com.pasich.encly.core.resources.security_encryption_pin_plain +import com.pasich.encly.core.resources.security_encryption_seed_plain +import com.pasich.encly.core.resources.security_title +import com.pasich.encly.core.security.AuthType +import com.pasich.encly.core.security.AutoLockDelay +import com.pasich.encly.core.security.BiometricStatus +import com.pasich.encly.platform.LocalBiometricHost +import com.pasich.encly.platform.LocalScreenshotAllowance +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.EnclyNavigationRow +import com.pasich.encly.presentation.designsystem.EnclyScrollColumn +import com.pasich.encly.presentation.designsystem.EnclySnackbarHost +import com.pasich.encly.presentation.designsystem.EnclySwitchRow +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.backup.BackupDialogs +import com.pasich.encly.presentation.screen.backup.rememberBackupDialogActions +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.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Composable +fun SecuritySettingsScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + securityViewModel: SecuritySettingsViewModel = metroViewModel(), + vaultViewModel: BackupViewModel = metroViewModel(), +) { + val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() + val options = rememberSecurityOptions(securityViewModel, vaultViewModel) + val vaultState by vaultViewModel.uiState.collectAsStateWithLifecycle() + val host = LocalBiometricHost.current + + val snackbarHostState = remember { SnackbarHostState() } + val dialogActions = rememberBackupDialogActions(vaultViewModel) + val effectActions = remember(navController, securityViewModel, vaultViewModel) { + SecurityEffectActions( + onVaultMessageShown = { + vaultViewModel.clearMessage() + securityViewModel.refresh() + }, + onVaultErased = { + navController.navigate(NavRoutes.OnboardingRoute.name) { + popUpTo(0) { inclusive = true } + } + }, + onErrorShown = securityViewModel::clearError, + ) + } + SecurityScreenEffects(vaultState.message, vaultState.erased, securityState.error, snackbarHostState, effectActions) + + Box(modifier = modifier.fillMaxSize()) { + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar(title = stringResource(Res.string.security_title), onBack = { + navController.popBackStack() + }) + }, + snackbarHost = { EnclySnackbarHost(snackbarHostState) }, + ) { paddingValues -> + // Nothing until the first load: no PIN text that turns into the phrase text. + if (securityState.loaded) { + SecurityContent( + securityState = securityState, + options = options, + vaultBusy = vaultState.busy, + actions = SecurityActions( + onOpen = { route -> navController.navigate(route.name) }, + onBiometric = { enabled -> + if (host != null) securityViewModel.toggleBiometric(host, enabled) + }, + onVaultAction = vaultViewModel::start, + onStrictKeyboard = securityViewModel::setStrictKeyboard, + onAutoLock = securityViewModel::setAutoLockDelay, + ), + modifier = Modifier.padding(paddingValues), + ) + } + } + // Re-authentication, the new-phrase steps and the erase confirmation; the phrase steps + // cover the whole screen, so they are drawn last. + BackupDialogs(dialogActions, vaultState.step, vaultState.busy) + } +} + +/** What the security page can start. */ +private class SecurityActions( + val onOpen: (NavRoutes) -> Unit, + val onBiometric: (Boolean) -> Unit, + val onVaultAction: (BackupAction) -> Unit, + val onStrictKeyboard: (Boolean) -> Unit, + val onAutoLock: (AutoLockDelay) -> Unit, +) + +/** The page's own preferences: strict keyboard privacy, the auto-lock delay and the screenshots allowance. */ +private class SecurityOptions( + val strictKeyboard: Boolean, + val autoLock: AutoLockDelay, + val screenshots: ScreenshotsSetting?, +) + +@Composable +private fun rememberSecurityOptions( + securityViewModel: SecuritySettingsViewModel, + vaultViewModel: BackupViewModel, +): SecurityOptions { + val strictKeyboard by securityViewModel.strictKeyboard.collectAsStateWithLifecycle() + val autoLockDelay by securityViewModel.autoLockDelay.collectAsStateWithLifecycle() + val screenshotsAllowed by securityViewModel.screenshotsAllowed.collectAsStateWithLifecycle() + // Only where the host can lift capture protection at all (Android); elsewhere the row is not shown. + val screenshotsOffered = LocalScreenshotAllowance.current != null + return SecurityOptions( + strictKeyboard = strictKeyboard, + autoLock = autoLockDelay, + screenshots = ScreenshotsSetting( + allowed = screenshotsAllowed, + onTurnOn = { vaultViewModel.start(BackupAction.ALLOW_SCREENSHOTS) }, + onTurnOff = securityViewModel::stopAllowingScreenshots, + ).takeIf { screenshotsOffered }, + ) +} + +@Composable +private fun SecurityContent( + securityState: SecuritySettingsViewModel.SecuritySettingsUiState, + options: SecurityOptions, + vaultBusy: Boolean, + actions: SecurityActions, + modifier: Modifier = Modifier, +) { + EnclyScrollColumn( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + ) { + SecurityRows(securityState, options, vaultBusy, actions) + } +} + +@Composable +private fun SecurityRows( + securityState: SecuritySettingsViewModel.SecuritySettingsUiState, + options: SecurityOptions, + vaultBusy: Boolean, + actions: SecurityActions, +) { + EncryptionStatusCard( + title = stringResource(Res.string.security_encryption_active), + text = stringResource( + if (securityState.isUserCreatedSeedKey) { + Res.string.security_encryption_seed_plain + } else { + Res.string.security_encryption_pin_plain + }, + ), + ) + SectionOverline( + text = stringResource(Res.string.auth_title), + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + EnclyGroup { + EnclyNavigationRow( + title = stringResource(Res.string.auth_method_pin_title), + supporting = stringResource(Res.string.auth_method_pin_desc), + icon = EnclyIcons.Key, + onClick = { actions.onOpen(NavRoutes.PinCodeConfig) }, + 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.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, + screenshots = options.screenshots, + ) + NotePreviewsSetting() + ProtectionSection() + EraseSection(busy = vaultBusy, onErase = { actions.onVaultAction(BackupAction.ERASE) }) +} + +private class SecurityEffectActions( + val onVaultMessageShown: () -> Unit, + val onVaultErased: () -> Unit, + val onErrorShown: () -> Unit, +) + +/** Messages and the end of the vault flows (a new recovery phrase, an erased vault), and errors. */ +@Composable +private fun SecurityScreenEffects( + vaultMessage: BackupMessage?, + vaultErased: Boolean, + securityError: UiText?, + snackbarHostState: SnackbarHostState, + actions: SecurityEffectActions, +) { + 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, vaultMessageText) { + val message = vaultMessageText ?: return@LaunchedEffect + actions.onVaultMessageShown() + snackbarScope.launch { snackbarHostState.showSnackbar(message) } + } + + LaunchedEffect(vaultErased) { + if (vaultErased) actions.onVaultErased() + } + + LaunchedEffect(securityError) { + val errorMessage = securityError ?: return@LaunchedEffect + actions.onErrorShown() + snackbarScope.launch { snackbarHostState.showSnackbar(errorMessage.resolve()) } + } +} + +@Composable +private fun BiometricSetting( + securityState: SecuritySettingsViewModel.SecuritySettingsUiState, + onToggle: (Boolean) -> Unit, +) { + EnclySwitchRow( + title = stringResource(Res.string.security_biometric_title), + supporting = stringResource( + when (securityState.biometricStatus) { + BiometricStatus.AVAILABLE -> Res.string.security_biometric_available + + // The sensor is there: say what is missing, not that the device cannot do it. + BiometricStatus.NOT_ENROLLED -> Res.string.security_biometric_not_enrolled + + BiometricStatus.UNAVAILABLE -> Res.string.security_biometric_unavailable + }, + ), + icon = EnclyIcons.Fingerprint, + checked = securityState.biometricEnable, + enabled = securityState.isBiometricAvailable, + onCheckedChange = onToggle, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt index 9b62bc3d..574fdbe1 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt @@ -1,11 +1,14 @@ package com.pasich.encly.presentation.screen.settings import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -13,29 +16,33 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.navigation.NavHostController -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.main_drawer_settings +import com.pasich.encly.core.resources.settings_backup +import com.pasich.encly.core.resources.settings_privacy import com.pasich.encly.domain.model.SwipeSettings import com.pasich.encly.domain.model.SwipeSide import com.pasich.encly.presentation.components.settings.SettingsBuilder import com.pasich.encly.presentation.components.settings.SettingsCategory import com.pasich.encly.presentation.components.settings.SettingsCategoryRenderer import com.pasich.encly.presentation.components.settings.SettingsNavigation +import com.pasich.encly.presentation.designsystem.EnclyScrollbar import com.pasich.encly.presentation.designsystem.EnclyTopBar import com.pasich.encly.presentation.dialogs.LanguageDialog import com.pasich.encly.presentation.dialogs.SwipeActionDialog import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.viewmodel.SettingsEvent import com.pasich.encly.presentation.viewmodel.SettingsViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource @Composable fun SettingsScreen( navController: NavHostController?, modifier: Modifier = Modifier, - settingViewState: SettingsViewModel = enclyViewModel(), + settingViewState: SettingsViewModel = metroViewModel(), securityDisable: Boolean = false, ) { val showTasks by settingViewState.showTasksFlow.collectAsState() @@ -67,7 +74,7 @@ fun SettingsScreen( containerColor = MaterialTheme.colorScheme.surface, topBar = { EnclyTopBar( - title = stringResource(R.string.main_drawer_settings), + title = stringResource(Res.string.main_drawer_settings), onBack = { navController?.popBackStack() }, ) }, @@ -102,8 +109,18 @@ fun SettingsScreen( @Composable private fun SettingsList(categories: List, securityDisable: Boolean, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + Box(modifier = modifier.fillMaxSize()) { + SettingsItems(categories, securityDisable, listState) + EnclyScrollbar(listState) + } +} + +@Composable +private fun SettingsItems(categories: List, securityDisable: Boolean, state: LazyListState) { LazyColumn( - modifier = modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), + state = state, contentPadding = PaddingValues( start = EnclyTheme.spacing.gutter, end = EnclyTheme.spacing.gutter, @@ -113,8 +130,8 @@ private fun SettingsList(categories: List, securityDisable: Bo verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), ) { items(categories) { category -> - val securityCategory = category.titleRes == R.string.settings_privacy || - category.titleRes == R.string.settings_backup + val securityCategory = category.titleRes == Res.string.settings_privacy || + category.titleRes == Res.string.settings_backup if (!(securityCategory && securityDisable)) SettingsCategoryRenderer(category = category) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt new file mode 100644 index 00000000..71ae3463 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt @@ -0,0 +1,168 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.pin_in_use +import com.pasich.encly.core.resources.wipe_pin_after_erase +import com.pasich.encly.core.resources.wipe_pin_backups +import com.pasich.encly.core.resources.wipe_pin_biometric_off +import com.pasich.encly.core.resources.wipe_pin_biometric_warning +import com.pasich.encly.core.resources.wipe_pin_confirm_subtitle +import com.pasich.encly.core.resources.wipe_pin_confirm_title +import com.pasich.encly.core.resources.wipe_pin_current_subtitle +import com.pasich.encly.core.resources.wipe_pin_failed +import com.pasich.encly.core.resources.wipe_pin_intro +import com.pasich.encly.core.resources.wipe_pin_limits +import com.pasich.encly.core.resources.wipe_pin_new_subtitle +import com.pasich.encly.core.resources.wipe_pin_new_title +import com.pasich.encly.core.resources.wipe_pin_remove +import com.pasich.encly.core.resources.wipe_pin_removed +import com.pasich.encly.core.resources.wipe_pin_same_as_pin +import com.pasich.encly.core.resources.wipe_pin_saved +import com.pasich.encly.core.resources.wipe_pin_set +import com.pasich.encly.core.resources.wipe_pin_title +import com.pasich.encly.core.resources.wipe_pin_turned_off +import com.pasich.encly.core.security.WipePinChange +import com.pasich.encly.platform.LocalBiometricHost +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.EnclyScrollColumn +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +private val WipePinTexts = PinFlowTexts( + title = Res.string.wipe_pin_title, + currentSubtitle = Res.string.wipe_pin_current_subtitle, + newTitle = Res.string.wipe_pin_new_title, + newSubtitle = Res.string.wipe_pin_new_subtitle, + confirmTitle = Res.string.wipe_pin_confirm_title, + confirmSubtitle = Res.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). + */ +@Composable +fun WipePinScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + securityViewModel: SecuritySettingsViewModel = metroViewModel(), +) { + val state = remember { PinFlowState() } + val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() + val host = LocalBiometricHost.current + val flowActions = remember(state, securityViewModel) { + PinFlowActions( + verify = securityViewModel::verifyCurrentPin, + lockoutMillis = securityViewModel::pinLockoutRemainingMillis, + save = { pin -> securityViewModel.setWipePin(pin) { result -> state.onWipePinResult(result) } }, + close = { navController.popBackStack() }, + ) + } + + PinFlowScaffold( + state = state, + texts = WipePinTexts, + actions = flowActions, + modifier = modifier, + ) { paddingValues -> + WipePinOptions( + info = WipePinInfo( + turnedOff = securityState.wipePinTurnedOff, + biometricOn = securityState.biometricEnable, + ), + actions = WipePinActions( + onSet = state::startNew, + onRemove = { + securityViewModel.removeWipePin { ok -> + state.message = if (ok) Res.string.wipe_pin_removed else Res.string.wipe_pin_failed + } + }, + onBiometricOff = { + if (host != null) securityViewModel.toggleBiometric(host, enable = false) + }, + ), + modifier = Modifier.padding(paddingValues), + ) + } +} + +private fun PinFlowState.onWipePinResult(result: WipePinChange) { + when (result) { + WipePinChange.SET -> saved(Res.string.wipe_pin_saved) + WipePinChange.SAME_AS_PIN -> restartNew(Res.string.wipe_pin_same_as_pin) + WipePinChange.IN_USE -> restartNew(Res.string.pin_in_use) + WipePinChange.FAILED -> restartNew(Res.string.wipe_pin_failed) + } +} + +/** What the options page shows besides the actions. */ +private class WipePinInfo(val turnedOff: Boolean, val biometricOn: Boolean) + +private class WipePinActions(val onSet: () -> Unit, val onRemove: () -> Unit, val onBiometricOff: () -> Unit) + +@Composable +private fun WipePinOptions(info: WipePinInfo, actions: WipePinActions, modifier: Modifier = Modifier) { + val spacing = EnclyTheme.spacing + EnclyScrollColumn( + verticalArrangement = Arrangement.spacedBy(spacing.m), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = spacing.gutter, vertical = spacing.s), + ) { + if (info.turnedOff) { + EnclyCallout(text = stringResource(Res.string.wipe_pin_turned_off), tone = CalloutTone.WARNING) + } + Text( + text = stringResource(Res.string.wipe_pin_intro), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + EnclyCallout(text = stringResource(Res.string.wipe_pin_after_erase), tone = CalloutTone.WARNING) + EnclyCallout(text = stringResource(Res.string.wipe_pin_backups)) + if (info.biometricOn) { + Column(verticalArrangement = Arrangement.spacedBy(spacing.xxs)) { + EnclyCallout(text = stringResource(Res.string.wipe_pin_biometric_warning), tone = CalloutTone.WARNING) + EnclyTextButton( + text = stringResource(Res.string.wipe_pin_biometric_off), + onClick = actions.onBiometricOff, + ) + } + } + Text( + text = stringResource(Res.string.wipe_pin_limits), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column( + verticalArrangement = Arrangement.spacedBy(spacing.xxs), + modifier = Modifier.fillMaxWidth().padding(top = spacing.s), + ) { + EnclyButton(text = stringResource(Res.string.wipe_pin_set), onClick = actions.onSet) + EnclyTextButton( + text = stringResource(Res.string.wipe_pin_remove), + onClick = actions.onRemove, + destructive = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt new file mode 100644 index 00000000..525cebdb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt @@ -0,0 +1,137 @@ +package com.pasich.encly.presentation.screen.trash + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pasich.encly.core.common.LoadState +import com.pasich.encly.core.common.asString +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.empty_trash_desc +import com.pasich.encly.core.resources.empty_trash_title +import com.pasich.encly.core.resources.trash_auto_delete_hint +import com.pasich.encly.core.resources.trash_hint +import com.pasich.encly.data.model.Note +import com.pasich.encly.presentation.components.tiles.NoteItem +import com.pasich.encly.presentation.designsystem.EnclyEmptyState +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.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.stringResource + +private const val SKELETON_CARDS = 3 + +/** + * The trashed notes: a skeleton while they load, "Trash is empty" or the read error, else the + * dimmed cards under a hint that a long-press selects and one that the trash empties itself after 28 days. + * Tapping a card opens it read-only, or toggles it while a selection is open. + */ +@Composable +fun TrashNotesList( + onItemClick: (Int, Note) -> Unit, + modifier: Modifier = Modifier, + trashViewModel: TrashViewModel = metroViewModel(), +) { + val listScrollState = rememberLazyListState() + val state by trashViewModel.state.collectAsStateWithLifecycle() + val stableNotes = remember(state.notes) { + state.notes.mapIndexed { index, note -> StableTrashNoteItem(note, index) } + } + val spacing = EnclyTheme.spacing + + LazyColumn( + modifier = modifier.fillMaxSize(), + state = listScrollState, + contentPadding = PaddingValues(top = spacing.xs, bottom = spacing.l), + verticalArrangement = Arrangement.spacedBy(spacing.cardGap), + ) { + val load = state.notesLoad + when { + load is LoadState.Loading -> items(SKELETON_CARDS) { + NoteSkeleton(modifier = Modifier.padding(horizontal = spacing.listGutter)) + } + + // A failed read is never shown as an empty trash. + load is LoadState.Failed -> item { + EnclyEmptyState( + icon = EnclyIcons.Trash, + title = load.error.title.asString(), + body = load.error.message.asString(), + error = true, + ) + } + + stableNotes.isEmpty() -> item { + EnclyEmptyState( + icon = EnclyIcons.Trash, + title = stringResource(Res.string.empty_trash_title), + body = stringResource(Res.string.empty_trash_desc), + ) + } + + else -> trashCards(stableNotes, state.canCheck, onItemClick) { index -> + trashViewModel.onEvent(TrashListEvent.ToggleCheckItem(index)) + } + } + } +} + +/** The hint and the dimmed cards; [onToggle] selects or unselects the card at an index. */ +private fun LazyListScope.trashCards( + notes: List, + selecting: Boolean, + onOpen: (Int, Note) -> Unit, + onToggle: (Int) -> Unit, +) { + item(key = "hint") { + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.listGutter), + ) { + Text( + text = stringResource(Res.string.trash_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.trash_auto_delete_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + items(items = notes, key = { it.id }) { stableItem -> + NoteItem( + itemNote = stableItem.note, + dimmed = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = EnclyTheme.spacing.listGutter) + .animateItem(), + onItemClick = { + if (selecting) onToggle(stableItem.index) else onOpen(stableItem.index, stableItem.note) + }, + onItemLongClick = { if (!selecting) onToggle(stableItem.index) }, + ) + } +} + +@Stable +private data class StableTrashNoteItem(val note: Note, val index: Int, val id: Long = note.id) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/trash/TrashScreen.kt similarity index 78% rename from app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/trash/TrashScreen.kt index 66fa7c97..3bf70e8c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/trash/TrashScreen.kt @@ -1,6 +1,5 @@ package com.pasich.encly.presentation.screen.trash -import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -12,11 +11,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.backhandler.BackHandler import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.clean_permanent_message +import com.pasich.encly.core.resources.clean_trash_message +import com.pasich.encly.core.resources.clean_trash_title +import com.pasich.encly.core.resources.delete_forever +import com.pasich.encly.core.resources.delete_notes_confirm +import com.pasich.encly.core.resources.empty_trash_action +import com.pasich.encly.core.resources.main_drawer_trash +import com.pasich.encly.core.resources.restore +import com.pasich.encly.core.resources.trash_selected import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclyOverflowMenu import com.pasich.encly.presentation.designsystem.EnclyTopBar @@ -26,7 +33,9 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource enum class DialogTrashAction { DELETE_PERMANENTLY, @@ -38,7 +47,7 @@ enum class DialogTrashAction { fun TrashScreen( navController: NavHostController, modifier: Modifier = Modifier, - trashViewModel: TrashViewModel = enclyViewModel(), + trashViewModel: TrashViewModel = metroViewModel(), ) { val state by trashViewModel.state.collectAsStateWithLifecycle() var isDialogVisible by remember { mutableStateOf(DialogTrashAction.DISABLE) } @@ -106,24 +115,24 @@ private fun TrashTopBar(checkedCount: Int?, hasNotes: Boolean, actions: TrashBar val selecting = checkedCount != null EnclyTopBar( title = if (checkedCount != null) { - pluralStringResource(R.plurals.trash_selected, checkedCount, checkedCount) + pluralStringResource(Res.plurals.trash_selected, checkedCount, checkedCount) } else { - stringResource(R.string.main_drawer_trash) + stringResource(Res.string.main_drawer_trash) }, onBack = actions.onBack.takeUnless { selecting }, onClose = actions.onClearSelection.takeIf { selecting }, actions = { AnimatedVisibility(selecting) { - IconWithTooltip(EnclyIcons.Restore, stringResource(R.string.restore), actions.onRestore) + IconWithTooltip(EnclyIcons.Restore, stringResource(Res.string.restore), actions.onRestore) } AnimatedVisibility(selecting) { - IconWithTooltip(EnclyIcons.Trash, stringResource(R.string.delete_forever), actions.onDeleteSelected) + IconWithTooltip(EnclyIcons.Trash, stringResource(Res.string.delete_forever), actions.onDeleteSelected) } if (!selecting && hasNotes) { EnclyOverflowMenu( items = listOf( OverflowAction( - text = stringResource(R.string.empty_trash_action), + text = stringResource(Res.string.empty_trash_action), onClick = actions.onEmptyTrash, destructive = true, ), @@ -146,11 +155,13 @@ private fun TrashConfirmDialog( ConfirmDialog( isVisible = action != DialogTrashAction.DISABLE, titleText = if (cleanAll) { - stringResource(R.string.clean_trash_title) + stringResource(Res.string.clean_trash_title) } else { - pluralStringResource(R.plurals.delete_notes_confirm, checkedCount, checkedCount) + pluralStringResource(Res.plurals.delete_notes_confirm, checkedCount, checkedCount) }, - messageText = stringResource(if (cleanAll) R.string.clean_trash_message else R.string.clean_permanent_message), + messageText = stringResource( + if (cleanAll) Res.string.clean_trash_message else Res.string.clean_permanent_message, + ), onConfirm = { onConfirm(action) }, onDismiss = onDismiss, destructive = true, diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt similarity index 92% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt index 3406ccf8..90959fb0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt @@ -9,19 +9,24 @@ import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.domain.model.ThemeType import com.pasich.encly.domain.repository.SettingsRepository import com.pasich.encly.utils.DeviceCapabilities -import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject /** * Settings → Appearance: palette, mode, dynamic colour and font. Every change is written to * DataStore at once; the theme (ThemeViewModel) observes the same flow, so the app restyles live. */ -@HiltViewModel -class AppearanceViewModel @Inject constructor( +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class AppearanceViewModel( private val settingsRepository: SettingsRepository, private val deviceCapabilities: DeviceCapabilities, ) : ViewModel() { diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt index 18b1d17c..880bbe23 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt @@ -16,22 +16,27 @@ import com.pasich.encly.domain.usecase.note.UpdateNoteColorUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteDescriptionUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTagUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject /** * The archive: archived notes that are not in the trash, newest edit first. It acts on a note * with the same [NoteListEvent]s as the main list, minus the ones that do not apply here (a * tag chip, the sort order, pinning: an archived note is never pinned). */ -@HiltViewModel -class ArchiveViewModel @Inject constructor( +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class ArchiveViewModel( private val observeNotesUseCase: ObserveNotesUseCase, private val updateNoteTrashStatusUseCase: UpdateNoteTrashStatusUseCase, private val updateNoteArchiveStatusUseCase: UpdateNoteArchiveStatusUseCase, @@ -71,7 +76,7 @@ class ArchiveViewModel @Inject constructor( private suspend fun execute(event: NoteListEvent): Boolean = when (event) { is NoteListEvent.ArchiveNote -> updateNoteArchiveStatusUseCase(event.note, event.archived).isSuccess is NoteListEvent.NoteToTrash -> updateNoteTrashStatusUseCase(event.note, true).isSuccess - is NoteListEvent.ChangeTag -> updateNoteTagUseCase(event.note, event.idTag).isSuccess + is NoteListEvent.ChangeTag -> updateNoteTagUseCase(event.note, event.idTag, event.tagged).isSuccess is NoteListEvent.ChangeDescription -> updateNoteDescriptionUseCase(event.note, event.description).isSuccess is NoteListEvent.ChangeColor -> updateNoteColorUseCase(event.note, event.color).isSuccess is NoteListEvent.SelectTag, is NoteListEvent.ToggleNoteSort, is NoteListEvent.PinNote -> false diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt similarity index 90% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt index 2a940b4e..b40155a8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt @@ -2,11 +2,15 @@ package com.pasich.encly.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.di.platformIoDispatcher import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.data.backup.PendingRestore -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -14,14 +18,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject /** * The last step of first-run setup: onboarding has created the vault and set its PIN (and * biometric) slots; this opens it, imports a backup staged by "Restore from backup" and commits. */ -@HiltViewModel -class AuthSetupViewModel @Inject constructor( +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class AuthSetupViewModel( private val securityManager: SecurityManager, private val sessionLockManager: SessionLockManager, private val pendingRestore: PendingRestore, @@ -56,13 +61,14 @@ class AuthSetupViewModel @Inject constructor( fun finishSetup(onResult: (FinishResult) -> Unit) { _restoreFailed.value = false _finishFailed.value = false + val lockRequests = sessionLockManager.lockRequests() viewModelScope.launch { val owner = coroutineContext.job _busy.value = true // Runs to its end even if this ViewModel is cleared meanwhile: a vault opened for // a screen that no longer exists is closed again instead of staying open. val (outcome, published) = withContext(NonCancellable) { - val outcome = withContext(Dispatchers.IO) { openRestoreAndCommit() } + val outcome = withContext(platformIoDispatcher) { openRestoreAndCommit() } val committed = outcome == SetupOutcome.COMMITTED val published = when { !committed -> false @@ -72,7 +78,7 @@ class AuthSetupViewModel @Inject constructor( false } - else -> sessionLockManager.onUnlocked() + else -> sessionLockManager.onUnlocked(lockRequests) } outcome to published } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt similarity index 82% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index c04a3e95..2db29eba 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt @@ -1,13 +1,19 @@ package com.pasich.encly.presentation.viewmodel -import android.net.Uri -import androidx.annotation.StringRes -import androidx.fragment.app.FragmentActivity -import com.pasich.encly.R import com.pasich.encly.core.backup.BackupCipher import com.pasich.encly.core.backup.BackupError import com.pasich.encly.core.backup.BackupException import com.pasich.encly.core.backup.BackupPayload +import com.pasich.encly.core.di.platformIoDispatcher +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_phrase_mismatch +import com.pasich.encly.core.resources.backup_error_phrase_setup +import com.pasich.encly.core.resources.backup_export_done +import com.pasich.encly.core.resources.backup_phrase_added +import com.pasich.encly.core.resources.backup_phrase_check_wrong +import com.pasich.encly.core.resources.biometric_change_not_confirmed +import com.pasich.encly.core.resources.lock_wrong_pin +import com.pasich.encly.core.resources.security_recovery_replaced import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.data.backup.BackupDocuments @@ -15,6 +21,8 @@ import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup import com.pasich.encly.data.backup.ImportMode import com.pasich.encly.data.backup.ImportSummary +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.ScreenBiometricHost import com.pasich.encly.presentation.screen.backup.backupErrorMessage import com.pasich.encly.presentation.screen.backup.backupExportErrorMessage import kotlinx.coroutines.CoroutineScope @@ -24,14 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.StringResource /** * What a re-authenticated flow goes on to do. [CREATE_PHRASE], [REPLACE_PHRASE] and [ERASE] * start from Settings → Security; they share the re-authentication and phrase steps with * backups. [EXPORT_MARKDOWN] is the plaintext export, run by [MarkdownExportViewModel] (it needs - * no recovery phrase and writes no encrypted backup). + * no recovery phrase and writes no encrypted backup). [ALLOW_SCREENSHOTS] is "Allow screenshots until the app + * locks": the re-authentication is the whole flow, then the allowance is on. */ -enum class BackupAction { EXPORT, EXPORT_MARKDOWN, IMPORT, CREATE_PHRASE, REPLACE_PHRASE, ERASE } +enum class BackupAction { EXPORT, EXPORT_MARKDOWN, IMPORT, CREATE_PHRASE, REPLACE_PHRASE, ERASE, ALLOW_SCREENSHOTS } /** Where a backup or vault flow currently is. Each step is one dialog or system picker. */ sealed interface BackupStep { @@ -44,7 +54,7 @@ sealed interface BackupStep { data class Reauth( val action: BackupAction, val biometric: Boolean, - @param:StringRes val error: Int? = null, + val error: StringResource? = null, val failures: Int = 0, ) : BackupStep @@ -67,23 +77,29 @@ sealed interface BackupStep { data class CheckNewPhrase( val positions: List, val expected: List, - @param:StringRes val error: Int? = null, + val error: StringResource? = null, ) : BackupStep /** A recovery-seed vault without a backup key must type its words once. */ - data class ConfirmExistingPhrase(@param:StringRes val error: Int? = null) : BackupStep + data class ConfirmExistingPhrase(val error: StringResource? = null) : BackupStep data object PickExportTarget : BackupStep /** Before a plaintext (Markdown) export: the file will not be encrypted. Cancel or continue to the PIN. */ data object ConfirmPlainExport : BackupStep - /** The system "create document" picker for a Markdown export: a `.zip` of all notes, or one `.md`. */ - data class PickMarkdownTarget(val name: String, val zip: Boolean) : BackupStep + /** + * Before one note leaves as a PDF or a printout (the desktop): the same warning as the Markdown export, with + * the printer's own caveat when [print]. + */ + data class ConfirmDocumentExport(val print: Boolean) : BackupStep + + /** The system "create document" picker for an export: a `.zip` of all notes, one `.md`, or one [pdf]. */ + data class PickMarkdownTarget(val name: String, val zip: Boolean, val pdf: Boolean = false) : BackupStep data object PickImportFile : BackupStep - data class EnterImportPhrase(@param:StringRes val error: Int? = null) : BackupStep + data class EnterImportPhrase(val error: StringResource? = null) : BackupStep data class ChooseImportMode(val notes: Int, val tasks: Int, val tags: Int) : BackupStep data object ConfirmReplace : BackupStep @@ -94,7 +110,7 @@ sealed interface BackupStep { /** A one-shot result line. Carries counts at most, never content. */ sealed interface BackupMessage { - data class Text(@param:StringRes val id: Int) : BackupMessage + data class Text(val id: StringResource) : BackupMessage data class Imported(val summary: ImportSummary) : BackupMessage } @@ -115,7 +131,7 @@ class BackupFlowState(private val scope: CoroutineScope, lastExportAt: Long?, pr fun go(step: BackupStep) = ui.update { it.copy(step = step) } - fun message(@StringRes id: Int) = ui.update { it.copy(message = BackupMessage.Text(id)) } + fun message(id: StringResource) = ui.update { it.copy(message = BackupMessage.Text(id)) } /** Ends the flow: drops every pending buffer and shows [message]. */ fun finish(message: BackupMessage) { @@ -196,18 +212,18 @@ class ReauthFlow( if (ok) { onAuthenticated(step.action) } else { - state.go(step.copy(error = R.string.lock_wrong_pin, failures = step.failures + 1)) + state.go(step.copy(error = Res.string.lock_wrong_pin, failures = step.failures + 1)) } } } - fun withBiometric(activity: FragmentActivity) { + fun withBiometric(host: ScreenBiometricHost) { val step = state.step as? BackupStep.Reauth ?: return - securityManager.confirmBiometric(activity) { ok -> + securityManager.confirmBiometric(host.biometric) { ok -> if (ok) { state.launch { onAuthenticated(step.action) } } else { - state.go(step.copy(error = R.string.biometric_change_not_confirmed)) + state.go(step.copy(error = Res.string.biometric_change_not_confirmed)) } } } @@ -233,7 +249,7 @@ class ExportFlow( } } - fun onTarget(uri: Uri?) { + fun onTarget(uri: DocumentRef?) { val file = sealed ?: return sealed = null if (uri == null) return @@ -241,10 +257,10 @@ class ExportFlow( try { // Not cancelled by a re-lock clearing this screen: a half-written document // would look like a backup and never restore. The file is ciphertext. - withContext(NonCancellable + Dispatchers.IO) { documents.write(uri, file) } + withContext(NonCancellable + platformIoDispatcher) { documents.write(uri, file) } backupManager.recordExport() state.ui.update { it.copy(lastExportAt = backupManager.lastExportAt()) } - state.finish(BackupMessage.Text(R.string.backup_export_done)) + state.finish(BackupMessage.Text(Res.string.backup_export_done)) } catch (e: BackupException) { state.fail(e.error) } @@ -284,7 +300,7 @@ class RecoveryPhraseFlow( val phrase = phraseSetup.generate() newPhrase = phrase replacing = replace - state.go(BackupStep.ShowNewPhrase(String(phrase).split(' '), replacing)) + state.go(BackupStep.ShowNewPhrase(phrase.concatToString().split(' '), replacing)) } fun writtenDown() { @@ -297,7 +313,7 @@ class RecoveryPhraseFlow( fun showAgain() { val phrase = newPhrase ?: return if (state.step is BackupStep.CheckNewPhrase) { - state.go(BackupStep.ShowNewPhrase(String(phrase).split(' '), replacing)) + state.go(BackupStep.ShowNewPhrase(phrase.concatToString().split(' '), replacing)) } } @@ -308,7 +324,7 @@ class RecoveryPhraseFlow( step == null || phrase == null -> Unit !matches(phrase, step.positions, answers) -> - state.go(step.copy(error = R.string.backup_phrase_check_wrong)) + state.go(step.copy(error = Res.string.backup_phrase_check_wrong)) else -> add(phrase) } @@ -322,12 +338,12 @@ class RecoveryPhraseFlow( } drop() when { - !saved -> state.finish(BackupMessage.Text(R.string.backup_error_phrase_setup)) + !saved -> state.finish(BackupMessage.Text(Res.string.backup_error_phrase_setup)) - replace -> state.finish(BackupMessage.Text(R.string.security_recovery_replaced)) + replace -> state.finish(BackupMessage.Text(Res.string.security_recovery_replaced)) else -> { - state.message(R.string.backup_phrase_added) + state.message(Res.string.backup_phrase_added) onReady() } } @@ -344,7 +360,7 @@ class RecoveryPhraseFlow( val ok = withContext(Dispatchers.Default) { withNormalizedPhrase(backupManager, input) { phraseSetup.confirm(it) } } - if (ok) onReady() else state.go(BackupStep.ConfirmExistingPhrase(R.string.backup_error_phrase_mismatch)) + if (ok) onReady() else state.go(BackupStep.ConfirmExistingPhrase(Res.string.backup_error_phrase_mismatch)) } } @@ -364,7 +380,7 @@ class RecoveryPhraseFlow( } private fun matches(phrase: CharArray, positions: List, answers: List): Boolean { - val words = String(phrase).split(' ') + val words = phrase.concatToString().split(' ') return answers.size == positions.size && positions.zip(answers).all { (index, answer) -> words[index] == answer.trim().lowercase() } } @@ -390,13 +406,13 @@ class ImportFlow( /** The merge/replace choice, to return to when "Replace all data?" is cancelled. */ private var choice: BackupStep.ChooseImportMode? = null - fun onFile(uri: Uri?) { + fun onFile(uri: DocumentRef?) { // The picker is back: its grace on the vault ends here, whatever was picked. onPickerReturned() if (uri == null) return state.launchBusy { try { - file = withContext(Dispatchers.IO) { documents.read(uri).also(BackupCipher::inspect) } + file = withContext(platformIoDispatcher) { documents.read(uri).also(BackupCipher::inspect) } state.go(BackupStep.EnterImportPhrase()) } catch (e: BackupException) { state.fail(e.error) @@ -466,7 +482,7 @@ class ImportFlow( decrypted = null state.launchBusy { try { - val summary = withContext(Dispatchers.IO) { backupManager.import(payload, mode) } + val summary = withContext(platformIoDispatcher) { backupManager.import(payload, mode) } state.finish(BackupMessage.Imported(summary)) } catch (e: BackupException) { state.fail(e.error) diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt similarity index 75% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt index 3958ca97..e1777ce8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt @@ -2,20 +2,27 @@ package com.pasich.encly.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.pasich.encly.R +import com.pasich.encly.core.di.platformIoDispatcher +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_no_picker +import com.pasich.encly.core.resources.backup_phrase_exists +import com.pasich.encly.core.resources.security_erase_failed +import com.pasich.encly.core.security.ScreenshotAllowance import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject /** * Settings → Backup, plus the vault actions of Settings → Security (create or replace the @@ -23,13 +30,16 @@ import javax.inject.Inject * (the decrypted payload waiting for the merge/replace choice) and is dropped on cancel, on * completion and when the ViewModel is cleared, e.g. by a re-lock. */ -@HiltViewModel -class BackupViewModel @Inject constructor( +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class BackupViewModel( backupManager: BackupManager, private val phraseSetup: BackupPhraseSetup, private val securityManager: SecurityManager, private val sessionLockManager: SessionLockManager, documents: BackupDocuments, + private val screenshots: ScreenshotAllowance, ) : ViewModel() { private val state = BackupFlowState(viewModelScope, backupManager.lastExportAt(), ::dropPending) @@ -43,6 +53,9 @@ class BackupViewModel @Inject constructor( /** The flow in progress, so a ready recovery phrase continues the right one. */ private var action: BackupAction? = null + /** The session the re-authentication was started in (see [SessionLockManager.sessionGeneration]). */ + private var reauthGeneration = 0L + init { // A re-lock drops every plaintext buffer (a decrypted backup, new recovery words) at // once, even while the app is in the background and this screen is not composed. An @@ -62,6 +75,7 @@ class BackupViewModel @Inject constructor( fun start(action: BackupAction) { dropPending() this.action = action + reauthGeneration = sessionLockManager.sessionGeneration val biometric = securityManager.canReauthWithBiometric() state.go(BackupStep.Reauth(action, biometric)) } @@ -74,6 +88,16 @@ class BackupViewModel @Inject constructor( // seals a backup, so nothing here may start it. action == BackupAction.EXPORT_MARKDOWN -> state.go(BackupStep.Idle) + // Nothing to continue with: the re-authentication was the confirmation. + action == BackupAction.ALLOW_SCREENSHOTS -> { + // The PIN is checked off the main thread: if the app locked meanwhile (it went to the background + // with no delay), this must not switch the allowance on after the lock that ended it. + if (!sessionLockManager.locked.value && sessionLockManager.sessionGeneration == reauthGeneration) { + screenshots.allow() + } + state.go(BackupStep.Idle) + } + // Held for five seconds on the security page, then re-authenticated: that is the // confirmation, so the erase runs straight away. action == BackupAction.ERASE -> { @@ -83,7 +107,7 @@ class BackupViewModel @Inject constructor( action == BackupAction.CREATE_PHRASE -> if (phraseSetup.hasRecoveryPhrase()) { - state.finish(BackupMessage.Text(R.string.backup_phrase_exists)) + state.finish(BackupMessage.Text(Res.string.backup_phrase_exists)) } else { phraseFlow.create() } @@ -112,12 +136,14 @@ class BackupViewModel @Inject constructor( if (state.step != BackupStep.ConfirmErase) return dropPending() state.launchBusy { - val erased = withContext(Dispatchers.IO) { securityManager.wipeAndReset() } + val erased = withContext(platformIoDispatcher) { securityManager.wipeAndReset() } // A refused erase (see SecurityManager.wipeAndReset) must not be announced as done. if (erased) { + // A fresh vault starts protected, whatever the erased one allowed. + screenshots.revoke() state.ui.update { it.copy(step = BackupStep.Idle, erased = true) } } else { - state.finish(BackupMessage.Text(R.string.security_erase_failed)) + state.finish(BackupMessage.Text(Res.string.security_erase_failed)) } } } @@ -136,7 +162,7 @@ class BackupViewModel @Inject constructor( /** No app on this device handles the Storage Access Framework picker. */ fun onSystemPickerUnavailable() { sessionLockManager.endSystemPicker() - state.finish(BackupMessage.Text(R.string.backup_error_no_picker)) + state.finish(BackupMessage.Text(Res.string.backup_error_no_picker)) } fun cancel() { diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt new file mode 100644 index 00000000..2d4521f9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt @@ -0,0 +1,70 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner + +/** + * At most one biometric prompt at a time, for a ViewModel that outlives its activity. + * + * A second request while a prompt is open is ignored: a double tap must not stack prompts. The + * guard is released by the prompt's answer, or when the activity that showed it stops. + * androidx.biometric 1.1 does not answer a prompt that cannot be shown or is cut off: its + * authenticate() only logs "Called after onSaveInstanceState()" and returns, with no callback, + * when the activity is already stopped; below Android 10 its fragment cancels the prompt + * internally on ON_STOP without telling the caller; and it drops the answer of a prompt whose + * activity is destroyed (BiometricPrompt resets its callback on ON_DESTROY). Without the release, + * a prompt started over a backgrounded window (the auto-lock firing in the background) or cut + * off by the app leaving the foreground would leave the guard closed for good: the lock screen's + * fingerprint button, in a ViewModel that survives all of it, could never start a prompt again. + * + * Main thread only, as the prompts themselves. + */ +internal class BiometricPromptGuard { + private var current: Any? = null + + /** + * The prompt whose activity stopped while it was open. Its answer is still taken when no newer prompt + * started since: a prompt that some devices show over a stopped activity answers after the stop. + */ + private var stopped: Any? = null + + val inFlight: Boolean get() = current != null + + /** + * Runs [prompt] unless one is already open. [prompt] calls the `release` it is given with + * its answer; `release` returns false when that answer is stale (its activity was destroyed, + * or a newer prompt started after its activity stopped), and the caller then drops it. + */ + fun launch(host: LifecycleOwner, prompt: (release: () -> Boolean) -> Unit) { + // A stopped or destroyed activity shows no prompt (it would be cancelled unseen) and + // would not release the guard. + if (current != null || !host.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) return + val token = Any() + current = token + stopped = null + val observer = object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) { + // The button may start a new prompt now; this one's answer counts until then. + if (current === token) { + current = null + stopped = token + } + } + + override fun onDestroy(owner: LifecycleOwner) { + owner.lifecycle.removeObserver(this) + if (current === token) current = null + if (stopped === token) stopped = null + } + } + host.lifecycle.addObserver(observer) + prompt { + host.lifecycle.removeObserver(observer) + val mine = current === token || stopped === token + if (current === token) current = null + if (stopped === token) stopped = null + mine + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ClearOnLock.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ClearOnLock.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/ClearOnLock.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ClearOnLock.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt new file mode 100644 index 00000000..890a0f02 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -0,0 +1,787 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import com.pasich.encly.core.di.ApplicationScope +import com.pasich.encly.core.di.IoDispatcher +import com.pasich.encly.core.security.NeverLocked +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.NoteColor +import com.pasich.encly.domain.model.NoteLineSpacing +import com.pasich.encly.domain.model.NoteTextOverride +import com.pasich.encly.domain.repository.NotesRepository +import com.pasich.encly.domain.repository.SettingsRepository +import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockFactory +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.editor.model.TextualBlock +import com.pasich.encly.editor.state.BlockEditorState +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.FindState +import com.pasich.encly.editor.state.FocusRequest +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.SelectedBlocks +import com.pasich.encly.editor.state.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.SlashPicker +import com.pasich.encly.editor.state.TextRangeSelection +import com.pasich.encly.editor.state.addBlockAfter +import com.pasich.encly.editor.state.addBlockToEnd +import com.pasich.encly.editor.state.applyShortcut +import com.pasich.encly.editor.state.applySlash +import com.pasich.encly.editor.state.applyTemplate +import com.pasich.encly.editor.state.applyTool +import com.pasich.encly.editor.state.backspaceAtStart +import com.pasich.encly.editor.state.breakLine +import com.pasich.encly.editor.state.canMoveInteracted +import com.pasich.encly.editor.state.canRemoveInteracted +import com.pasich.encly.editor.state.canReplaceTextRange +import com.pasich.encly.editor.state.deleteSelectedBlocks +import com.pasich.encly.editor.state.duplicateBlockOf +import com.pasich.encly.editor.state.duplicateInteracted +import com.pasich.encly.editor.state.focusFirstBlock +import com.pasich.encly.editor.state.focusWorkingBlock +import com.pasich.encly.editor.state.indexOf +import com.pasich.encly.editor.state.interactedBlockPosition +import com.pasich.encly.editor.state.interactedListItem +import com.pasich.encly.editor.state.isBlankNote +import com.pasich.encly.editor.state.listBackspaceAtStart +import com.pasich.encly.editor.state.listLineBreak +import com.pasich.encly.editor.state.listTypingStep +import com.pasich.encly.editor.state.moveBlockOf +import com.pasich.encly.editor.state.moveInteracted +import com.pasich.encly.editor.state.moveListItem +import com.pasich.encly.editor.state.pasteCopied +import com.pasich.encly.editor.state.pasteLink +import com.pasich.encly.editor.state.removeInteracted +import com.pasich.encly.editor.state.replaceTextRange +import com.pasich.encly.editor.state.shiftListItem +import com.pasich.encly.editor.state.tableTypingStep +import com.pasich.encly.editor.state.textRangeBlocks +import com.pasich.encly.editor.state.toggleChecklistLock +import com.pasich.encly.presentation.editor.persistence.LoadNoteState +import com.pasich.encly.presentation.editor.persistence.NoteCopyTitle +import com.pasich.encly.presentation.editor.persistence.NotePersistence +import com.pasich.encly.presentation.editor.persistence.SaveStatusNote +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +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 + +/** + * The note editor. Wires the block content ([BlockEditorState]) to the stored note + * ([NotePersistence]) and decides whether the note may be edited at all (locked, or read-only + * from the trash). Focus is plain state here; the UI owns the FocusRequesters. + */ +@Suppress("LongParameterList") // Injected dependencies; grouping them would only obscure wiring. +@AssistedInject +class EditNoteViewModel( + notesRepository: NotesRepository, + @Assisted private val savedStateHandle: SavedStateHandle, + updateNoteTrashStatusUseCase: UpdateNoteTrashStatusUseCase, + updateNoteArchiveStatusUseCase: UpdateNoteArchiveStatusUseCase, + private val settingsRepository: SettingsRepository, + @ApplicationScope private val appScope: CoroutineScope, + @IoDispatcher ioDispatcher: CoroutineDispatcher, + copyTitle: NoteCopyTitle, + lockEvents: VaultLockEvents = NeverLocked, +) : ViewModel() { + private val noteId: Long = savedStateHandle["idNote"] + ?: -1 // Note identifier; when creating a new note it will be -1 + internal val copySource: Long = + savedStateHandle["copySource"] ?: -1 // Identifier of the note to copy, if specified + val isReadTrashOnly: Boolean = + savedStateHandle["isReadTrashOnly"] as? Boolean == true // Flag for reading a note only from the trash + internal val addTag: Long = savedStateHandle["addTag"] ?: 0 + + /** A note being written from scratch (not opened, not a copy): the editor focuses its title. */ + val isNewNote: Boolean get() = noteId == -1L && copySource == -1L + + private val editor = BlockEditorState() + + private val persistence = NotePersistence( + notesRepository = notesRepository, + updateNoteTrashStatusUseCase = updateNoteTrashStatusUseCase, + updateNoteArchiveStatusUseCase = updateNoteArchiveStatusUseCase, + ioDispatcher = ioDispatcher, + readOnly = isReadTrashOnly, + blocks = { editor.blocks }, + copyTitle = copyTitle, + loading = noteId != -1L || copySource != -1L, + ) + + val state: StateFlow get() = persistence.state + val status: StateFlow get() = persistence.status + + /** + * True when a note had stored content that could not be read: the editor warns instead of + * showing a silent blank screen, and nothing is saved over the original. + */ + val contentLoadFailed: StateFlow get() = persistence.contentLoadFailed + + /** The blocks to display and edit. */ + val blocks: List get() = editor.blocks + + val canUndo: StateFlow get() = editor.canUndo + val canRedo: StateFlow get() = editor.canRedo + + /** The block the user last worked on; the toolbar acts on it. */ + val interactedBlockId: StateFlow get() = editor.selection.interactedBlockId + + /** Position of the block the user last worked on, always inside [blocks]. */ + val interactedIndex: Int get() = editor.selection.interactedIndex + + /** The block whose field has focus; null while none has. */ + val focusedBlockId: StateFlow get() = editor.selection.focusedBlockId + + /** Which block the editor UI should focus next. */ + val focusRequests: Flow get() = editor.selection.requests + + /** Carries out a focus request at once where its field exists; set by the editor screen while composed. */ + var immediateFocus: ((FocusRequest) -> Unit)? + get() = editor.selection.immediateFocus + set(value) { + editor.selection.immediateFocus = value + } + + /** 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 + + private val _editedElsewhere = MutableStateFlow(false) + + /** + * True while another window edits this note (a host with several windows): this editor only shows the note, it + * cannot be unlocked, and nothing it holds is saved, so the other window's changes are not overwritten. + */ + val editedElsewhere: StateFlow get() = _editedElsewhere + + /** The host says whether another window edits this note ([elsewhere]); see [editedElsewhere]. */ + fun setEditedElsewhere(elsewhere: Boolean) { + _editedElsewhere.value = elsewhere + persistence.suspended = elsewhere + if (elsewhere) _lockEditor.value = true + } + + private var followStored: Job? = null + + // The last give-back in progress: a claim granted again waits for it, so its late "suspended" cannot land on an + // editor that holds the claim again (the same window recreating this editor's screen is the usual case). + private var finishing: Job? = null + + /** + * The host answers this editor's claim on its note. Refused: another window edits it, so this one only shows the + * note, and shows what that window saves. Granted: if the note was saved elsewhere since this editor last held + * it, the stored text is shown instead of the stale copy, then editing resumes. + */ + fun onClaimResult(granted: Boolean) { + followStored?.cancel() + if (!granted) { + setEditedElsewhere(true) + followStored = viewModelScope.launch { + persistence.storedNote().collect { persistence.showStored(it, editor::load) } + } + return + } + viewModelScope.launch { + finishing?.join() + persistence.resume(editor::load) + _editedElsewhere.value = false + } + } + + /** + * The host takes this editor off the screen: what is not yet stored is saved, nothing is written from now on + * until a claim is granted again, and only then [release] gives the note to another window. Runs on the app's + * scope, so it finishes even when the window is closing. + */ + fun finishEditing(release: () -> Unit) { + followStored?.cancel() + finishing = appScope.launch { + try { + persistence.release() + } finally { + release() + } + } + } + + private val readOnly: Boolean get() = isReadTrashOnly || _editedElsewhere.value + + // Link blocks whose saved address is open in URL entry. Editor UI state only: the block + // keeps its stored link until a new one is committed. + private val _editingLinkIds = MutableStateFlow>(emptySet()) + + /** Ids of the link blocks the user is editing ([editLink]); they show URL entry. */ + val editingLinkIds: StateFlow> get() = _editingLinkIds + + /** + * What this note sets for its own text size and line spacing; the rest follows the defaults. + * They are columns of the note in the vault, stored with it like the title. + */ + val textOverride: StateFlow = persistence.state.map { state -> + NoteTextOverride( + size = state.note.textSize, + spacing = state.note.lineSpacing?.let { enumValues().getOrNull(it) }, + ) + }.distinctUntilChanged().stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + NoteTextOverride(), + ) + + /** The text size in effect: the note's own, else the default from Settings → Appearance. */ + val fontSize: StateFlow = combine(settingsRepository.fontSizeFlow, textOverride) { default, own -> + own.size ?: default + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = 16, + ) + + val fontStyle = settingsRepository.fontStyleFlow.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = FontStyleType.DEFAULT, + ) + + val noteLineSpacing = combine(settingsRepository.noteLineSpacingFlow, textOverride) { default, own -> + own.spacing ?: default + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = NoteLineSpacing.DEFAULT, + ) + + val simpleEdit = settingsRepository.simpleEditFlow.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) + + private var unregisterLockFlush: () -> Unit = {} + + init { + when { + copySource != -1L -> loadNote(copySource, isCopy = true) + + noteId != -1L -> loadNote(noteId, isCopy = false) + + // A new note created from a tag's list starts in that tag. + addTag != 0L -> persistence.updateNote { it.copy(tagIds = setOf(addTag)) } + } + persistence.startAutosave(viewModelScope, editor.contentChanges) + // A lock writes what the autosave still waits for before it closes the vault, then ends editing: nothing + // is written to the closed database. + unregisterLockFlush = lockEvents.registerPreLockFlush { + try { + persistence.save() + } finally { + persistence.close() + } + } + 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) { + followStored?.cancel() + 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 = "") } + } + } + + // A new note (or copy) gets its id on its first save. Kept as the screen's argument, so a + // screen restored after process death opens that note instead of a blank one. + private fun rememberStoredId() { + viewModelScope.launch { + persistence.state.map { it.note.id }.distinctUntilChanged().collect { id -> + if (id > 0L && id != noteId) { + savedStateHandle["idNote"] = id + savedStateHandle["copySource"] = -1L + } + } + } + } + + /** 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) + // A note that could not be read stays read-only, so an empty editor is never + // saved as (or over) it. + _lockEditor.value = !found || isReadTrashOnly + } + } + + override fun onCleared() { + unregisterLockFlush() + // One last-chance save for lifecycle teardown. It does nothing once the editor was + // closed (explicit exit, trash, delete, restore, discard), so it cannot duplicate an + // insert or undo any of those. + appScope.launch { persistence.save() } + super.onCleared() + } + + /** The user leaves the editor: no queued or later autosave may write the note again. */ + fun markExitHandled() = persistence.close() + + /** + * Flush the current editor state as soon as the Activity leaves the foreground. + * ProcessLifecycleOwner re-locks/closes SQLCipher later in the background transition, + * so this closes the autosave debounce window before the vault is closed. + */ + fun saveForBackground() { + appScope.launch { persistence.save() } + } + + /** Persists the note; returns once the database reports success or failure. */ + suspend fun saveNote(): Boolean = persistence.save() + + /** Puts the stored note back as it was when the editor opened it. */ + suspend fun discardChanges(): Boolean = persistence.discard() + + /** Restores the note from the trash. */ + suspend fun noteRestore(): Boolean = persistence.restoreFromTrash() + + /** Moves the note to the trash; returns once the write completed. */ + suspend fun noteMoveToTrash(): Boolean = persistence.moveToTrash() + + /** Moves the note to the archive, or back to the list; returns once the write completed. */ + suspend fun noteSetArchived(archived: Boolean): Boolean = persistence.setArchived(archived) + + /** Marks the note with [color]. Stored with the next save, like the title. */ + fun updateColor(color: NoteColor) { + if (!readOnly) persistence.updateNote { it.copy(color = color.key) } + } + + /** Inserts a copy of the note, titled by [NoteCopyTitle]; returns its id, or -1 on failure. */ + suspend fun noteDuplicate(): Long = persistence.duplicate() + + /** Whether the note is a new one with nothing in it yet: nothing to copy, save or discard. */ + val isBlankDraft: Boolean get() = persistence.isBlankDraft + + /** Whether discarding would undo anything (see [NotePersistence.hasChanges]). */ + suspend fun hasChanges(): Boolean = persistence.hasChanges() + + /** Deletes the note from the database for good. */ + suspend fun noteDelete(): Boolean = persistence.delete() + + fun updateTitle(newTitle: String) { + persistence.updateNote { it.copy(title = newTitle) } + } + + /** + * Puts the tag [tagId] on the note, or takes it off when the note has it; 0 ("No tag") takes every tag off. The + * editor shows it at once; it is stored like a title change, by the autosave or the save on leaving, together + * with the editor's content. + */ + fun toggleTagNote(tagId: Long) { + persistence.updateNote { note -> + note.copy( + tagIds = when { + tagId <= 0L -> emptySet() + tagId in note.tagIds -> note.tagIds - tagId + else -> note.tagIds + tagId + }, + ) + } + } + + /** Sets this note's own text size; the default in Settings stays as it is. */ + fun updateFontSize(size: Int) { + if (!readOnly) persistence.updateNote { it.copy(textSize = size.coerceIn(MIN_TEXT_SIZE, MAX_TEXT_SIZE)) } + } + + /** Sets this note's own line spacing; the default in Settings stays as it is. */ + fun updateNoteLineSpacing(value: NoteLineSpacing) { + if (!readOnly) persistence.updateNote { it.copy(lineSpacing = value.ordinal) } + } + + /** "Use default": the note follows the text size and line spacing of Settings again. */ + fun useDefaultText() { + if (!readOnly) persistence.updateNote { it.copy(textSize = null, lineSpacing = null) } + } + + /** Locks or unlocks the editor. A note that could not be read, or one in the trash, stays locked. */ + fun toggleLockEditor() { + if (contentLoadFailed.value || readOnly) return + _lockEditor.value = !_lockEditor.value + } + + private fun canEdit(): Boolean = !readOnly && !_lockEditor.value + + // --- blocks ---------------------------------------------------------------------------- + + /** Position of [block] in the editor, by identity; -1 when it is no longer there. */ + fun indexOfBlock(block: Block): Int = editor.blocks.indexOfFirst { it.id == block.id } + + /** The field of [block] (for a list: one of its items) took focus. */ + 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) + // 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) + editor.slash.dismiss() + } + + /** + * Adds a block of [blockType] after the focused block (or the last one the user touched). + * An empty first text block at that point is replaced instead. + */ + fun addBlock(blockType: BlockType) { + if (canEdit()) editor.addBlock(BlockFactory.createBlock(blockType)) + } + + /** Adds a block of [blockType] after position [afterIndex] and focuses it. */ + fun addBlockAfter(afterIndex: Int, blockType: BlockType) { + if (canEdit()) editor.addBlockAfter(afterIndex, BlockFactory.createBlock(blockType)) + } + + /** Focuses a trailing text block (the tap area below the last block). */ + fun addBlockToEnd() { + if (canEdit()) editor.addBlockToEnd() + } + + /** + * Removes [block] if [blockRemoveAction] allows it; see [BlockEditorState.removeBlock]. + * Does nothing in a locked or read-only editor. + */ + fun removeBlock(block: Block, blockRemoveAction: BlockRemoveAction, isReFocus: Boolean = true) { + if (canEdit()) editor.removeBlock(block, blockRemoveAction, isReFocus) + } + + fun replaceBlock(targetBlockIndex: Int, newBlock: Block): Boolean = + canEdit() && editor.replaceBlock(targetBlockIndex, newBlock) + + /** + * Sets the text of a text-bearing [block] and records it for undo/redo. The previous + * text is read from the block itself, so undo always restores what was really there. + */ + fun onBlockTextChanged(block: Block, newText: String) { + if (canEdit(block) && block is TextualBlock) editor.changeValue(block.id, block.text, newText, mergeable = true) + } + + /** Sets the items of a list [block] (typing, checking, adding or removing items). */ + fun onListItemsChanged(block: Block.ListBlock, newItems: List, mergeable: Boolean) { + if (!canEdit(block)) return + val step = if (mergeable) listTypingStep(block.items.value, newItems) else null + editor.changeValue(block.id, block.items, newItems, mergeable, step) + } + + /** Sets the content of a table [block] (typing in a cell, a row or column added or removed). */ + fun onTableChanged(block: Block.TableBlock, newTable: TableData, mergeable: Boolean) { + if (!canEdit(block)) return + val step = if (mergeable) tableTypingStep(block.table.value, newTable) else null + editor.changeValue(block.id, block.table, newTable, mergeable, step) + } + + // A block that left the note (converted, merged, undone) may still report a late edit from + // its field; it must not reach the history. + private fun canEdit(block: Block): Boolean = canEdit() && editor.indexOf(block.id) >= 0 + + /** + * 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) 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) + } + + /** + * [pasted] went into the field of [block] (item [itemId] of a list) between [head] and [tail]. When it is exactly + * what the last copy or cut put on the clipboard, the blocks it was made of go in (see pasteCopied). Null when it + * is not that text, in simple editing or a locked editor: the paste then stays as the field read it. + */ + fun onPasteCopied(block: Block, itemId: String?, head: String, pasted: String, tail: String): String? = + if (!canEdit(block) || simpleEdit.value) null else editor.pasteCopied(block.id, itemId, head, pasted, tail) + + /** 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) + + /** A mark typed at the start of paragraph [block] ("# ", "- ", ...): it becomes that block. */ + 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) + + /** A line break in item [itemId] of list [block] (see listLineBreak). */ + fun onListLineBreak(block: Block.ListBlock, itemId: String, lineBreak: LineBreak): String? = + if (canEdit(block)) editor.listLineBreak(block.id, itemId, lineBreak) else null + + /** Backspace at the start of item [itemId] of list [block]. */ + fun onListBackspaceAtStart(block: Block.ListBlock, itemId: String): Boolean = + canEdit(block) && editor.listBackspaceAtStart(block.id, itemId) + + /** Indents ([deeper]) or outdents item [itemId] of list [block] one level (see shiftListItem). */ + fun shiftListItem(block: Block.ListBlock, itemId: String, deeper: Boolean): Boolean = + canEdit(block) && editor.shiftListItem(block.id, itemId, deeper) + + /** Moves item [itemId] of list [block] [by] places among its siblings (see moveListItem). */ + fun moveListItem(block: Block.ListBlock, itemId: String, by: Int): Boolean = + canEdit(block) && editor.moveListItem(block.id, itemId, by) + + /** Indents ([deeper]) or outdents the list item the user works in. */ + fun shiftInteractedListItem(deeper: Boolean) { + val (listId, itemId) = editor.interactedListItem() ?: return + if (canEdit()) editor.shiftListItem(listId, itemId, deeper) + } + + /** The cursor in [block]'s field moved to [offset]. */ + fun onCaretMoved(block: Block, offset: Int) = editor.selection.onCaret(block.id, offset) + + /** The cursor in item [itemId] of list [block] moved to [offset]. */ + fun onListItemCaret(block: Block, itemId: String, offset: Int) = + editor.selection.onItemCaret(block.id, itemId, offset) + + /** Sets the link of [block]; a link being edited ([editLink]) is committed with it. */ + fun onLinkChanged(block: Block.LinkBlock, newLink: LinkDataBlock) { + if (!canEdit()) return + _editingLinkIds.update { it - block.id } + editor.changeValue(block.id, block.block, newLink, mergeable = false) + } + + /** + * Puts a saved link back into URL entry, keeping its address to edit. The saved link stays + * in the block until the user commits a new one ([onLinkChanged]), so a save in the + * meantime (autosave, Back, the app going to the background) still stores it. + */ + fun editLink(block: Block.LinkBlock) { + if (!canEdit()) return + _editingLinkIds.update { it + block.id } + editor.selection.onInteraction(block.id) + } + + /** + * Applies toolbar [tool] to the block the user works on (see applyTool); [exact] for a + * specific heading level. + */ + fun applyTool(tool: BlockType, exact: Boolean = false) { + if (canEdit()) editor.applyTool(tool, exact) + } + + /** The title's "Next": the first block takes focus. */ + fun focusFirstBlock() { + if (canEdit()) editor.focusFirstBlock() + } + + /** The toolbar's "Show keyboard": back into the block the user was writing in. */ + fun focusWorkingBlock() { + if (canEdit()) editor.focusWorkingBlock() + } + + fun undo() { + if (canEdit()) editor.undo() + } + + fun redo() { + if (canEdit()) editor.redo() + } + + // --- toolbar: acts on the block the user last worked on -------------------------------- + + fun moveBlock(up: Boolean) { + if (canEdit()) editor.moveInteracted(up) + } + + fun canMoveBlock(up: Boolean): Boolean = canEdit() && editor.canMoveInteracted(up) + + /** Moves [block] one place [up] (or down): TalkBack's actions on a block. */ + fun moveBlockOf(block: Block, up: Boolean) { + if (canEdit(block)) editor.moveBlockOf(block.id, up) + } + + fun canRemoveInteractedBlock(): Boolean = canEdit() && editor.canRemoveInteracted() + + fun removeInteractedBlock() { + if (canEdit()) editor.removeInteracted() + } + + // --- block selection: whole blocks picked to copy, cut or delete ------------------------------- + + /** The blocks picked as whole blocks. Picking changes nothing in the note, so a locked or trashed note may. */ + val selectedBlocks: SelectedBlocks get() = editor.pickedBlocks + + /** Whether the note can be changed: cut and delete of picked blocks are offered only then. */ + val canEditBlocks: Boolean get() = canEdit() + + /** Picks every block of the note; false in simple editing, whose single field selects its own text. */ + fun selectAllBlocks(): Boolean { + if (simpleEdit.value) return false + editor.pickedBlocks.selectAll() + return true + } + + /** Selects the whole note's text across blocks; false in simple editing, whose single field selects its own. */ + fun selectAllText(): Boolean { + if (simpleEdit.value) return false + editor.textRange.selectAll() + return true + } + + /** The part of the note the text selection covers, as blocks of their own (for the clipboard). */ + fun textRangeBlocks(): List = editor.textRangeBlocks() + + /** Whether the text selection across blocks can be replaced or cut: it reaches no locked checklist. */ + fun canReplaceTextRange(): Boolean = editor.canReplaceTextRange() + + /** A copy or cut put [text], the rendering of [blocks], on the clipboard: pasting it back inserts these blocks. */ + fun rememberCopied(text: String, blocks: List) = editor.rememberCopied(text, blocks) + + /** The text selection that runs across blocks. */ + val textRange: TextRangeSelection get() = editor.textRange + + /** Replaces the cross-block text selection with [replacement] as one undo step; false when it cannot. */ + fun replaceTextRange(replacement: String): Boolean = canEdit() && editor.replaceTextRange(replacement) + + /** Removes the picked blocks as one undo step; does nothing in a locked or read-only note. */ + fun deleteSelectedBlocks(): Boolean = canEdit() && editor.deleteSelectedBlocks() + + /** Locks or unlocks the checklist [block] (see toggleChecklistLock). */ + fun toggleChecklistLock(block: Block) { + if (canEdit(block)) editor.toggleChecklistLock(block.id) + } + + /** Duplicates the block the user works on, right after it. */ + fun duplicateInteractedBlock() { + if (canEdit()) editor.duplicateInteracted() + } + + /** Duplicates [block], right after it: TalkBack's and the block sheets' action. */ + fun duplicateBlock(block: Block) { + if (canEdit(block)) editor.duplicateBlockOf(block.id) + } + + // --- find in this note: read-only, so it works in a locked or trashed note too --------------- + + fun openFind() = editor.find.open() + + fun closeFind() = editor.find.close() + + fun onFindQueryChanged(query: String) = editor.find.search(query, editor.blocks) + + fun findNext() = editor.find.next() + + fun findPrevious() = editor.find.previous() + + // --- templates ------------------------------------------------------------------------- + + /** + * Whether to offer "Start from template": a brand-new note that is still completely empty, + * and not in Simple Edit (it never shows block tools). Following the text, so it goes away + * with the first character and comes back if undo makes the note blank again, but not once + * the note has been saved (its stored id is set): from then on it is an existing note. + */ + val templatesOffered: StateFlow = combine( + persistence.state.map { it.note.title.isEmpty() && it.note.id <= 0L }.distinctUntilChanged(), + editor.contentChanges.map { editor.isBlankNote() }.onStart { emit(editor.isBlankNote()) }, + simpleEdit, + _lockEditor, + ) { blankUnsaved, blankBody, simple, locked -> isNewNote && blankUnsaved && blankBody && !simple && !locked } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), false) + + /** Starts the blank note from [blocks] (a template's, see NoteTemplate.blocks). */ + fun applyTemplate(blocks: List) { + if (canEdit() && !simpleEdit.value) editor.applyTemplate(blocks) + } + + /** Whether the block the user works on is the first, the last or in between. */ + fun getMoveBlockState(): Int = editor.interactedBlockPosition() +} + +/** Creates [EditNoteViewModel] with the SavedStateHandle of its entry, navigation arguments included. */ +@AssistedFactory +@ViewModelAssistedFactoryKey(EditNoteViewModel::class) +@ContributesIntoMap(AppScope::class) +fun interface EditNoteViewModelFactory : ViewModelAssistedFactory { + override fun create(extras: CreationExtras): EditNoteViewModel = create(extras.createSavedStateHandle()) + + fun create(@Assisted savedStateHandle: SavedStateHandle): EditNoteViewModel +} + +/** The find bar refreshes its matches this long after the text stops changing. */ +private const val FIND_REFRESH_MS = 100L + +private const val MIN_TEXT_SIZE = 10 +private const val MAX_TEXT_SIZE = 32 + +/** 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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditorToolbarViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditorToolbarViewModel.kt new file mode 100644 index 00000000..1facde25 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditorToolbarViewModel.kt @@ -0,0 +1,29 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import com.pasich.encly.domain.model.EditorToolbar +import com.pasich.encly.domain.model.ToolbarLayout +import com.pasich.encly.domain.model.ToolbarTool +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.flow.StateFlow + +/** Which tools the editor toolbar offers and in what order (see [EditorToolbar]); every change is saved at once. */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class EditorToolbarViewModel(private val toolbar: EditorToolbar) : ViewModel() { + val layout: StateFlow = toolbar.layout + + /** Moves [tool] [steps] places down the list of all tools (up when negative); see [ToolbarLayout.moved]. */ + fun move(tool: ToolbarTool, steps: Int) = toolbar.set(toolbar.layout.value.moved(tool, steps)) + + /** Hides [tool], or shows it again. */ + fun toggle(tool: ToolbarTool) = toolbar.set(toolbar.layout.value.toggled(tool)) + + /** Back to the toolbar as it ships. */ + fun reset() = toolbar.reset() +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt similarity index 78% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt index 0a18e909..e86f59bc 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt @@ -1,10 +1,10 @@ package com.pasich.encly.presentation.viewmodel -import android.content.Context -import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import com.pasich.encly.core.backup.BackupException import com.pasich.encly.core.di.IoDispatcher import com.pasich.encly.core.security.InitialStatus @@ -19,8 +19,16 @@ import com.pasich.encly.data.handoff.HandoffException import com.pasich.encly.data.handoff.HandoffImport import com.pasich.encly.data.handoff.HandoffPreview import com.pasich.encly.data.handoff.HandoffStaging -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext +import com.pasich.encly.platform.CacheDirectory +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.PlatformDocuments +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,9 +36,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException -import javax.inject.Inject +import kotlinx.io.IOException +import kotlinx.io.files.Path /** Where a My Notes hand-off is. */ sealed interface HandoffStep { @@ -57,17 +64,19 @@ sealed interface HandoffStep { * the caller first). The vault must be unlocked through the normal lock screen before the * URI is read at all; the data is then shown as counts, and imported only on [confirm]. */ -@HiltViewModel -class ImportFromMyNotesViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, +@AssistedInject +@Suppress("LongParameterList") // Injected dependencies; grouping them would only obscure wiring. +class ImportFromMyNotesViewModel( + private val documents: PlatformDocuments, + cache: CacheDirectory, private val securityManager: SecurityManager, private val sessionLockManager: SessionLockManager, private val backupManager: BackupManager, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val savedStateHandle: SavedStateHandle, + @Assisted private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val staging = HandoffStaging(File(context.cacheDir, HandoffStaging.DIR_NAME)) + private val staging = HandoffStaging(Path(cache.path, HandoffStaging.DIR_NAME)) private val _step = MutableStateFlow(HandoffStep.Waiting) val step: StateFlow = _step.asStateFlow() @@ -79,11 +88,11 @@ class ImportFromMyNotesViewModel @Inject constructor( private var handoff: HandoffImport? = null /** - * Starts the hand-off of [uri] once; a recreated activity calls it again and is ignored. One + * Starts the hand-off of [source] once; a recreated activity calls it again and is ignored. One * that already finished before the process was killed shows its counts again instead of * offering the same import a second time. */ - fun start(uri: Uri) { + fun start(source: DocumentRef) { if (started) return started = true restoredDone()?.let { @@ -97,7 +106,7 @@ class ImportFromMyNotesViewModel @Inject constructor( } sessionLockManager.locked.first { !it && securityManager.isDatabaseUnlocked() } _step.value = HandoffStep.Reading - _step.value = read(uri) + _step.value = read(source) } } @@ -160,7 +169,7 @@ class ImportFromMyNotesViewModel @Inject constructor( private fun vaultOpen(): Boolean = securityManager.isDatabaseUnlocked() && !sessionLockManager.locked.value /** - * A committed vault that is closed shows the lock screen (as MainActivity does at startup). + * A committed vault that is closed shows the lock screen (as EnclyApp does at startup). * False when there is no vault to unlock: not set up yet, damaged, or from Encly 1.x. */ private suspend fun requireUnlock(): Boolean { @@ -170,8 +179,8 @@ class ImportFromMyNotesViewModel @Inject constructor( return true } - private suspend fun read(uri: Uri): HandoffStep = try { - val loaded = withContext(ioDispatcher) { staging.load { context.contentResolver.openInputStream(uri) } } + private suspend fun read(source: DocumentRef): HandoffStep = try { + val loaded = withContext(ioDispatcher) { staging.load { documents.openRead(source) } } handoff = loaded HandoffStep.Preview(loaded.preview) } catch (e: HandoffException) { @@ -180,13 +189,21 @@ class ImportFromMyNotesViewModel @Inject constructor( // The mapped data failed the backup validation: a malformed hand-off. HandoffStep.Failed(HandoffError.INVALID_PAYLOAD) } catch (_: IOException) { - HandoffStep.Failed(HandoffError.FAILED) - } catch (_: SecurityException) { - // No read grant on the URI. + // Also a refused read grant on the document. HandoffStep.Failed(HandoffError.FAILED) } } +/** Creates [ImportFromMyNotesViewModel] with the SavedStateHandle of its entry, navigation arguments included. */ +@AssistedFactory +@ViewModelAssistedFactoryKey(ImportFromMyNotesViewModel::class) +@ContributesIntoMap(AppScope::class) +fun interface ImportFromMyNotesViewModelFactory : ViewModelAssistedFactory { + override fun create(extras: CreationExtras): ImportFromMyNotesViewModel = create(extras.createSavedStateHandle()) + + fun create(@Assisted savedStateHandle: SavedStateHandle): ImportFromMyNotesViewModel +} + /** SavedStateHandle key of a finished hand-off's counts (see ImportFromMyNotesViewModel.start). */ private const val KEY_DONE = "handoff_done_counts" private const val DONE_COUNTS = 6 diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt similarity index 94% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt index 8a5f04a6..a7efe504 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt @@ -1,6 +1,5 @@ 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 @@ -12,6 +11,7 @@ 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.ImportDocuments import com.pasich.encly.data.importer.ImportError import com.pasich.encly.data.importer.ImportException import com.pasich.encly.data.importer.ImportFileSource @@ -22,8 +22,13 @@ 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 com.pasich.encly.data.importer.isOutOfMemory +import com.pasich.encly.platform.DocumentRef +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job @@ -35,8 +40,7 @@ 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 +import kotlinx.io.IOException /** Why an import ended without writing anything. */ enum class ImportFailure { @@ -112,9 +116,11 @@ sealed interface ImportStep { * 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, +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class ImportNotesViewModel( + private val files: ImportDocuments, private val store: VaultDataStore, private val sessionLockManager: SessionLockManager, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -149,12 +155,12 @@ class ImportNotesViewModel @Inject constructor( _step.value = ImportStep.Failed(ImportFailure.NO_PICKER) } - fun onFilesPicked(uris: List) { + fun onFilesPicked(uris: List) { sessionLockManager.endSystemPicker() if (uris.isNotEmpty()) begin(files.files(uris)) } - fun onFolderPicked(tree: Uri?) { + fun onFolderPicked(tree: DocumentRef?) { sessionLockManager.endSystemPicker() if (tree != null) begin(files.tree(tree)) } @@ -191,6 +197,8 @@ class ImportNotesViewModel @Inject constructor( _step.value = outcome.step } + // The Error branch rethrows all but the out-of-memory one. + @Suppress("TooGenericExceptionCaught") private suspend fun read(store: VaultDataStore, source: ImportFileSource): Outcome = try { withContext(ioDispatcher) { val context = currentCoroutineContext() @@ -203,9 +211,8 @@ class ImportNotesViewModel @Inject constructor( failure(failureOf(e.error)) } catch (_: IOException) { failure(ImportFailure.UNREADABLE) - } catch (_: SecurityException) { - failure(ImportFailure.UNREADABLE) - } catch (_: OutOfMemoryError) { + } catch (e: Error) { + if (!e.isOutOfMemory()) throw e failure(ImportFailure.TOO_LARGE) } catch (_: RuntimeException) { // The vault closed while reading, or the snapshot failed. The cause can quote content. diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/InlineSubtaskEdit.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/InlineSubtaskEdit.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/InlineSubtaskEdit.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/InlineSubtaskEdit.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ListLoadErrors.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ListLoadErrors.kt new file mode 100644 index 00000000..bcc87841 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ListLoadErrors.kt @@ -0,0 +1,21 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.core.common.LoadError +import com.pasich.encly.core.common.UiText +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.notes_load_failed_desc +import com.pasich.encly.core.resources.notes_load_failed_title +import com.pasich.encly.core.resources.tags_load_failed_title + +/** The read errors the list screens show. */ +internal object ListLoadErrors { + val NOTES = LoadError( + title = UiText.of(Res.string.notes_load_failed_title), + message = UiText.of(Res.string.notes_load_failed_desc), + ) + + val TAGS = LoadError( + title = UiText.of(Res.string.tags_load_failed_title), + message = UiText.of(Res.string.notes_load_failed_desc), + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockNowViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockNowViewModel.kt new file mode 100644 index 00000000..d7185970 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockNowViewModel.kt @@ -0,0 +1,19 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import com.pasich.encly.core.security.SessionLockManager +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +/** The "Lock now" action of the notes screen. */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class LockNowViewModel(private val sessionLockManager: SessionLockManager) : ViewModel() { + + /** Closes the vault; the app then shows the lock screen. */ + fun lockNow() = sessionLockManager.lockNow() +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt new file mode 100644 index 00000000..95b5e010 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -0,0 +1,219 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.di.ApplicationScope +import com.pasich.encly.core.di.platformIoDispatcher +import com.pasich.encly.core.security.AuthStrategy +import com.pasich.encly.core.security.RecoveryStage +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.core.security.VaultUnlockResult +import com.pasich.encly.platform.ScreenBiometricHost +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The result of a PIN attempt. + * BACKGROUNDED: the credential was right, but the app left the foreground before the vault + * finished opening, so SessionLockManager closed it again. The lock screen stays; no error. + * KEY_LOST: the device-bound PIN key is gone; only the recovery phrase (or fingerprint) unlocks. + * DEVICE_LOCKED: the credential was right, but the operating system reports the screen as locked, so the vault was + * closed again; the lock screen says so. + */ +enum class PinUnlockResult { SUCCESS, WRONG_PIN, LOCKED_OUT, KEY_LOST, DB_ERROR, BACKGROUNDED, DEVICE_LOCKED } +enum class SeedUnlockResult { SUCCESS, WRONG_SEED, DB_ERROR, BACKGROUNDED, DEVICE_LOCKED } + +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Suppress("TooManyFunctions") // One small entry point per unlock factor and capability. +@Inject +class LockViewModel( + private val securityManager: SecurityManager, + private val sessionLockManager: SessionLockManager, + /** Where the rest of a wipe-PIN erase runs once the vault is open; outlives this screen. */ + @param:ApplicationScope private val appScope: CoroutineScope, +) : ViewModel() { + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + /** The open biometric prompt, if any (see [BiometricPromptGuard]). */ + private val biometricPrompt = BiometricPromptGuard() + + /** A biometric prompt is open; a new request is ignored meanwhile. */ + val biometricInFlight: Boolean get() = biometricPrompt.inFlight + + fun strategy(): AuthStrategy = securityManager.authStrategy() + + fun isSeedStrategy(): Boolean = strategy() == AuthStrategy.SEED_PHRASE || + strategy() == AuthStrategy.SEED_PHRASE_BIOMETRIC + + fun biometricEnabled(): Boolean = securityManager.isBiometricEnabled() + fun biometricAvailable(): Boolean = securityManager.biometricAvailable() + fun recoveryAvailable(): Boolean = securityManager.hasRecoverySeed() + fun lockoutRemainingMillis(): Long = securityManager.pinLockoutRemainingMillis() + + /** Whether the session is closed; changes when any lock screen of the host unlocks or the vault locks. */ + val sessionLocked: StateFlow get() = sessionLockManager.locked + + /** + * Whether the open session was unlocked with the recovery phrase and still has no new PIN: whichever window the + * vault was opened from, every lock screen then sends the user to set one before going on. + */ + fun mustResetPin(): Boolean = securityManager.recoveryStage() == RecoveryStage.MUST_SET_PIN + + /** + * What a session unlocked with the recovery phrase owes before Home: a new PIN, a choice between keeping the + * PIN and setting a new one, or nothing. Whichever window the vault was opened from, every lock screen asks. + */ + fun recoveryStage(): RecoveryStage = securityManager.recoveryStage() + + /** Whether the session is closed (again); an unlock reveal then must not open Home. */ + fun isSessionLocked(): Boolean = sessionLockManager.locked.value + + /** + * Whether the note remembered at the re-lock under [savedEpoch] may be reopened: only in the + * vault it was in. After a wipe-PIN erase (here or on another lock screen) it does not + * exist, and an empty editor would give the erase away. + */ + fun canReopenNote(savedEpoch: Long?): Boolean = savedEpoch != null && savedEpoch == securityManager.eraseEpoch() + + /** Unlocks with [pin], which is wiped. */ + fun authenticatePin(pin: CharArray, onResult: (PinUnlockResult) -> Unit) { + launchUnlock(onResult) { + val result = withContext(Dispatchers.Default) { + when (securityManager.unlockWithPin(pin)) { + VaultUnlockResult.SUCCESS -> PinUnlockResult.SUCCESS + VaultUnlockResult.INVALID_CREDENTIAL -> PinUnlockResult.WRONG_PIN + VaultUnlockResult.LOCKED_OUT -> PinUnlockResult.LOCKED_OUT + VaultUnlockResult.PIN_KEY_LOST -> PinUnlockResult.KEY_LOST + VaultUnlockResult.DB_ERROR -> PinUnlockResult.DB_ERROR + } + } + // The rest of a wipe-PIN erase (if that was one) runs while the vault is revealed. + if (result == PinUnlockResult.SUCCESS) appScope.launch { securityManager.completePendingWipe() } + publish( + result == PinUnlockResult.SUCCESS, + result, + PinUnlockResult.BACKGROUNDED, + PinUnlockResult.DEVICE_LOCKED, + ) + } + } + + /** "Keep my PIN" after a recovery-phrase unlock that left the choice; see [RecoveryStage.CHOOSE]. */ + fun keepAfterRecovery(): Boolean = securityManager.keepUnlockMethodsAfterRecovery() + + /** Unlocks with the recovery words (lower case, single spaces); [chars] is wiped afterwards. */ + fun authenticateSeed(chars: CharArray, onResult: (SeedUnlockResult) -> Unit) { + launchUnlock(onResult) { + val result = withContext(Dispatchers.Default) { + try { + when (securityManager.unlockWithSeed(chars)) { + VaultUnlockResult.SUCCESS -> SeedUnlockResult.SUCCESS + VaultUnlockResult.INVALID_CREDENTIAL -> SeedUnlockResult.WRONG_SEED + else -> SeedUnlockResult.DB_ERROR + } + } finally { + SensitiveDataCleaner.clear(chars) + } + } + publish( + result == SeedUnlockResult.SUCCESS, + result, + SeedUnlockResult.BACKGROUNDED, + SeedUnlockResult.DEVICE_LOCKED, + ) + } + } + + /** + * Unlocks with the biometric slot. Ignored while a prompt is open; an answer that arrives + * after the prompt's activity was destroyed (the screen it would report to is gone) is dropped + * and its key wiped. + */ + fun authenticateBiometric(host: ScreenBiometricHost, onResult: (Boolean) -> Unit) { + biometricPrompt.launch(host.owner) { release -> + securityManager.requestBiometricKey(host.biometric) { dek -> + if (!release()) { + dek?.let(SensitiveDataCleaner::clear) + return@requestBiometricKey + } + if (dek == null) { + onResult(false) + return@requestBiometricKey + } + launchUnlock(onResult) { + val ok = withContext(platformIoDispatcher) { + try { + securityManager.unlockWithRawKey(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } + publish(ok, ok, false, false) + } + } + } + } + + /** + * Runs an unlock to its end even if this ViewModel is cleared meanwhile (the KDF and the + * database open cannot be interrupted anyway). An unlock that completes for a cleared + * ViewModel has no screen left to navigate into the vault, so it is closed again + * ([SessionLockManager.onUnlockAbandoned]) instead of staying open in the background. + */ + private fun launchUnlock(onResult: (T) -> Unit, unlock: suspend UnlockScope.() -> T) { + // Read where the unlock starts: a lock that comes in while the key is being checked is then not lost. + val lockRequests = sessionLockManager.lockRequests() + val job = viewModelScope.launch { + val owner = coroutineContext.job + _busy.value = true + val result = withContext(NonCancellable) { UnlockScope(owner, lockRequests).unlock() } + // A cleared screen gets no result: the unlock was already closed again. + ensureActive() + _busy.value = false + onResult(result) + } + job.invokeOnCompletion { _busy.value = false } + } + + /** What an unlock body can see: whether its ViewModel is still alive. */ + private inner class UnlockScope(private val owner: Job, private val lockRequests: Long) { + /** + * The unlock [succeeded]: publish it if the app is in front and the screen still + * exists, otherwise close the vault again and report [backgrounded], or [deviceLocked] when the + * operating system's lock is what refused it. + */ + fun publish(succeeded: Boolean, result: T, backgrounded: T, deviceLocked: T): T = when { + !succeeded -> result + + !owner.isActive -> { + sessionLockManager.onUnlockAbandoned() + backgrounded + } + + sessionLockManager.onUnlocked(lockRequests) -> result + + sessionLockManager.isDeviceLocked() -> deviceLocked + + else -> backgrounded + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LossRecoveryViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LossRecoveryViewModel.kt new file mode 100644 index 00000000..9f63121b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LossRecoveryViewModel.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import com.pasich.encly.core.security.SecurityManager +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +/** + * Full-reset path for unrecoverable local vault corruption/loss. + */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class LossRecoveryViewModel(private val securityManager: SecurityManager) : ViewModel() { + + fun wipeAllData() { + securityManager.wipeAndReset() + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MainListStateViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MainListStateViewModel.kt new file mode 100644 index 00000000..8a7655cb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MainListStateViewModel.kt @@ -0,0 +1,32 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.domain.repository.SettingsRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class MainListStateViewModel(private val settingsRepository: SettingsRepository) : ViewModel() { + + val isGridNoteList: StateFlow = + settingsRepository.isGridNoteList.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false, + ) + + fun updateGridNoteList() { + val value = !isGridNoteList.value + viewModelScope.launch { settingsRepository.setGridNoteList(value) } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt new file mode 100644 index 00000000..44744629 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -0,0 +1,327 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.backup.BackupError +import com.pasich.encly.core.backup.BackupException +import com.pasich.encly.core.common.AppStrings +import com.pasich.encly.core.di.platformIoDispatcher +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_no_picker +import com.pasich.encly.core.resources.main_drawer_tasks +import com.pasich.encly.core.resources.markdown_export_done +import com.pasich.encly.core.resources.note_preview_unreadable +import com.pasich.encly.core.resources.pdf_export_done +import com.pasich.encly.core.resources.print_done +import com.pasich.encly.core.resources.priority_high +import com.pasich.encly.core.resources.priority_low +import com.pasich.encly.core.resources.priority_medium +import com.pasich.encly.core.resources.untitled +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.backup.VaultSnapshot +import com.pasich.encly.data.export.MarkdownDocuments +import com.pasich.encly.data.export.MarkdownExport +import com.pasich.encly.data.export.MarkdownLabels +import com.pasich.encly.data.export.NoteDocument +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.NoteDocumentRenderer +import com.pasich.encly.platform.PrintResult +import com.pasich.encly.utils.todayStamp +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.io.IOException + +/** How a note leaves as a document: a PDF file the user saves, or a printout. */ +enum class DocumentFormat { PDF, PRINT } + +/** What a Markdown export writes (and, on the desktop, one note as a PDF or on paper). */ +sealed interface MarkdownExportTarget { + /** Every note that is not in the trash, and the tasks, as a `.zip` of `.md` files. */ + data object AllData : MarkdownExportTarget + + /** One note, as one `.md` file. */ + data class SingleNote(val noteId: Long) : MarkdownExportTarget + + /** One note as a PDF or a printout, drawn by [renderer] (the host's; Android has none). */ + data class NoteAsDocument(val noteId: Long, val format: DocumentFormat, val renderer: NoteDocumentRenderer) : + MarkdownExportTarget +} + +/** + * The plaintext export of notes as Markdown. The file is **not encrypted**, so the flow is: a + * warning ([BackupStep.ConfirmPlainExport]), then the same re-authentication every export uses + * ([ReauthFlow]: the PIN, or biometrics when enabled), then the system save dialog, then the + * content streams straight into the document the user created there. + * + * - The vault is read only after the save dialog returns, and only through the session that was + * authenticated: a vault that re-locked meanwhile fails instead of exporting. + * - It exports the vault that is open, whichever it is (the decoy vault exports what it holds). + * - Nothing plaintext is held while another app (the save dialog) is in front, and no copy is + * made anywhere else: no temp file, no cache, no share intent. + */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Suppress("TooManyFunctions") // One function per step of the flow, and the helpers of the write. +@Inject +class MarkdownExportViewModel( + private val securityManager: SecurityManager, + private val sessionLockManager: SessionLockManager, + private val store: VaultDataStore, + private val documents: MarkdownDocuments, + private val strings: AppStrings, +) : ViewModel() { + + private val state = BackupFlowState(viewModelScope, null, ::dropPending) + val uiState: StateFlow = state.ui.asStateFlow() + + val reauthFlow = ReauthFlow(state, securityManager, onAuthenticated = ::proceed) + + private var target: MarkdownExportTarget? = null + + /** The vault session that was authenticated; the export reads this one and no other. */ + private var session: VaultDataStore? = null + + init { + // A re-lock drops what the flow holds at once, even while the app is in the background. + viewModelScope.launch { + sessionLockManager.locked.collect { locked -> + if (locked) { + dropPending() + state.ui.update { it.copy(step = BackupStep.Idle, busy = false) } + } + } + } + } + + /** Starts an export of [target]: first the warning that the file will not be encrypted. */ + fun start(target: MarkdownExportTarget) { + dropPending() + this.target = target + state.go( + if (target is MarkdownExportTarget.NoteAsDocument) { + BackupStep.ConfirmDocumentExport(print = target.format == DocumentFormat.PRINT) + } else { + BackupStep.ConfirmPlainExport + }, + ) + } + + /** "Continue" on the warning: re-authenticate before anything is read. */ + fun confirmWarning() { + if (state.step != BackupStep.ConfirmPlainExport && state.step !is BackupStep.ConfirmDocumentExport) return + val biometric = securityManager.canReauthWithBiometric() + state.go(BackupStep.Reauth(BackupAction.EXPORT_MARKDOWN, biometric)) + } + + private fun proceed(action: BackupAction) { + val target = target + // A PIN that verified after the flow was cancelled (or restarted) must not carry on: only the + // re-authentication of the export now waiting for it counts. + val waiting = (state.step as? BackupStep.Reauth)?.action == BackupAction.EXPORT_MARKDOWN + if (action != BackupAction.EXPORT_MARKDOWN || target == null || !waiting) return + val bound = try { + // Tied to this opening of the vault: after a re-lock it fails instead of reading. + store.bindToCurrentSession() + } catch (_: RuntimeException) { + state.fail(BackupError.VAULT_UNAVAILABLE) + return + } + session = bound + if (target is MarkdownExportTarget.NoteAsDocument && target.format == DocumentFormat.PRINT) { + // A printout has no file to pick: the dialog that follows is the printer's. + print(bound, target) + } else { + state.go(pickerFor(target)) + } + } + + /** The save dialog a file export asks for. */ + private fun pickerFor(target: MarkdownExportTarget): BackupStep.PickMarkdownTarget = when (target) { + MarkdownExportTarget.AllData -> BackupStep.PickMarkdownTarget(datedName("encly-notes", "zip"), zip = true) + + is MarkdownExportTarget.NoteAsDocument -> + BackupStep.PickMarkdownTarget(datedName("encly-note", "pdf"), zip = false, pdf = true) + + // A date only, never the title: the name shows in the system file dialog and its Recents. + is MarkdownExportTarget.SingleNote -> BackupStep.PickMarkdownTarget(datedName("encly-note", "md"), zip = false) + } + + /** + * The save dialog is another app. It keeps the vault open for a short while (like the import + * dialog does), because the notes are read when it returns. + */ + fun onSystemPickerLaunched() { + sessionLockManager.allowSystemPicker() + state.go(BackupStep.Idle) + } + + /** No app on this device handles the save dialog. */ + fun onSystemPickerUnavailable() { + sessionLockManager.endSystemPicker() + state.finish(BackupMessage.Text(Res.string.backup_error_no_picker)) + } + + /** The save dialog returned: [uri] is the new, empty document, or null when it was cancelled. */ + fun onTarget(uri: DocumentRef?) { + sessionLockManager.endSystemPicker() + val bound = session + val what = target + dropPending() + if (uri == null) return + if (bound == null || what == null) { + state.launchBusy { discard(uri) } + return + } + state.launchBusy { + try { + export(bound, what, uri) + val done = if (what is MarkdownExportTarget.NoteAsDocument) { + Res.string.pdf_export_done + } else { + Res.string.markdown_export_done + } + state.finish(BackupMessage.Text(done)) + } catch (e: BackupException) { + state.fail(e.error) + } catch (e: CancellationException) { + throw e + } catch (_: RuntimeException) { + // The vault closed under the export (a re-lock): nothing readable was written. + AppLogger.w(TAG, "Markdown export failed") + discard(uri) + state.fail(BackupError.VAULT_UNAVAILABLE) + } + } + } + + private suspend fun export(bound: VaultDataStore, what: MarkdownExportTarget, uri: DocumentRef) { + val snapshot = when (what) { + MarkdownExportTarget.AllData -> bound.snapshot() + + // Only that note and its tag are read, not the whole vault. + is MarkdownExportTarget.SingleNote -> bound.snapshotOfNote(what.noteId) + + is MarkdownExportTarget.NoteAsDocument -> bound.snapshotOfNote(what.noteId) + } + val labels = labels() + // A re-lock that landed after the read must not be followed by a write. + bound.assertCurrentSession() + // Not cancelled by a re-lock clearing this screen: a half-written document would look like + // a complete export. + withContext(NonCancellable + platformIoDispatcher) { + documents.write(uri) { out -> + when (what) { + MarkdownExportTarget.AllData -> MarkdownExport.writeAll(snapshot, labels, out) + + is MarkdownExportTarget.SingleNote -> + if (!MarkdownExport.writeNote(snapshot, what.noteId, labels, out)) { + throw BackupException(BackupError.IO) + } + + // The PDF is built in memory first and written in one go, so a failure leaves no half file. + is MarkdownExportTarget.NoteAsDocument -> + what.renderer.writePdf(documentOf(snapshot, what.noteId, labels), out) + } + } + } + } + + /** The note [noteId] of [snapshot] as a document; fails when the vault has no such note (or it is in the trash). */ + private fun documentOf(snapshot: VaultSnapshot, noteId: Long, labels: MarkdownLabels): NoteDocument { + val note = snapshot.notes.firstOrNull { it.id == noteId && !it.isTrash } + ?: throw BackupException(BackupError.IO) + return NoteDocument.of(note, snapshot.tagsOf(note.id).map { it.nameTag }, labels.untitled, labels.unreadable) + } + + /** Prints the note: read through the authenticated session, handed to the printer's dialog, then forgotten. */ + private fun print(bound: VaultDataStore, what: MarkdownExportTarget.NoteAsDocument) { + dropPending() + state.go(BackupStep.Idle) + state.launchBusy { + try { + val snapshot = bound.snapshotOfNote(what.noteId) + val document = documentOf(snapshot, what.noteId, labels()) + // A re-lock that landed after the read must not be followed by a print. + bound.assertCurrentSession() + // The dialog stays open over a lock (the OS's own window): the printer asks again after it. + val stillAllowed = { + !sessionLockManager.locked.value && runCatching { bound.assertCurrentSession() }.isSuccess + } + when (what.renderer.print(document, stillAllowed)) { + PrintResult.PRINTED -> state.finish(BackupMessage.Text(Res.string.print_done)) + PrintResult.CANCELLED -> Unit + } + } catch (e: BackupException) { + state.fail(e.error) + } catch (e: CancellationException) { + throw e + } catch (_: IOException) { + // The printer or the renderer failed; the note itself was read. + AppLogger.w(TAG, "Print failed") + state.fail(BackupError.IO) + } catch (_: RuntimeException) { + // The vault closed under the print (a re-lock): nothing readable was sent. + AppLogger.w(TAG, "Print failed") + state.fail(BackupError.VAULT_UNAVAILABLE) + } + } + } + + /** Removes the empty document the save dialog created, off the main thread. */ + private suspend fun discard(uri: DocumentRef) = + withContext(NonCancellable + platformIoDispatcher) { documents.discard(uri) } + + fun cancel() { + sessionLockManager.endSystemPicker() + dropPending() + state.go(BackupStep.Idle) + } + + fun clearMessage() { + state.ui.update { it.copy(message = null) } + } + + override fun onCleared() { + dropPending() + super.onCleared() + } + + private fun dropPending() { + target = null + session = null + } + + private suspend fun labels() = MarkdownLabels( + untitled = strings.get(Res.string.untitled), + unreadable = strings.get(Res.string.note_preview_unreadable), + tasks = strings.get(Res.string.main_drawer_tasks), + priorities = mapOf( + HIGH to strings.get(Res.string.priority_high), + MEDIUM to strings.get(Res.string.priority_medium), + LOW to strings.get(Res.string.priority_low), + ), + ) + + /** "encly-notes-2026-10-25.zip": a date only, never a title. */ + private fun datedName(prefix: String, extension: String): String = "$prefix-${todayStamp()}.$extension" + + private companion object { + const val TAG = "MarkdownExport" + const val HIGH = 2 + const val MEDIUM = 1 + const val LOW = 0 + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt index 4d2fdbd9..41a9978e 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt @@ -21,7 +21,11 @@ import com.pasich.encly.domain.usecase.note.UpdateNoteDescriptionUseCase import com.pasich.encly.domain.usecase.note.UpdateNotePinStatusUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTagUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -35,13 +39,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class NoteListViewModel -@Suppress("LongParameterList") // Hilt-injected dependencies, one use case per action on a note. +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Suppress("LongParameterList") // Injected dependencies, one use case per action on a note. @Inject -constructor( +class NoteListViewModel( private val observeNotesUseCase: ObserveNotesUseCase, private val updateNoteTrashStatusUseCase: UpdateNoteTrashStatusUseCase, private val updateNoteTagUseCase: UpdateNoteTagUseCase, @@ -149,7 +152,7 @@ constructor( is NoteListEvent.ChangeColor -> updateNoteColorUseCase(event.note, event.color).isSuccess - is NoteListEvent.ChangeTag -> updateNoteTagUseCase(event.note, event.idTag).isSuccess + is NoteListEvent.ChangeTag -> updateNoteTagUseCase(event.note, event.idTag, event.tagged).isSuccess is NoteListEvent.ChangeDescription -> updateNoteDescriptionUseCase(event.note, event.description).isSuccess @@ -172,7 +175,9 @@ sealed class NoteListEvent { data class PinNote(val note: Note, val pinned: Boolean) : NoteListEvent() data class ArchiveNote(val note: Note, val archived: Boolean) : NoteListEvent() data class ChangeColor(val note: Note, val color: NoteColor) : NoteListEvent() - data class ChangeTag(val note: Note, val idTag: Long) : NoteListEvent() + + /** Puts the tag [idTag] on the note or takes it off ([tagged]); 0 takes every tag off. */ + data class ChangeTag(val note: Note, val idTag: Long, val tagged: Boolean = true) : NoteListEvent() data class ChangeDescription(val note: Note, val description: String) : NoteListEvent() data class ToggleNoteSort(val option: NoteSortOption) : NoteListEvent() } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NotePreviewsViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NotePreviewsViewModel.kt new file mode 100644 index 00000000..468df0fb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NotePreviewsViewModel.kt @@ -0,0 +1,20 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import com.pasich.encly.domain.model.NotePreviews +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.flow.StateFlow + +/** Whether the note lists show a preview of the text (see [NotePreviews]). */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class NotePreviewsViewModel(private val previews: NotePreviews) : ViewModel() { + val show: StateFlow = previews.show + + fun setShow(enabled: Boolean) = previews.setShow(enabled) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt new file mode 100644 index 00000000..50fe6729 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt @@ -0,0 +1,98 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.common.asLoadState +import com.pasich.encly.core.common.valueOrNull +import com.pasich.encly.core.di.IoDispatcher +import com.pasich.encly.core.security.NeverLocked +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.domain.enums.NoteSortOption +import com.pasich.encly.domain.model.NoteListItem +import com.pasich.encly.domain.usecase.note.ObserveNotesUseCase +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach + +/** + * Searches every note that is not in the trash and whose tag is not hidden, whatever tag chip + * the note list shows, and returns every match. The notes' text is parsed once per database + * change (in [ObserveNotesUseCase]); a keystroke only filters, after a short pause. + */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class NoteSearchViewModel( + observeNotesUseCase: ObserveNotesUseCase, + @IoDispatcher dispatcher: CoroutineDispatcher, + lockEvents: VaultLockEvents = NeverLocked, +) : ViewModel() { + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val notes = observeNotesUseCase(tagId = null, sortOption = NoteSortOption.UPDATED_DESC) + // A read failure is shown by the note list behind the search; here it only means + // no results, never a crash. + .asLoadState(ListLoadErrors.NOTES) + .mapNotNull { it.valueOrNull() } + + private val _results = MutableStateFlow(SearchResults()) + + /** Kept in a state of its own, not shared from the flow, so a lock can empty it at any time. */ + val results: StateFlow = _results.asStateFlow() + + private var searchJob: Job? = null + + init { + search(dispatcher) + clearOnLock(lockEvents) { + searchJob?.cancel() + _query.value = "" + _results.value = SearchResults() + } + } + + @OptIn(FlowPreview::class) + private fun search(dispatcher: CoroutineDispatcher) { + searchJob = combine(_query.debounce { if (it.isBlank()) 0L else SEARCH_DEBOUNCE_MS }, notes) { query, all -> + val needle = query.trim() + SearchResults( + query = needle, + notes = if (needle.isEmpty()) emptyList() else all.filter { it.matches(needle) }, + ) + } + .flowOn(dispatcher) + .onEach { _results.value = it } + .launchIn(viewModelScope) + } + + fun onQueryChange(query: String) { + _query.value = query + } + + fun clear() { + _query.value = "" + } + + /** The notes matching [query]; [query] is the trimmed text they were searched for. */ + data class SearchResults(val query: String = "", val notes: List = emptyList()) + + private companion object { + const val SEARCH_DEBOUNCE_MS = 200L + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteTabsViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteTabsViewModel.kt new file mode 100644 index 00000000..8fa3fff2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteTabsViewModel.kt @@ -0,0 +1,34 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import com.pasich.encly.core.security.NeverLocked +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.presentation.pane.NoteTabs +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +/** + * The open notes of the expanded layout's detail pane. The screen asks for it from the home destination's + * entry, so it outlives the home composition while another page is on top, and goes with the entry when the + * vault locks (the lock screen replaces the whole back stack): no tab, and no title, survives a re-lock. + */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class NoteTabsViewModel(lockEvents: VaultLockEvents = NeverLocked) : ViewModel() { + /** The tabs. */ + val tabs = NoteTabs() + + init { + // The tabs are dropped the moment the session locks, not when the lock screen gets to replace this entry. + clearOnLock(lockEvents) { tabs.clear() } + } + + override fun onCleared() { + tabs.clear() + super.onCleared() + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/NotesScrollToTop.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NotesScrollToTop.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/NotesScrollToTop.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NotesScrollToTop.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt index 2d84416e..a78f6ab4 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt @@ -1,20 +1,32 @@ package com.pasich.encly.presentation.viewmodel -import android.net.Uri -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.pasich.encly.R import com.pasich.encly.core.backup.BackupCipher import com.pasich.encly.core.backup.BackupError import com.pasich.encly.core.backup.BackupException import com.pasich.encly.core.common.UiText -import com.pasich.encly.core.security.PIN_LENGTH +import com.pasich.encly.core.di.platformIoDispatcher +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_no_picker +import com.pasich.encly.core.resources.onboarding_error_initialization +import com.pasich.encly.core.resources.onboarding_error_seed_generation +import com.pasich.encly.core.resources.onboarding_error_vault_creation +import com.pasich.encly.core.resources.pin_mismatch_retry +import com.pasich.encly.core.resources.pin_save_failed +import com.pasich.encly.core.security.MAX_PIN_LENGTH +import com.pasich.encly.core.security.MIN_PIN_LENGTH import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.domain.usecase.OnboardingUseCase +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.ScreenBiometricHost import com.pasich.encly.presentation.screen.backup.backupErrorMessage -import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,7 +34,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject enum class SecurityType { /** @@ -77,8 +88,10 @@ data class StepProgress(val step: Int, val total: Int) * "Open my notebook". An abandoned flow leaves no committed vault, so the next start simply * begins onboarding again. */ -@HiltViewModel -class OnboardingViewModel @Inject constructor( +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class OnboardingViewModel( private val onboardingUseCase: OnboardingUseCase, private val backupDocuments: BackupDocuments, ) : ViewModel() { @@ -88,6 +101,9 @@ class OnboardingViewModel @Inject constructor( ) val uiState: StateFlow = _uiState.asStateFlow() + /** The open fingerprint prompt, if any (see [BiometricPromptGuard]). */ + private val biometricPrompt = BiometricPromptGuard() + /** The encrypted backup picked for "Restore from backup". Ciphertext only. */ private var restoreFile: ByteArray? = null @@ -98,7 +114,7 @@ class OnboardingViewModel @Inject constructor( private var recoverySeed: CharArray? = null /** Digits typed on the PIN screen; only the first [OnboardingUiState.pinLength] count. */ - private val pinEntry = CharArray(PIN_LENGTH) + private val pinEntry = CharArray(MAX_PIN_LENGTH) /** The first PIN entry while the user types it again. */ private var firstPin: CharArray? = null @@ -160,7 +176,7 @@ class OnboardingViewModel @Inject constructor( fun onPinDigit(digit: Int) { val length = _uiState.value.pinLength - if (length >= PIN_LENGTH || digit !in 0..MAX_DIGIT) return + if (length >= MAX_PIN_LENGTH || digit !in 0..MAX_DIGIT) return pinEntry[length] = '0' + digit _uiState.update { it.copy(pinLength = length + 1, pinError = null) } } @@ -182,8 +198,8 @@ class OnboardingViewModel @Inject constructor( */ fun submitPin() { val state = _uiState.value - if (state.pinLength != PIN_LENGTH || state.isLoading) return - val entered = pinEntry.copyOf() + if (state.pinLength < MIN_PIN_LENGTH || state.isLoading) return + val entered = pinEntry.copyOf(state.pinLength) SensitiveDataCleaner.clear(pinEntry) val first = firstPin if (first == null) askPinAgain(entered) else confirmPin(first, entered, state.path) @@ -199,7 +215,7 @@ class OnboardingViewModel @Inject constructor( SensitiveDataCleaner.clear(entered) if (!matches) { resetPin() - _uiState.update { it.copy(pinError = UiText.of(R.string.pin_mismatch_retry)) } + _uiState.update { it.copy(pinError = UiText.of(Res.string.pin_mismatch_retry)) } return } firstPin = null @@ -238,7 +254,7 @@ class OnboardingViewModel @Inject constructor( reachReady() } else { _uiState.update { - it.copy(isLoading = false, error = UiText.of(R.string.onboarding_error_initialization)) + it.copy(isLoading = false, error = UiText.of(Res.string.onboarding_error_initialization)) } } } @@ -249,9 +265,9 @@ class OnboardingViewModel @Inject constructor( wipeRecoverySeed() recoverySeed = seed // The words must be shown to be written down, so the UI gets them as text. - _uiState.update { it.copy(words = String(seed).split(" ").filter(String::isNotBlank)) } + _uiState.update { it.copy(words = seed.concatToString().split(" ").filter(String::isNotBlank)) } }.onFailure { - _uiState.update { it.copy(error = UiText.of(R.string.onboarding_error_seed_generation)) } + _uiState.update { it.copy(error = UiText.of(Res.string.onboarding_error_seed_generation)) } } } @@ -296,7 +312,7 @@ class OnboardingViewModel @Inject constructor( reachReady() } else { _uiState.update { - it.copy(isLoading = false, error = UiText.of(R.string.onboarding_error_vault_creation)) + it.copy(isLoading = false, error = UiText.of(Res.string.onboarding_error_vault_creation)) } } } @@ -326,7 +342,7 @@ class OnboardingViewModel @Inject constructor( val configured = pin != null && withContext(Dispatchers.Default) { onboardingUseCase.configurePin(pin.copyOf()) } if (!configured) { - _uiState.update { it.copy(isLoading = false, error = UiText.of(R.string.pin_save_failed)) } + _uiState.update { it.copy(isLoading = false, error = UiText.of(Res.string.pin_save_failed)) } return } chosenPin?.let(SensitiveDataCleaner::clear) @@ -340,18 +356,41 @@ class OnboardingViewModel @Inject constructor( verificationWords = emptyList(), userAnswers = emptyMap(), isVerificationComplete = false, - biometricPending = it.biometricRequested && it.biometricAvailable, + biometricPending = it.step != OnboardingStep.READY && it.biometricRequested && it.biometricAvailable, ) } } - /** Shows the system biometric prompt that adds the fingerprint slot. */ - fun enrollBiometric(activity: FragmentActivity) { + /** + * Shows the system biometric prompt that adds the fingerprint slot, once per Ready entry: the request is + * consumed here, so nothing that recomposes or resumes the screen can ask again. A cancel or a failure + * does not repeat the prompt; it leaves [OnboardingUiState.biometricRetry] for the button to try again. + */ + fun enrollBiometric(host: ScreenBiometricHost) { if (!_uiState.value.biometricPending) return _uiState.update { it.copy(biometricPending = false) } - onboardingUseCase.enrollBiometric(activity) { ok -> - _uiState.update { it.copy(biometricEnabled = ok) } + showEnrolmentPrompt(host) + } + + /** "Try the fingerprint again": one more prompt for one tap, never while a prompt is open. */ + fun retryBiometric(host: ScreenBiometricHost) { + val state = _uiState.value + if (state.step != OnboardingStep.READY || !state.biometricRetry || biometricPrompt.inFlight) return + _uiState.update { it.copy(biometricRetry = false) } + showEnrolmentPrompt(host) + } + + private fun showEnrolmentPrompt(host: ScreenBiometricHost) { + var shown = false + biometricPrompt.launch(host.owner) { release -> + shown = true + onboardingUseCase.enrollBiometric(host.biometric) { ok -> + release() + _uiState.update { it.copy(biometricEnabled = ok, biometricRetry = !ok) } + } } + // A window that cannot show a prompt (stopped) or one already open: the button stays to try again. + if (!shown && !biometricPrompt.inFlight) _uiState.update { it.copy(biometricRetry = true) } } /** No activity to show the prompt in: continue with the PIN only. */ @@ -363,15 +402,15 @@ class OnboardingViewModel @Inject constructor( /** No app on this device can open documents: say so instead of crashing. */ fun onRestorePickerUnavailable() { - _uiState.update { it.copy(restoreFileError = UiText.of(R.string.backup_error_no_picker)) } + _uiState.update { it.copy(restoreFileError = UiText.of(Res.string.backup_error_no_picker)) } } /** Reads the picked file and checks its header; the phrase is asked for only if it fits. */ - fun onRestoreFilePicked(uri: Uri?) { + fun onRestoreFilePicked(uri: DocumentRef?) { if (uri == null) return _uiState.update { it.copy(isLoading = true, restoreFileError = null, restorePhraseError = null) } viewModelScope.launch { - val picked = withContext(Dispatchers.IO) { readBackup(uri) } + val picked = withContext(platformIoDispatcher) { readBackup(uri) } _uiState.update { it.copy( isLoading = false, @@ -383,7 +422,7 @@ class OnboardingViewModel @Inject constructor( } } - private fun readBackup(uri: Uri): PickedBackup { + private fun readBackup(uri: DocumentRef): PickedBackup { val name = backupDocuments.displayName(uri) return try { val file = backupDocuments.read(uri) @@ -495,6 +534,8 @@ class OnboardingViewModel @Inject constructor( // Ready val biometricPending: Boolean = false, val biometricEnabled: Boolean = false, + /** The fingerprint prompt was cancelled or failed: Ready offers a button to try it again. */ + val biometricRetry: Boolean = false, ) { /** The header's progress, or null on Welcome and Restore, which have none. */ val progress: StepProgress? diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt similarity index 77% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index a4968932..6b8e6d01 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -1,10 +1,14 @@ package com.pasich.encly.presentation.viewmodel -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.pasich.encly.R import com.pasich.encly.core.common.UiText +import com.pasich.encly.core.di.platformIoDispatcher +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.biometric_change_not_confirmed +import com.pasich.encly.core.resources.biometric_enroll_failed +import com.pasich.encly.core.resources.pin_current_wrong +import com.pasich.encly.core.resources.pin_update_failed import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock import com.pasich.encly.core.security.AutoLockDelay @@ -12,24 +16,32 @@ 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.ScreenshotAllowance import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.core.security.WipePinChange -import dagger.hilt.android.lifecycle.HiltViewModel +import com.pasich.encly.platform.ScreenBiometricHost +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -@HiltViewModel +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey @Suppress("TooManyFunctions") // One small entry point per setting on the Security page. -class SecuritySettingsViewModel @Inject constructor( +@Inject +class SecuritySettingsViewModel( private val securityManager: SecurityManager, private val keyboardPrivacy: KeyboardPrivacy, private val autoLock: AutoLock, + private val screenshots: ScreenshotAllowance, ) : ViewModel() { private val _uiState = MutableStateFlow(SecuritySettingsUiState()) @@ -51,6 +63,14 @@ class SecuritySettingsViewModel @Inject constructor( fun setAutoLockDelay(delay: AutoLockDelay) = autoLock.setDelay(delay) + /** + * "Allow screenshots until the app locks" is on. Turning it on goes through re-authentication + * ([BackupAction.ALLOW_SCREENSHOTS]); turning it off needs none, and so does the next lock. + */ + val screenshotsAllowed: StateFlow = screenshots.allowed + + fun stopAllowingScreenshots() = screenshots.revoke() + init { refresh() } @@ -58,7 +78,7 @@ class SecuritySettingsViewModel @Inject constructor( /** Reloads the vault's security state, e.g. after a recovery phrase was added. */ fun refresh() { viewModelScope.launch { - val authSettings = withContext(Dispatchers.IO) { securityManager.getSettingsAuth() } + val authSettings = withContext(platformIoDispatcher) { securityManager.getSettingsAuth() } _uiState.value = _uiState.value.copy( isUserCreatedSeedKey = authSettings.isUserCreatedSeedKey, authType = authSettings.authType, @@ -92,7 +112,7 @@ class SecuritySettingsViewModel @Inject constructor( } } if (!ok) { - _uiState.value = _uiState.value.copy(error = UiText.of(R.string.pin_current_wrong)) + _uiState.value = _uiState.value.copy(error = UiText.of(Res.string.pin_current_wrong)) } onResult(ok) } @@ -109,12 +129,14 @@ class SecuritySettingsViewModel @Inject constructor( } } when (result) { - PinChange.SET -> _uiState.value = _uiState.value.copy(authType = AuthType.PIN) + PinChange.SET -> _uiState.value = _uiState.value.copy(authType = AuthType.PIN, hasPinSlot = true) // 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)) + PinChange.FAILED -> + _uiState.value = + _uiState.value.copy(error = UiText.of(Res.string.pin_update_failed)) } onResult(result) } @@ -135,7 +157,7 @@ class SecuritySettingsViewModel @Inject constructor( /** Turns the wipe PIN off (whether one was set is never known). */ fun removeWipePin(onResult: (Boolean) -> Unit) { viewModelScope.launch { - val ok = withContext(Dispatchers.IO) { securityManager.removeWipePin() } + val ok = withContext(platformIoDispatcher) { securityManager.removeWipePin() } if (ok) _uiState.value = _uiState.value.copy(wipePinTurnedOff = false) onResult(ok) } @@ -158,7 +180,7 @@ class SecuritySettingsViewModel @Inject constructor( /** 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() } + val ok = withContext(platformIoDispatcher) { securityManager.removeDuressPin() } if (ok) _uiState.value = _uiState.value.copy(hasDuressPin = false, duressPinTurnedOff = false) onResult(ok) } @@ -170,24 +192,24 @@ class SecuritySettingsViewModel @Inject constructor( * Taps while either prompt is open are ignored: a second enrolment would replace the key the * first one is wrapping. */ - fun toggleBiometric(activity: FragmentActivity, enable: Boolean) { - biometricPrompt.launch(activity) { release -> - if (enable) enrollBiometric(activity, release) else disableBiometric(activity, release) + fun toggleBiometric(host: ScreenBiometricHost, enable: Boolean) { + biometricPrompt.launch(host.owner) { release -> + if (enable) enrollBiometric(host, release) else disableBiometric(host, release) } } - private fun enrollBiometric(activity: FragmentActivity, release: () -> Boolean) { - securityManager.enrollBiometric(activity) { ok -> + private fun enrollBiometric(host: ScreenBiometricHost, release: () -> Boolean) { + securityManager.enrollBiometric(host.biometric) { ok -> release() _uiState.value = _uiState.value.copy( biometricEnable = ok && securityManager.isBiometricEnabled(), - error = if (ok) null else UiText.of(R.string.biometric_enroll_failed), + error = if (ok) null else UiText.of(Res.string.biometric_enroll_failed), ) } } - private fun disableBiometric(activity: FragmentActivity, release: () -> Boolean) { - securityManager.confirmBiometric(activity) { confirmed -> + private fun disableBiometric(host: ScreenBiometricHost, release: () -> Boolean) { + securityManager.confirmBiometric(host.biometric) { confirmed -> release() if (confirmed) { securityManager.disableBiometric() @@ -195,7 +217,7 @@ class SecuritySettingsViewModel @Inject constructor( } else { _uiState.value = _uiState.value.copy( biometricEnable = securityManager.isBiometricEnabled(), - error = UiText.of(R.string.biometric_change_not_confirmed), + error = UiText.of(Res.string.biometric_change_not_confirmed), ) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt new file mode 100644 index 00000000..c211dae6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt @@ -0,0 +1,89 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.core.security.NeverLocked +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.data.model.Tag +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlin.concurrent.Volatile + +/** + * The tag chip the note list is filtered by, shared by the tag row and the note list of one window. It belongs to + * the open session: a lock drops it at once, so the next vault (the decoy one after a duress PIN) never starts + * filtered by, or naming, a tag of the vault that was closed. A host with several windows gives each window its own + * (see [WindowSelections]); [close] ends the watch on the lock when the window goes. + */ +class SelectedTagHolder(lockEvents: VaultLockEvents = NeverLocked) { + private val _selectedTagFlow = MutableSharedFlow(replay = 1) + val selectedTagFlow: SharedFlow = _selectedTagFlow.asSharedFlow() + + // Unconfined: the reset runs inside the lock itself, before any later unlock can publish. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + init { + scope.launch { + lockEvents.locked.collect { locked -> if (locked) _selectedTagFlow.tryEmit(Tag()) } + } + } + + fun selectTag(tag: Tag) { + _selectedTagFlow.tryEmit(tag) + } + + /** The window this belongs to is gone: stop watching the lock. */ + fun close() { + scope.cancel() + } +} + +/** + * Which window's [SelectedTagHolder] the ViewModels being created now get. A window's ViewModel factory sets it + * around every creation (see `AppViewModelFactory.forWindow`), on the UI thread where they are created; nothing + * set means the process-wide one of a host with a single window. + */ +object WindowSelections { + @Volatile + private var creating: SelectedTagHolder? = null + + /** Runs [create] so that every [SelectedTagHolder] it asks for is [holder]. */ + fun creatingFor(holder: SelectedTagHolder, create: () -> T): T { + val before = creating + creating = holder + try { + return create() + } finally { + creating = before + } + } + + internal fun current(): SelectedTagHolder? = creating +} + +/** The tag selection of a host with one window, which also serves a window that has none of its own. */ +@SingleIn(AppScope::class) +@Inject +class SelectedTags(lockEvents: VaultLockEvents) { + private val shared = SelectedTagHolder(lockEvents) + + /** The holder for a ViewModel being created now. */ + fun forViewModel(): SelectedTagHolder = WindowSelections.current() ?: shared +} + +@ContributesTo(AppScope::class) +@BindingContainer +object SelectedTagModule { + @Provides + fun provideSelectedTagHolder(tags: SelectedTags): SelectedTagHolder = tags.forViewModel() +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt similarity index 83% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt index 9321c237..022b941b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt @@ -3,21 +3,27 @@ package com.pasich.encly.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pasich.encly.core.locale.AppLanguage -import com.pasich.encly.core.locale.AppLocales import com.pasich.encly.domain.model.SwipeAction import com.pasich.encly.domain.model.SwipeSettings import com.pasich.encly.domain.model.SwipeSide import com.pasich.encly.domain.repository.SettingsRepository -import dagger.hilt.android.lifecycle.HiltViewModel +import com.pasich.encly.platform.AppLocaleController +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class SettingsViewModel @Inject constructor(private val settingsRepository: SettingsRepository) : ViewModel() { +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class SettingsViewModel(private val settingsRepository: SettingsRepository, private val locales: AppLocaleController) : + ViewModel() { val languageDialogVisible = MutableStateFlow(false) @@ -50,7 +56,7 @@ class SettingsViewModel @Inject constructor(private val settingsRepository: Sett } /** Read live, not cached: this ViewModel outlives the activity a language change recreates. */ - fun currentLanguage(): AppLanguage = AppLocales.current() + fun currentLanguage(): AppLanguage = locales.current() fun setLanguageDialogVisibility(value: Boolean) { languageDialogVisible.value = value @@ -58,7 +64,7 @@ class SettingsViewModel @Inject constructor(private val settingsRepository: Sett fun selectLanguage(language: AppLanguage) { languageDialogVisible.value = false - if (language != AppLocales.current()) AppLocales.apply(language) + if (language != locales.current()) locales.apply(language) } /** DataStore writes are main-safe; no dispatcher switch is needed. */ diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StartupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StartupViewModel.kt new file mode 100644 index 00000000..10d7dec0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StartupViewModel.kt @@ -0,0 +1,53 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.domain.usecase.note.PurgeExpiredTrashUseCase +import com.pasich.encly.whatsnew.BundledChangelogs +import com.pasich.encly.whatsnew.Changelogs +import com.pasich.encly.whatsnew.ReleaseNotes +import com.pasich.encly.whatsnew.WhatsNewTracker +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * What the app does once per start and once per unlock, off any screen's own business: tells [WhatsNewTracker] + * about a fresh install, and, when the vault has been opened, empties the trash of notes older than 28 days and + * decides whether "What's new" is due. + */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class StartupViewModel(private val whatsNew: WhatsNewTracker, private val purgeTrash: PurgeExpiredTrashUseCase) : + ViewModel() { + private val _whatsNewDue = MutableStateFlow(null) + + /** The notes to show in the "What's new" sheet, or null when none is due (or it was dismissed). */ + val whatsNewDue: StateFlow = _whatsNewDue.asStateFlow() + + private var unlocked = false + + /** The app started: a [freshInstall] has seen no version before this one, so it has nothing new to read. */ + fun onAppStart(freshInstall: Boolean) { + if (freshInstall) whatsNew.recordFreshInstall() + } + + /** The vault is open and the home screen is shown (once per ViewModel): clean the trash, and check the version. */ + fun onUnlocked(languageTag: String, bundled: Map = BundledChangelogs.notes) { + if (unlocked) return + unlocked = true + viewModelScope.launch { purgeTrash() } + if (whatsNew.takeUnseen()) _whatsNewDue.value = Changelogs.notes(languageTag, bundled) + } + + fun dismissWhatsNew() { + _whatsNewDue.value = null + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt similarity index 78% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt index 372c6d1e..7def31d2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt @@ -5,19 +5,23 @@ import androidx.lifecycle.viewModelScope import com.pasich.encly.core.AppLogger import com.pasich.encly.domain.repository.TagsRepository import com.pasich.encly.domain.repository.TasksRepository -import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject private const val TAG = "StatisticViewModel" -@HiltViewModel -class StatisticViewModel @Inject constructor(tagsRepository: TagsRepository, tasksRepository: TasksRepository) : - ViewModel() { +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class StatisticViewModel(tagsRepository: TagsRepository, tasksRepository: TasksRepository) : ViewModel() { /** * The counts below read the vault while the drawer is open. A read that fails (the vault was diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StorageUnreadableViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StorageUnreadableViewModel.kt new file mode 100644 index 00000000..a8dc06ff --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StorageUnreadableViewModel.kt @@ -0,0 +1,45 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The retry of a start that could not read the vault's state file ([InitialStatus.STORAGE_UNREADABLE]): nothing + * is wiped, the start is simply asked again. + */ +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class StorageUnreadableViewModel( + private val securityManager: SecurityManager, + private val sessionLockManager: SessionLockManager, +) : ViewModel() { + private val _stillUnreadable = MutableStateFlow(false) + + /** True once a retry found the file still unreadable. */ + val stillUnreadable: StateFlow = _stillUnreadable.asStateFlow() + + /** Resolves the start again and reports where it leads; a committed vault that is not open starts locked. */ + fun retry(onStatus: (InitialStatus) -> Unit) { + viewModelScope.launch { + val status = withContext(Dispatchers.Default) { securityManager.resolveInitialStatus() } + _stillUnreadable.value = status == InitialStatus.STORAGE_UNREADABLE + if (status == InitialStatus.AUTH) sessionLockManager.requireUnlock() + onStatus(status) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt similarity index 86% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt index 35b72777..2c580400 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt @@ -5,11 +5,17 @@ import androidx.lifecycle.viewModelScope import com.pasich.encly.core.common.LoadState import com.pasich.encly.core.common.asLoadState import com.pasich.encly.core.common.valueOrNull +import com.pasich.encly.core.security.NeverLocked +import com.pasich.encly.core.security.VaultLockEvents import com.pasich.encly.data.model.Tag import com.pasich.encly.domain.repository.NotesRepository import com.pasich.encly.domain.repository.TagsRepository import com.pasich.encly.domain.usecase.tag.ReorderTagsUseCase -import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -20,11 +26,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject enum class TagOperationFailure { CREATE, @@ -57,12 +61,15 @@ internal fun List.hasTagNamed(name: String, exceptId: Long? = null): Boolea return any { it.id != exceptId && it.nameTag.trim().equals(wanted, ignoreCase = true) } } -@HiltViewModel -class TagListViewModel @Inject constructor( +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class TagListViewModel( private val tagsRepository: TagsRepository, private val reorderTagsUseCase: ReorderTagsUseCase, private val selectedTagHolder: SelectedTagHolder, notesRepository: NotesRepository, + lockEvents: VaultLockEvents = NeverLocked, ) : ViewModel() { private val _state = MutableStateFlow(TagListState()) @@ -73,23 +80,31 @@ class TagListViewModel @Inject constructor( /** * How many notes (not in the trash, archived ones included) carry each tag, by tag id. Read - * only by the tags screen: deleting a tag untags its archived notes too. + * only by the tags screen: deleting a tag untags its archived notes too. One query, so a note + * being archived is never counted twice. */ val noteCounts: StateFlow> = - combine(notesRepository.getAllNotesWithTag(), notesRepository.getArchivedNotesWithTag()) { live, archived -> - (live + archived).mapNotNull { it.note.tagId }.groupingBy { it }.eachCount() - } + notesRepository.tagNoteCounts() .catch { emit(emptyMap()) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyMap()) private var persistedTags: List = emptyList() private var reorderJob: Job? = null + private var tagsJob: Job? = null + init { loadTags() + clearOnLock(lockEvents) { + tagsJob?.cancel() + reorderJob?.cancel() + persistedTags = emptyList() + _state.update { it.copy(tagsLoad = LoadState.Loading) } + } viewModelScope.launch { selectedTagHolder.selectedTagFlow.collect { selectedTag -> _state.update { it.copy(selectedTagId = selectedTag.id) } + dropForeignSelection() } } } @@ -189,11 +204,19 @@ class TagListViewModel @Inject constructor( } } + /** A selection that is not a tag of the open vault (left over from another one) is dropped. */ + private fun dropForeignSelection() { + val selected = _state.value.selectedTagId + val tags = _state.value.tagsLoad.valueOrNull() ?: return + if (selected != 0L && tags.none { it.id == selected }) selectedTagHolder.selectTag(Tag()) + } + private fun loadTags() { - viewModelScope.launch { + tagsJob = viewModelScope.launch { tagsRepository.getTags().asLoadState(ListLoadErrors.TAGS).collect { load -> load.valueOrNull()?.let { persistedTags = it } _state.update { it.copy(tagsLoad = load) } + dropForeignSelection() } } } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TaskFilterEngine.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TaskFilterEngine.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/TaskFilterEngine.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TaskFilterEngine.kt index 8cc77d06..ea7a466d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TaskFilterEngine.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TaskFilterEngine.kt @@ -1,7 +1,13 @@ package com.pasich.encly.presentation.viewmodel -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.priority_high +import com.pasich.encly.core.resources.priority_low +import com.pasich.encly.core.resources.priority_medium +import com.pasich.encly.core.resources.task_filter_all +import com.pasich.encly.core.resources.task_filter_completed import com.pasich.encly.data.model.Task +import org.jetbrains.compose.resources.StringResource /** * The task list's filtering and counting, as pure functions of the stored tasks and the @@ -12,10 +18,10 @@ internal object TaskFilterEngine { private const val COMPLETED_FILTER_ID = "completed" private const val PERCENT = 100 - private enum class PriorityChip(val id: String, val priority: Int, val label: Int) { - HIGH("priority_high", 2, R.string.priority_high), - MEDIUM("priority_medium", 1, R.string.priority_medium), - LOW("priority_low", 0, R.string.priority_low), + private enum class PriorityChip(val id: String, val priority: Int, val label: StringResource) { + HIGH("priority_high", 2, Res.string.priority_high), + MEDIUM("priority_medium", 1, Res.string.priority_medium), + LOW("priority_low", 0, Res.string.priority_low), } /** @@ -147,8 +153,12 @@ internal object TaskFilterEngine { } private fun allFilter(tasks: List): TaskFilter = - TaskFilter(ALL_FILTER_ID, R.string.task_filter_all, tasks.size, TaskFilter.Type.ACTIVE) - - private fun completedFilter(completedTasks: List): TaskFilter = - TaskFilter(COMPLETED_FILTER_ID, R.string.task_filter_completed, completedTasks.size, TaskFilter.Type.COMPLETED) + TaskFilter(ALL_FILTER_ID, Res.string.task_filter_all, tasks.size, TaskFilter.Type.ACTIVE) + + private fun completedFilter(completedTasks: List): TaskFilter = TaskFilter( + COMPLETED_FILTER_ID, + Res.string.task_filter_completed, + completedTasks.size, + TaskFilter.Type.COMPLETED, + ) } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt similarity index 94% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt index 6f60dc4f..4bb5159c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt @@ -1,12 +1,13 @@ package com.pasich.encly.presentation.viewmodel -import androidx.annotation.StringRes import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import com.pasich.encly.core.AppLogger import com.pasich.encly.core.security.NeverLocked import com.pasich.encly.core.security.VaultLockEvents @@ -16,7 +17,13 @@ import com.pasich.encly.domain.repository.TasksRepository import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase import com.pasich.encly.presentation.dialogs.tasks.SUBTASK_TITLE_MAX_LENGTH import com.pasich.encly.presentation.dialogs.tasks.SubtaskListState -import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -29,7 +36,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import javax.inject.Inject +import org.jetbrains.compose.resources.StringResource private const val TAG = "TasksViewModel" @@ -37,7 +44,7 @@ private const val TAG = "TasksViewModel" data class TaskFilter( val id: String, /** Resolved in the UI so the chip follows the in-app language. */ - @param:StringRes val label: Int, + val label: StringResource, val count: Int = 0, val type: Type, ) { @@ -101,12 +108,12 @@ data class TasksUiState( * The Tasks screen: the lists and filters, the editor sheet, and the list's own sub-task * editing (each task's tree opens on a tap; one inline field adds or renames a sub-task). */ -@HiltViewModel -class TasksViewModel @Inject constructor( +@AssistedInject +class TasksViewModel( private val tasksRepository: TasksRepository, private val updateTaskStatusUseCase: UpdateTaskStatusUseCase, lockEvents: VaultLockEvents = NeverLocked, - private val savedStateHandle: SavedStateHandle = SavedStateHandle(), + @Assisted private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { private val _uiState = MutableStateFlow(TasksUiState()) @@ -200,14 +207,18 @@ class TasksViewModel @Inject constructor( private fun observeTasks() { tasksJob = viewModelScope.launch { - combine( - tasksRepository.getAllActiveTasks(), - tasksRepository.getAllCompletedTasks(), - tasksRepository.getActiveTasksCount(), - tasksRepository.getCompletedTasksCount(), - tasksRepository.observeSubtasks(), - ) { activeTasks, completedTasks, activeCount, completedCount, subtasks -> - TaskFilterEngine.reduce(_uiState.value, activeTasks, completedTasks, activeCount, completedCount) + // Open and done tasks come from one query: two queries combined could hold a task that + // was just checked or unchecked in both lists at once, and the list keys tasks by id. + combine(tasksRepository.getAllTasks(), tasksRepository.observeSubtasks()) { tasks, subtasks -> + val (completedTasks, activeTasks) = tasks.partition { it.isCompleted } + val doneNewestFirst = completedTasks.sortedByDescending { it.completedDate } + TaskFilterEngine.reduce( + _uiState.value, + activeTasks, + doneNewestFirst, + activeTasks.size, + completedTasks.size, + ) .copy(subtasks = subtasks.groupBy { it.taskId }) }.catch { // The vault closed under the list (locked; [clearOnLock] resets the screen right @@ -710,6 +721,16 @@ class TasksViewModel @Inject constructor( } } +/** Creates [TasksViewModel] with the SavedStateHandle of its entry, navigation arguments included. */ +@AssistedFactory +@ViewModelAssistedFactoryKey(TasksViewModel::class) +@ContributesIntoMap(AppScope::class) +fun interface TasksViewModelFactory : ViewModelAssistedFactory { + override fun create(extras: CreationExtras): TasksViewModel = create(extras.createSavedStateHandle()) + + fun create(@Assisted savedStateHandle: SavedStateHandle): TasksViewModel +} + /** What a write tells the screen once it has left its lock (see launchSubtaskWrite). */ private sealed interface PendingEvent { data class Failure(val failure: TaskOperationFailure) : PendingEvent diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt new file mode 100644 index 00000000..539e9cfb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.domain.model.ThemeSettings +import com.pasich.encly.domain.repository.SettingsRepository +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class ThemeViewModel(settingsRepository: SettingsRepository) : ViewModel() { + + // MainActivity holds the splash until the theme was read once, so the first frame starts + // from the stored theme instead of flashing the defaults. The main thread never blocks on + // the DataStore: the defaults are only a fallback if that read has not happened. + val themeSettingsFlow: StateFlow = + settingsRepository.themeSettingsFlow.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = settingsRepository.latestThemeSettings ?: ThemeSettings(), + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt similarity index 86% rename from app/src/main/java/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt index 509221d3..fae2c968 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt @@ -5,33 +5,48 @@ import androidx.lifecycle.viewModelScope import com.pasich.encly.core.common.LoadState import com.pasich.encly.core.common.asLoadState import com.pasich.encly.core.common.valueOrNull +import com.pasich.encly.core.security.NeverLocked +import com.pasich.encly.core.security.VaultLockEvents import com.pasich.encly.data.model.Note import com.pasich.encly.domain.repository.NotesRepository import com.pasich.encly.domain.usecase.note.CleanTrashNotesUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.binding +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class TrashViewModel @Inject constructor( +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class TrashViewModel( private val notesRepository: NotesRepository, private val updateNoteTrashStatusUseCase: UpdateNoteTrashStatusUseCase, private val cleanTrashNotesUseCase: CleanTrashNotesUseCase, + lockEvents: VaultLockEvents = NeverLocked, ) : ViewModel() { private val _state = MutableStateFlow(TrashListState()) val state: StateFlow get() = _state + private var notesJob: Job? = null + init { loadNotes() + clearOnLock(lockEvents) { + notesJob?.cancel() + _state.value = TrashListState() + } } private fun loadNotes() { - viewModelScope.launch { + notesJob = viewModelScope.launch { notesRepository.getTrashNotes().asLoadState(ListLoadErrors.NOTES).collect { load -> _state.update { it.copy(notesLoad = load) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Adaptive.kt b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Adaptive.kt new file mode 100644 index 00000000..c9ab6061 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Adaptive.kt @@ -0,0 +1,89 @@ +package com.pasich.encly.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * How much room the window gives the UI (Material window width classes). [Compact] is the phone layout and + * the default of every composable that no host sized: a screen drawn on its own looks like it always did. + */ +enum class WidthClass { + /** Phones, and any window narrower than [MEDIUM_MIN_WIDTH] or lower than [MIN_HEIGHT_FOR_WIDE]: today's UI. */ + Compact, + + /** Large phones unfolded, small tablets, narrow desktop windows: a navigation rail, centred content. */ + Medium, + + /** Tablets, unfolded devices and desktop windows: a permanent drawer, notes beside the open note. */ + Expanded, + ; + + /** Whether the UI is laid out for more than the phone. */ + val isWide: Boolean get() = this != Compact + + companion object { + /** The width from which the layout is [Medium]. */ + val MEDIUM_MIN_WIDTH: Dp = 600.dp + + /** The width from which the layout is [Expanded]. */ + val EXPANDED_MIN_WIDTH: Dp = 840.dp + + /** A window lower than this is [Compact] whatever its width, so a phone held sideways stays a phone. */ + val MIN_HEIGHT_FOR_WIDE: Dp = 480.dp + + /** The class of a window [width] wide and [height] high. */ + fun of(width: Dp, height: Dp): WidthClass = when { + height < MIN_HEIGHT_FOR_WIDE || width < MEDIUM_MIN_WIDTH -> Compact + width < EXPANDED_MIN_WIDTH -> Medium + else -> Expanded + } + } +} + +/** The width class the UI is laid out in; `EnclyApp` provides it from the window, tests provide it directly. */ +val LocalWidthClass = staticCompositionLocalOf { WidthClass.Compact } + +/** How tightly the UI is packed: [Touch] is the phone spacing, [Desktop] the tighter one for a pointer. */ +enum class DensityProfile { + /** The spacing the app has always had, sized for fingers. */ + Touch, + + /** Tighter paddings and row heights for an expanded window driven by a mouse and keyboard. */ + Desktop, + ; + + companion object { + /** [Desktop] only for an [WidthClass.Expanded] window on a [pointerFirst] device, else [Touch]. */ + fun of(widthClass: WidthClass, pointerFirst: Boolean): DensityProfile = + if (widthClass == WidthClass.Expanded && pointerFirst) Desktop else Touch + } +} + +/** The density the UI is packed in; `EnclyApp` provides it and [EnclyTheme] turns it into [EnclySpacing]. */ +val LocalDensityProfile = staticCompositionLocalOf { DensityProfile.Touch } + +/** Whether the device is driven by a mouse and keyboard first (a desktop), as the platform says. */ +internal expect fun isPointerFirstDevice(): Boolean + +/** Whether the UI is driven by a mouse and keyboard first; the platform says, tests provide it directly. */ +val LocalPointerFirst = staticCompositionLocalOf { isPointerFirstDevice() } + +/** The spacing tokens for a [profile]; [DensityProfile.Touch] is the default [EnclySpacing]. */ +fun enclySpacingFor(profile: DensityProfile): EnclySpacing = when (profile) { + DensityProfile.Touch -> EnclySpacing() + + DensityProfile.Desktop -> EnclySpacing( + gutter = 20.dp, + listGutter = 12.dp, + cardGap = 8.dp, + section = 20.dp, + rowGap = 12.dp, + fieldGap = 14.dp, + topBarHeight = 52.dp, + buttonHeight = 44.dp, + textButtonHeight = 36.dp, + rowHeight = 44.dp, + drawerWidth = 280.dp, + ) +} diff --git a/app/src/main/java/com/pasich/encly/ui/theme/EnclySpacing.kt b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/EnclySpacing.kt similarity index 82% rename from app/src/main/java/com/pasich/encly/ui/theme/EnclySpacing.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/EnclySpacing.kt index afe2262d..fccf3321 100644 --- a/app/src/main/java/com/pasich/encly/ui/theme/EnclySpacing.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/EnclySpacing.kt @@ -73,6 +73,18 @@ data class EnclySpacing( val drawerWidth: Dp = 320.dp, /** The empty area under the last editor block; a tap there adds a block. */ val editorTapArea: Dp = 200.dp, + /** Widest a page of text, forms or lists grows in a medium or expanded window; it is centred beyond. */ + val contentMaxWidth: Dp = 720.dp, + /** Width of the notes list beside the open note in the expanded layout. */ + val listPaneWidth: Dp = 380.dp, + /** Width of the navigation rail of the medium layout. */ + val railWidth: Dp = 80.dp, + /** Height of the open notes' tab strip. */ + val tabHeight: Dp = 40.dp, + /** Widest a tab of the strip grows. */ + val tabMaxWidth: Dp = 200.dp, + /** Width of a dialog that stands in for a bottom sheet in a wide window. */ + val dialogWidth: Dp = 480.dp, ) val LocalSpacing = staticCompositionLocalOf { EnclySpacing() } diff --git a/app/src/main/java/com/pasich/encly/ui/theme/NoteColors.kt b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/NoteColors.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/ui/theme/NoteColors.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/NoteColors.kt diff --git a/app/src/main/java/com/pasich/encly/ui/theme/Palettes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Palettes.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/ui/theme/Palettes.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Palettes.kt diff --git a/app/src/main/java/com/pasich/encly/ui/theme/Shapes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Shapes.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/ui/theme/Shapes.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Shapes.kt diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Theme.kt b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Theme.kt new file mode 100644 index 00000000..165b8c0b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Theme.kt @@ -0,0 +1,101 @@ +package com.pasich.encly.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.toArgb +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pasich.encly.domain.model.ThemePalette +import com.pasich.encly.domain.model.ThemeSettings +import com.pasich.encly.platform.LocalAppWindow +import com.pasich.encly.presentation.viewmodel.ThemeViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel + +@Composable +fun AppTheme(themeViewModel: ThemeViewModel = metroViewModel(), content: @Composable () -> Unit) { + val themeSettings by themeViewModel.themeSettingsFlow.collectAsStateWithLifecycle() + val isDarkTheme = themeSettings.isDark(isSystemInDarkTheme()) + + val window = LocalAppWindow.current + SideEffect { + window.setLightStatusBar(light = !isDarkTheme) + // Defense in depth: the host sets this before the first frame, and the theme reasserts it for + // the Compose window. The only exception is the temporary "Allow screenshots until the app locks", + // which the host keeps (see ScreenshotAllowance); it is never stored. + window.enforceSecure() + } + + EnclyTheme(settings = themeSettings, dark = isDarkTheme) { + // The window behind Compose (activity recreation, IME and window transitions) follows + // the palette and the app's mode, not the static, system-night-only window_background. + val background = MaterialTheme.colorScheme.background.toArgb() + SideEffect { window.setBackgroundColor(background) } + content() + } +} + +/** + * The Encly theme for already resolved settings: the colour scheme of the palette in the given + * mode (or of the wallpaper when dynamic colour is on and the platform has it), typography from the font + * choice, shapes and spacing. Previews can use it without a ViewModel. + */ +@Composable +fun EnclyTheme(settings: ThemeSettings, dark: Boolean, content: @Composable () -> Unit) { + val wallpaper = if (settings.dynamic) wallpaperColorScheme(dark) else null + val colorScheme = if (wallpaper != null) { + if (settings.palette == ThemePalette.MIDNIGHT) wallpaper.withTrueBlackSurfaces() else wallpaper + } else { + colorSchemeFor(settings.palette, dark) + } + val fonts = settings.fontStyle.fontSet() + val typography = remember(fonts) { appTypography(fonts) } + val enclyTypography = remember(fonts) { enclyTypography(fonts) } + val themeFonts = remember(settings.fontStyle, fonts) { ThemeFonts(settings.fontStyle, fonts) } + val profile = LocalDensityProfile.current + val spacing = remember(profile) { enclySpacingFor(profile) } + + CompositionLocalProvider( + LocalEnclyColors provides enclyColorsFor(settings.palette, dark), + LocalEnclyTypography provides enclyTypography, + LocalThemeFonts provides themeFonts, + LocalSpacing provides spacing, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + shapes = EnclyShapes, + content = content, + ) + } +} + +/** Encly tokens that Material's theme does not carry. */ +object EnclyTheme { + val colors: EnclyColors + @Composable + @ReadOnlyComposable + get() = LocalEnclyColors.current + + val typography: EnclyTypography + @Composable + @ReadOnlyComposable + get() = LocalEnclyTypography.current + + val spacing: EnclySpacing + @Composable + @ReadOnlyComposable + get() = LocalSpacing.current +} + +/** + * The colour scheme the system derives from the user's wallpaper (Android 12+) in the given mode, or null + * where the platform has none. + */ +@Composable +internal expect fun wallpaperColorScheme(dark: Boolean): ColorScheme? diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Type.kt b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Type.kt new file mode 100644 index 00000000..7f176680 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Type.kt @@ -0,0 +1,198 @@ +package com.pasich.encly.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.shared.resources.Res +import com.pasich.encly.shared.resources.ibm_plex_sans_variable +import com.pasich.encly.shared.resources.playfair_display_variable +import com.pasich.encly.shared.resources.poppins_bold +import com.pasich.encly.shared.resources.poppins_medium +import com.pasich.encly.shared.resources.poppins_regular +import com.pasich.encly.shared.resources.source_sans3_variable +import org.jetbrains.compose.resources.Font +import org.jetbrains.compose.resources.FontResource + +// Fonts are bundled OFL files in composeResources/font (licenses in /licenses/fonts). Encly must not fetch +// fonts through the Google Play Services font provider: that is a network request to Google, +// breaks the "fully offline" promise and leaves devices without GMS with no fonts. +// Variable fonts cover several weights from one file via FontVariation settings (API 26+). + +@Composable +private fun playfair(): FontFamily = variableFontFamily(Res.font.playfair_display_variable) + +@Composable +private fun sourceSans(): FontFamily = variableFontFamily(Res.font.source_sans3_variable) + +@Composable +private fun ibmPlex(): FontFamily = variableFontFamily(Res.font.ibm_plex_sans_variable) + +// No SemiBold file is bundled; a 600 request resolves to the nearest face (Bold). +@Composable +private fun poppins(): FontFamily { + val regular = Font(Res.font.poppins_regular, FontWeight.Normal) + val medium = Font(Res.font.poppins_medium, FontWeight.Medium) + val bold = Font(Res.font.poppins_bold, FontWeight.Bold) + return remember(regular, medium, bold) { FontFamily(regular, medium, bold) } +} + +@OptIn(ExperimentalTextApi::class) +@Composable +private fun variableFontFamily(resource: FontResource): FontFamily { + val fonts = listOf(FontWeight.Normal, FontWeight.Medium, FontWeight.SemiBold, FontWeight.Bold).map { weight -> + Font( + resource = resource, + weight = weight, + variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)), + ) + } + return remember(fonts) { FontFamily(fonts) } +} + +/** + * The families a font choice (Settings → Appearance → Font) is made of: [display] and [body], and the two + * that ignore the choice, IBM Plex Sans for data ([plex]) and Playfair Display for the wordmark ([serif]). + */ +@Immutable +data class FontSet(val display: FontFamily, val body: FontFamily, val plex: FontFamily, val serif: FontFamily) { + companion object { + /** The platform's own font for all four: before the bundled files are read, and in previews. */ + val System = FontSet(FontFamily.Default, FontFamily.Default, FontFamily.Default, FontFamily.Default) + } +} + +/** The [FontSet] the theme built for [style], which anything under it asking for the same style reuses. */ +@Immutable +class ThemeFonts(val style: FontStyleType, val set: FontSet) + +val LocalThemeFonts = staticCompositionLocalOf { null } + +/** + * [fontSet] of this style, reusing the theme's when it is the same style. A second set would load the + * same files again: its own families, and so its own typefaces. + */ +@Composable +fun FontStyleType.sharedFontSet(): FontSet { + val themed = LocalThemeFonts.current + return if (themed != null && themed.style == this) themed.set else fontSet() +} + +/** Editorial, Modern and Technical (design spec §2.2). */ +@Composable +fun FontStyleType.fontSet(): FontSet { + val plex = ibmPlex() + val serif = playfair() + return when (this) { + FontStyleType.COZY_EDITOR -> FontSet(display = serif, body = sourceSans(), plex = plex, serif = serif) + FontStyleType.MODERN_SIMPLE -> FontSet(display = poppins(), body = sourceSans(), plex = plex, serif = serif) + FontStyleType.TECH_MINIMAL -> FontSet(display = plex, body = plex, plex = plex, serif = serif) + } +} + +private fun style( + family: FontFamily, + weight: FontWeight, + size: Int, + lineHeight: Int, + tracking: TextUnit = 0.em, + fontStyle: FontStyle = FontStyle.Normal, +) = TextStyle( + fontFamily = family, + fontWeight = weight, + fontStyle = fontStyle, + fontSize = size.sp, + lineHeight = lineHeight.sp, + letterSpacing = tracking, +) + +/** + * The Material 3 type scale (design spec §2.3). Headings follow the chosen display family, text + * the chosen body family. The overline ([Typography.labelSmall]) is always IBM Plex Sans. + */ +fun appTypography(fonts: FontSet): Typography { + val d = fonts.display + val b = fonts.body + val semi = FontWeight.SemiBold + return Typography( + displayLarge = style(d, semi, size = 44, lineHeight = 48, tracking = (-0.02).em), + displayMedium = style(d, semi, size = 40, lineHeight = 44, tracking = (-0.02).em), + displaySmall = style(d, semi, size = 34, lineHeight = 40, tracking = (-0.01).em), + headlineLarge = style(d, semi, size = 32, lineHeight = 36, tracking = (-0.01).em), + headlineMedium = style(d, semi, size = 30, lineHeight = 34, tracking = (-0.01).em), + headlineSmall = style(d, semi, size = 28, lineHeight = 32), + titleLarge = style(d, semi, size = 21, lineHeight = 26), + titleMedium = style(d, semi, size = 19, lineHeight = 24), + titleSmall = style(b, semi, size = 17, lineHeight = 22), + bodyLarge = style(b, FontWeight.Normal, size = 17, lineHeight = 26), + bodyMedium = style(b, FontWeight.Normal, size = 15, lineHeight = 21), + bodySmall = style(b, FontWeight.Normal, size = 14, lineHeight = 20), + labelLarge = style(b, semi, size = 16, lineHeight = 22), + labelMedium = style(b, semi, size = 14, lineHeight = 20), + labelSmall = style(fonts.plex, FontWeight.Medium, size = 12, lineHeight = 16, tracking = 0.08.em), + ) +} + +/** Text styles outside the Material scale (design spec §2.3, second table). */ +@Immutable +data class EnclyTypography( + val buttonPrimary: TextStyle, + val chip: TextStyle, + val stepLabel: TextStyle, + val dataLarge: TextStyle, + val dataIndex: TextStyle, + val dataSmall: TextStyle, + val keypadDigit: TextStyle, + val phraseInput: TextStyle, + val quote: TextStyle, + val wordmark: TextStyle, + val meta: TextStyle, + val heroSub: TextStyle, + /** Dialog titles: headlineMedium sized down to 24/30. */ + val dialogTitle: TextStyle, + /** Sub-screen top bars: headlineSmall at 26. */ + val subScreenTitle: TextStyle, + /** Theme swatch labels, 13/18; the selected one in bold. */ + val swatchLabel: TextStyle, + /** The "Aa" sample of a font choice, in that choice's display family. */ + val fontSample: TextStyle, +) + +/** Data styles (IBM Plex Sans) and the wordmark (Playfair Display) ignore the font choice. */ +fun enclyTypography(fonts: FontSet): EnclyTypography { + val b = fonts.body + val plex = FontWeight.Medium + return EnclyTypography( + buttonPrimary = style(b, FontWeight.SemiBold, size = 17, lineHeight = 22, tracking = 0.01.em), + chip = style(b, FontWeight.SemiBold, size = 15, lineHeight = 20), + stepLabel = style(fonts.plex, plex, size = 12, lineHeight = 16, tracking = 0.06.em), + dataLarge = style(fonts.plex, plex, size = 18, lineHeight = 24, tracking = 0.01.em), + dataIndex = style(fonts.plex, plex, size = 13, lineHeight = 16), + dataSmall = style(fonts.plex, plex, size = 12, lineHeight = 16), + keypadDigit = style(fonts.plex, plex, size = 26, lineHeight = 32), + phraseInput = style(fonts.plex, FontWeight.Normal, size = 17, lineHeight = 26), + // No italic Playfair file is bundled; Compose synthesises the slant. + quote = style(fonts.display, plex, size = 19, lineHeight = 27, fontStyle = FontStyle.Italic), + wordmark = style(fonts.serif, FontWeight.Bold, size = 20, lineHeight = 24, tracking = (-0.01).em), + meta = style(b, FontWeight.Normal, size = 13, lineHeight = 18), + heroSub = style(b, FontWeight.Normal, size = 18, lineHeight = 27), + dialogTitle = style(fonts.display, FontWeight.SemiBold, size = 24, lineHeight = 30), + subScreenTitle = style(fonts.display, FontWeight.SemiBold, size = 26, lineHeight = 32), + swatchLabel = style(b, FontWeight.Medium, size = 13, lineHeight = 18), + fontSample = style(fonts.display, FontWeight.SemiBold, size = 24, lineHeight = 28), + ) +} + +val LocalEnclyTypography = staticCompositionLocalOf { enclyTypography(FontSet.System) } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/utils/DataFormater.kt b/shared/src/commonMain/kotlin/com/pasich/encly/utils/DataFormater.kt new file mode 100644 index 00000000..4b6243a4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/utils/DataFormater.kt @@ -0,0 +1,55 @@ +package com.pasich.encly.utils + +import androidx.compose.runtime.Composable +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.date_today +import com.pasich.encly.core.resources.date_today_at +import com.pasich.encly.core.resources.date_yesterday +import com.pasich.encly.core.resources.date_yesterday_at +import kotlinx.datetime.TimeZone +import org.jetbrains.compose.resources.stringResource +import kotlin.time.Clock +import kotlin.time.Instant + +/** A stored note time as an [Instant]; 0 means "never saved" and reads as now. */ +fun noteInstant(millis: Long): Instant = if (millis == 0L) Clock.System.now() else Instant.fromEpochMilliseconds(millis) + +/** A short date-and-time in the in-app language (e.g. "5/14/26, 3:04 PM"). */ +@Composable +fun formatDateTime(date: Instant): String = + formatLocalized(date, LocalizedDate.DATE_TIME_SHORT, currentLanguageTag(), TimeZone.currentSystemDefault()) + +/** "Today 15:04", "Yesterday 15:04", or a localized medium date with time. */ +@Composable +fun formatNoteDate(date: Instant = Clock.System.now()): String { + val tag = currentLanguageTag() + val zone = TimeZone.currentSystemDefault() + return when (relativeDay(date, zone = zone)) { + RelativeDay.TODAY -> + stringResource(Res.string.date_today_at, formatLocalized(date, LocalizedDate.TIME, tag, zone)) + + RelativeDay.YESTERDAY -> + stringResource(Res.string.date_yesterday_at, formatLocalized(date, LocalizedDate.TIME, tag, zone)) + + RelativeDay.OTHER -> formatLocalized(date, LocalizedDate.DATE_TIME_MEDIUM, tag, zone) + } +} + +/** + * The date on a note card: "Today", "Yesterday", then day and month ("18 Sep"), with the year + * only when it is not this year (design spec §4.3). + */ +@Composable +fun formatCardDate(date: Instant): String { + val zone = TimeZone.currentSystemDefault() + return when (relativeDay(date, zone = zone)) { + RelativeDay.TODAY -> stringResource(Res.string.date_today) + + RelativeDay.YESTERDAY -> stringResource(Res.string.date_yesterday) + + RelativeDay.OTHER -> { + val style = if (isSameYear(date, zone = zone)) LocalizedDate.DAY_MONTH else LocalizedDate.DAY_MONTH_YEAR + formatLocalized(date, style, currentLanguageTag(), zone) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/utils/DeviceCapabilities.kt b/shared/src/commonMain/kotlin/com/pasich/encly/utils/DeviceCapabilities.kt new file mode 100644 index 00000000..458246e0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/utils/DeviceCapabilities.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.utils + +/** What the device the app runs on can do that the UI offers or hides accordingly. */ +interface DeviceCapabilities { + /** True where the system can colour the app from the user's wallpaper (Android 12 and later). */ + fun supportsDynamicColors(): Boolean +} diff --git a/app/src/main/java/com/pasich/encly/utils/FontSizeUtils.kt b/shared/src/commonMain/kotlin/com/pasich/encly/utils/FontSizeUtils.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/utils/FontSizeUtils.kt rename to shared/src/commonMain/kotlin/com/pasich/encly/utils/FontSizeUtils.kt index 3f0a20bd..f36b334e 100644 --- a/app/src/main/java/com/pasich/encly/utils/FontSizeUtils.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/utils/FontSizeUtils.kt @@ -2,7 +2,7 @@ package com.pasich.encly.utils import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.model.BlockType /** * Utility for calculating font sizes for different block types. diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/utils/LocalizedDate.kt b/shared/src/commonMain/kotlin/com/pasich/encly/utils/LocalizedDate.kt new file mode 100644 index 00000000..9c02bde5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/utils/LocalizedDate.kt @@ -0,0 +1,37 @@ +package com.pasich.encly.utils + +import androidx.compose.runtime.Composable +import kotlinx.datetime.TimeZone +import kotlin.time.Instant + +/** + * The one platform-specific place of date formatting: localized month names, order and 12/24 hour + * clock come from the platform (java.text, android.text.format), which kotlinx-datetime has none of. Everything + * else works on kotlinx-datetime types and passes a [LocalizedDate] and a language tag in here. + */ +internal enum class LocalizedDate { + /** "3:04 PM" */ + TIME, + + /** "5/14/26, 3:04 PM" */ + DATE_TIME_SHORT, + + /** "May 14, 2026, 3:04 PM" */ + DATE_TIME_MEDIUM, + + /** "14 May", in the locale's own order. */ + DAY_MONTH, + + /** "14 May 2025", in the locale's own order. */ + DAY_MONTH_YEAR, +} + +/** + * The language tag of the current (per-app) configuration. Unlike the default locale it always + * follows the language picked in Settings, on every API level. + */ +@Composable +internal expect fun currentLanguageTag(): String + +/** [instant] in [zone], written as [style] in the language [languageTag]. */ +internal expect fun formatLocalized(instant: Instant, style: LocalizedDate, languageTag: String, zone: TimeZone): String diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt new file mode 100644 index 00000000..775739ef --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt @@ -0,0 +1,116 @@ +package com.pasich.encly.utils + +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.TextualBlock +import com.pasich.encly.editor.model.hasContent +import com.pasich.encly.editor.model.kindIn +import com.pasich.encly.editor.model.normalizedLevels +import com.pasich.encly.editor.model.numbers + +/** + * Utility class for formatting note text. + */ +object NotesTextFormatter { + + /** + * Plain text of a note's stored blocks, or null when they cannot be read (damaged, or + * written by a newer version). Never throws: list previews and search call it for every + * note, and one bad note must not take the whole list down. + */ + fun jsonToPlainText(json: String): String? { + if (json.isEmpty()) return "" + + // Use the existing converter to get blocks from JSON + return BlockConverter.jsonToBlocksOrNull(json)?.let(::blocksToPlainText) + } + + /** + * The text the user typed into [blocks], one block or list item per line, for search: no + * checkbox or number markers, no separator lines and no quotation marks, so a query such + * as "x" or "-" does not match every checklist or separator. + */ + fun blocksToSearchText(blocks: List): String = blocks.flatMap { block -> + when (block) { + is Block.TextBlock -> listOf(block.text.value) + is Block.HBlock -> listOf(block.text.value) + is Block.QuoteBlock -> listOf(block.text.value) + is Block.LinkBlock -> listOf(block.block.value.title, block.block.value.url) + is Block.ListBlock -> block.items.value.map { it.value } + is Block.SeparatorBlock -> emptyList() + is Block.CodeBlock -> listOf(block.text.value) + is Block.TableBlock -> block.table.value.rows.flatMap { row -> row.cells.map { it.value } } + } + }.filter { it.isNotBlank() }.joinToString("\n") + + /** + * Converts a list of blocks into plain text without Markdown formatting, one block or list item + * per line. Blank paragraphs (the blank lines a note keeps between its sections) are skipped and + * no empty line separates blocks, so a card's short excerpt never spends a line on nothing. + */ + fun blocksToPlainText(blocks: List): String { + val text = StringBuilder() + + blocks.filter { it.hasContent() }.forEach { block -> + when (block) { + is Block.TextBlock, is Block.HBlock, is Block.CodeBlock -> { + text.append((block as TextualBlock).text.value) + text.append("\n") + } + + is Block.QuoteBlock -> { + // For quotes, add quotation marks instead of a Markdown marker + text.append("«${block.text.value}»") + text.append("\n") + } + + is Block.LinkBlock -> { + val linkData = block.block.value + // If there is a title, use it and append the URL in parentheses + if (linkData.title.isNotEmpty()) { + text.append("${linkData.title} (${linkData.url})") + } else { + text.append(linkData.url) + } + text.append("\n") + } + + is Block.SeparatorBlock -> { + text.append("---------------------") + text.append("\n") + } + + is Block.TableBlock -> block.table.value.rows.forEach { row -> + text.append(row.cells.joinToString(" | ") { it.value }) + text.append("\n") + } + + is Block.ListBlock -> { + // A nested item is indented two spaces a level; numbers start again at every level. + val items = block.items.value.normalizedLevels(block.blockType) + val numbers = items.numbers(block.blockType) + items.forEachIndexed { index, item -> + val marker = when (item.kindIn(block.blockType)) { + // For checkboxes, use [x] or [ ] + BlockType.LIST_CHECK -> if (item.isCheck) "[x]" else "[ ]" + + // Leave numbered lists as they are + BlockType.LIST_NUMBER -> "${numbers[index]}." + + // For bulleted lists, use a hyphen + else -> "-" + } + text.append(" ".repeat(item.level)).append("$marker ${item.value}\n") + } + } + + else -> { + // Can do nothing here or log it + } + } + } + + return text.toString().trim() + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/utils/RelativeDay.kt b/shared/src/commonMain/kotlin/com/pasich/encly/utils/RelativeDay.kt new file mode 100644 index 00000000..9d2c20e2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/utils/RelativeDay.kt @@ -0,0 +1,36 @@ +package com.pasich.encly.utils + +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +/** Where a timestamp falls relative to the current day, in the device time zone. */ +enum class RelativeDay { TODAY, YESTERDAY, OTHER } + +fun relativeDay( + date: Instant, + now: Instant = Clock.System.now(), + zone: TimeZone = TimeZone.currentSystemDefault(), +): RelativeDay { + val target = date.toLocalDateTime(zone).date + val today = now.toLocalDateTime(zone).date + return when (target) { + today -> RelativeDay.TODAY + today.minus(DatePeriod(days = 1)) -> RelativeDay.YESTERDAY + else -> RelativeDay.OTHER + } +} + +/** Whether [date] falls in the same calendar year as [now]; a card date then leaves the year out. */ +fun isSameYear( + date: Instant, + now: Instant = Clock.System.now(), + zone: TimeZone = TimeZone.currentSystemDefault(), +): Boolean = date.toLocalDateTime(zone).year == now.toLocalDateTime(zone).year + +/** Today's date as "2026-10-25" in the device time zone, for a file name. */ +fun todayStamp(now: Instant = Clock.System.now(), zone: TimeZone = TimeZone.currentSystemDefault()): String = + now.toLocalDateTime(zone).date.toString() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/Changelogs.kt b/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/Changelogs.kt new file mode 100644 index 00000000..6ad57a7f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/Changelogs.kt @@ -0,0 +1,49 @@ +package com.pasich.encly.whatsnew + +import com.pasich.encly.core.locale.AppLanguage + +/** + * The notes of one release: [heading] is the first line of the store changelog ("Encly 2.3.1"), [body] the + * changes under it. + */ +data class ReleaseNotes(val heading: String, val body: String) + +/** + * The store changelogs the app was built with ([BundledChangelogs]), by language. The files are + * `fastlane/metadata/android//changelogs/.txt`; [folderFor] maps the app's languages to the + * folders, and a language without notes reads the English ones. + */ +object Changelogs { + /** The folder of English, the notes every other language falls back to. */ + const val FALLBACK = "en-US" + + /** The store folder for the BCP 47 [languageTag] of the app's language (null or an unsupported one: English). */ + fun folderFor(languageTag: String?): String = when (AppLanguage.fromTag(languageTag)) { + AppLanguage.UKRAINIAN -> "uk" + AppLanguage.GERMAN -> "de-DE" + AppLanguage.FRENCH -> "fr-FR" + AppLanguage.SPANISH -> "es-ES" + AppLanguage.ITALIAN -> "it-IT" + AppLanguage.POLISH -> "pl-PL" + AppLanguage.PORTUGUESE -> "pt-PT" + AppLanguage.DUTCH -> "nl-NL" + AppLanguage.ARABIC -> "ar" + AppLanguage.ENGLISH, AppLanguage.SYSTEM -> FALLBACK + } + + /** The notes of this build in [languageTag], else in English, else null (no changelog was written for it). */ + fun current(languageTag: String?): ReleaseNotes? = notes(languageTag, BundledChangelogs.notes) + + /** The notes in [languageTag] out of [bundled] (locale folder to text), else the English ones, else null. */ + fun notes(languageTag: String?, bundled: Map): ReleaseNotes? { + val text = bundled[folderFor(languageTag)] ?: bundled[FALLBACK] ?: return null + return parse(text) + } + + private fun parse(text: String): ReleaseNotes? { + val lines = text.trim().lines() + val heading = lines.firstOrNull()?.trim().orEmpty() + if (heading.isEmpty()) return null + return ReleaseNotes(heading, lines.drop(1).joinToString("\n").trim()) + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/WhatsNewTracker.kt b/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/WhatsNewTracker.kt new file mode 100644 index 00000000..aeb22f26 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/WhatsNewTracker.kt @@ -0,0 +1,56 @@ +package com.pasich.encly.whatsnew + +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.platform.AppInfo +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +/** + * Remembers the version the app last started as, so the first unlock after an update can show "What's new" once. + * + * A fresh install records its version at once ([recordFreshInstall]) and never shows it. An install that predates + * this has nothing recorded: it is an update, so it is shown the notes of the version it just got. + */ +@SingleIn(AppScope::class) +@Inject +class WhatsNewTracker(private val flags: KeyValueStore, private val appInfo: AppInfo) { + + /** A new install starts on this version, so there is nothing new to tell. Does nothing if one is recorded. */ + fun recordFreshInstall() { + if (flags.getString(LAST_SEEN_KEY, null) == null) record() + } + + /** + * True once per version: when this version is newer than the one recorded (or none is recorded, an install + * from before this existed). The version is recorded at once, so the next call is false. + */ + fun takeUnseen(): Boolean { + val last = flags.getString(LAST_SEEN_KEY, null) + val current = appInfo.versionName + if (last == current) return false + record() + return last == null || isNewer(current, last) + } + + private fun record() = flags.edit { putString(LAST_SEEN_KEY, appInfo.versionName) } + + private companion object { + const val LAST_SEEN_KEY = "whats_new_last_seen_version" + val VERSION = Regex("""^(\d+)\.(\d+)\.(\d+)""") + + /** A name that is not `MAJOR.MINOR.PATCH` counts as different, hence new. */ + fun isNewer(current: String, last: String): Boolean { + val now = code(current) + val before = code(last) + return if (now == null || before == null) true else now > before + } + + /** The version code (`MAJOR * 10000 + MINOR * 100 + PATCH`) of a name, with a suffix too (`2.3.1-debug`). */ + @Suppress("MagicNumber") // The weights of MAJOR.MINOR.PATCH, as in version.properties. + fun code(name: String): Long? { + val parts = VERSION.find(name)?.groupValues?.drop(1)?.map { it.toLong() } ?: return null + return parts[0] * 10_000 + parts[1] * 100 + parts[2] + } + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupAssertions.kt b/shared/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupAssertions.kt new file mode 100644 index 00000000..aad85ee2 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupAssertions.kt @@ -0,0 +1,14 @@ +package com.pasich.encly.core.backup + +import kotlin.test.assertEquals +import kotlin.test.fail + +/** Fails unless [block] throws a [BackupException] with the error [expected]. */ +internal fun assertError(expected: BackupError, block: () -> Unit) { + try { + block() + fail("expected BackupException($expected)") + } catch (e: BackupException) { + assertEquals(expected, e.error) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/core/locale/AppLanguageTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/core/locale/AppLanguageTest.kt new file mode 100644 index 00000000..f0f1ba6f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/core/locale/AppLanguageTest.kt @@ -0,0 +1,46 @@ +package com.pasich.encly.core.locale + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AppLanguageTest { + + @Test + fun noTagMeansSystemDefault() { + assertEquals(AppLanguage.SYSTEM, AppLanguage.fromTag(null)) + assertEquals(AppLanguage.SYSTEM, AppLanguage.fromTag("")) + } + + @Test + fun matchesByPrimaryLanguageSubtag() { + assertEquals(AppLanguage.UKRAINIAN, AppLanguage.fromTag("uk")) + assertEquals(AppLanguage.UKRAINIAN, AppLanguage.fromTag("uk-UA")) + assertEquals(AppLanguage.PORTUGUESE, AppLanguage.fromTag("pt-BR")) + assertEquals(AppLanguage.GERMAN, AppLanguage.fromTag("de_AT")) + assertEquals(AppLanguage.DUTCH, AppLanguage.fromTag("NL")) + assertEquals(AppLanguage.ARABIC, AppLanguage.fromTag("ar-EG")) + } + + @Test + fun unsupportedLanguageFallsBackToSystem() { + assertEquals(AppLanguage.SYSTEM, AppLanguage.fromTag("ja")) + } + + @Test + fun everyLanguageRoundTripsThroughItsTag() { + AppLanguage.entries.forEach { language -> + assertEquals(language, AppLanguage.fromTag(language.tag)) + } + } + + @Test + fun systemDefaultIsListedFirstThenTenLanguages() { + assertEquals(AppLanguage.SYSTEM, AppLanguage.entries.first()) + assertEquals(10, AppLanguage.entries.count { it.tag != null }) + } + + @Test + fun onlyArabicIsLaidOutRightToLeft() { + assertEquals(listOf(AppLanguage.ARABIC), AppLanguage.entries.filter { it.rightToLeft }) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt similarity index 86% rename from app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt index 050f65b7..e819ba19 100644 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt @@ -1,33 +1,35 @@ package com.pasich.encly.data.backup -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode -import cash.z.ecc.android.bip39.Mnemonics.WordCount import com.pasich.encly.core.backup.BackupCipher import com.pasich.encly.core.backup.BackupError +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.BackupSecret +import com.pasich.encly.core.backup.BackupTag import com.pasich.encly.core.backup.assertError -import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.core.security.Mnemonic import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task -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 com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock 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.assertTrue -import org.junit.Assert.fail -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail class BackupImporterTest { - private val words = MnemonicCode(WordCount.COUNT_12).chars + private val words = Mnemonic.generate() /** Every block type the editor can produce, serialized as the notes table stores it. */ private val allBlocks = BlockConverter.blocksToJson( @@ -50,18 +52,20 @@ class BackupImporterTest { private suspend fun seededVault() = InMemoryVaultDataStore().apply { val work = insertTag(Tag(nameTag = "Work", isVisible = true, position = 0, uid = "tag-work")) - insertTag(Tag(nameTag = "Hidden", isVisible = false, position = 1, uid = "tag-hidden")) - insertNote( + val hidden = insertTag(Tag(nameTag = "Hidden", isVisible = false, position = 1, uid = "tag-hidden")) + val plan = insertNote( Note( title = "Plan", value = allBlocks, description = "d", date = 20, dateCreate = 10, - tagId = work, uid = "n-plan", ), ) + // Two tags on one note. + insertNoteTag(NoteTag(plan, work)) + insertNoteTag(NoteTag(plan, hidden)) insertNote(Note(title = "Old", value = "[]", date = 5, dateCreate = 1, isTrash = true, uid = "n-old")) insertTask( Task( @@ -198,7 +202,7 @@ class BackupImporterTest { tags = emptyList(), notes = listOf( com.pasich.encly.core.backup.BackupNote( - "n1", "t", "[]", "", 1, 1, null, false, isPinned = true, isArchived = true, + "n1", "t", "[]", "", 1, 1, emptyList(), false, isPinned = true, isArchived = true, ), ), tasks = emptyList(), @@ -248,6 +252,33 @@ class BackupImporterTest { assertEquals(2, target.tags.single { it.uid == "tag-hidden" }.position) } + @Test + fun aNoteKeepsEveryTagThroughExportAndImport() = runTest { + val target = InMemoryVaultDataStore() + + BackupImporter.import(open(export(seededVault())), ImportMode.MERGE, target) + + val plan = target.notes.single { it.uid == "n-plan" } + assertEquals(target.tags.map { it.id }.toSet(), target.tagIdsOf(plan.id)) + assertEquals(emptySet(), target.tagIdsOf(target.notes.single { it.uid == "n-old" }.id)) + } + + @Test + fun twoBackupTagsThatAreOneTagHereLinkTheNoteOnce() = runTest { + val payload = BackupPayload( + exportedAt = 1, + tags = listOf(BackupTag("a", "Work", true, 0), BackupTag("b", "work", true, 1)), + notes = listOf(BackupNote("n", "N", "[]", "", 1, 1, tagUids = listOf("a", "b"), isTrash = false)), + tasks = emptyList(), + ) + val target = InMemoryVaultDataStore() + + BackupImporter.import(payload, ImportMode.MERGE, target, TagMatch.UID_OR_NAME) + + assertEquals(1, target.tags.size) + assertEquals(setOf(target.tags.single().id), target.tagIdsOf(target.notes.single().id)) + } + @Test fun mergingTheSameBackupTwiceAddsNothing() = runTest { val payload = open(export(seededVault())) @@ -301,7 +332,7 @@ class BackupImporterTest { @Test fun wrongWordsNeverReachTheVault() = runTest { val file = export(seededVault()) - val other = MnemonicCode(WordCount.COUNT_12).chars + val other = Mnemonic.generate() assertError(BackupError.WRONG_SECRET) { BackupCipher.open(file, BackupSecret.RecoveryPhrase(other)) } } @@ -320,7 +351,7 @@ class BackupImporterTest { it.description, it.date, it.dateCreate, - tagUid[it.tagId], + store.tagIdsOf(it.id).map(tagUid::get).toSet(), it.isTrash, ) }.toSet(), diff --git a/app/src/test/java/com/pasich/encly/data/export/MarkdownBlocksTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownBlocksTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/data/export/MarkdownBlocksTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownBlocksTest.kt index d6af3fdf..00811509 100644 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownBlocksTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownBlocksTest.kt @@ -1,13 +1,13 @@ package com.pasich.encly.data.export -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 com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse class MarkdownBlocksTest { diff --git a/app/src/test/java/com/pasich/encly/data/export/MarkdownFileNamesTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownFileNamesTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/data/export/MarkdownFileNamesTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownFileNamesTest.kt index 46c16d9b..5a0b84a0 100644 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownFileNamesTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownFileNamesTest.kt @@ -1,9 +1,9 @@ package com.pasich.encly.data.export -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class MarkdownFileNamesTest { @@ -75,12 +75,12 @@ class MarkdownFileNamesTest { @Test fun aLongTitleIsCutOnACharacterBoundary() { val name = names().next("я".repeat(200), "Untitled") - assertTrue(name.toByteArray().size <= MarkdownFileNames.MAX_NAME_BYTES) + assertTrue(name.encodeToByteArray().size <= MarkdownFileNames.MAX_NAME_BYTES) assertTrue(name.endsWith(".md")) // Two bytes per letter: no half character at the cut. assertEquals(name.removeSuffix(".md"), "я".repeat(name.length - 3)) val emoji = names().next("😀".repeat(100), "Untitled") - assertTrue(emoji.toByteArray().size <= MarkdownFileNames.MAX_NAME_BYTES) + assertTrue(emoji.encodeToByteArray().size <= MarkdownFileNames.MAX_NAME_BYTES) assertEquals(emoji.removeSuffix(".md"), "😀".repeat(emoji.removeSuffix(".md").length / 2)) } @@ -111,7 +111,9 @@ class MarkdownFileNamesTest { fun theSuffixStillFitsTheByteBudget() { val names = names() val long = "я".repeat(200) - repeat(12) { assertTrue(names.next(long, "Untitled").toByteArray().size <= MarkdownFileNames.MAX_NAME_BYTES) } + repeat(12) { + assertTrue(names.next(long, "Untitled").encodeToByteArray().size <= MarkdownFileNames.MAX_NAME_BYTES) + } } @Test diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NestedListMarkdownTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NestedListMarkdownTest.kt new file mode 100644 index 00000000..7d8a8553 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NestedListMarkdownTest.kt @@ -0,0 +1,123 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.editor.codec.MarkdownBlockMapper +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.utils.NotesTextFormatter +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Nested lists out as Markdown and plain text, and back in again. */ +class NestedListMarkdownTest { + private fun row(text: String, level: Int = 0, kind: BlockType? = null, check: Boolean = false) = + ItemListBlock(text, isCheck = check, level = level, kind = kind) + + private fun list(type: BlockType, vararg rows: ItemListBlock) = + Block.ListBlock(MutableStateFlow(rows.toList()), type) + + private fun render(vararg blocks: Block) = MarkdownBlocks.render(blocks.toList()) + + private fun levels(block: Block) = (block as Block.ListBlock).items.value.map { it.value to it.level } + + @Test + fun aBulletedListIndentsEachLevelUnderTheTextOfItsParent() { + val bullets = list(BlockType.LIST_BULLET, row("a"), row("b", 1), row("c", 2), row("d", 1), row("e")) + + assertEquals("- a\n - b\n - c\n - d\n- e", render(bullets)) + } + + @Test + fun aNumberedListRestartsItsNumbersAtEveryLevelAndIndentsByTheWidthOfTheMarker() { + val numbers = list(BlockType.LIST_NUMBER, row("a"), row("b", 1), row("c", 1), row("d"), row("e", 1)) + + assertEquals("1. a\n 1. b\n 2. c\n2. d\n 1. e", render(numbers)) + } + + @Test + fun aNestedItemMayBeOfAnotherKindThanItsList() { + val mixed = list( + BlockType.LIST_BULLET, + row("a"), + row("b", 1, BlockType.LIST_NUMBER), + row("c", 1, BlockType.LIST_NUMBER), + row("d", 1, BlockType.LIST_CHECK, check = true), + row("e", 2, BlockType.LIST_CHECK), + ) + + assertEquals("- a\n 1. b\n 2. c\n - [x] d\n - [ ] e", render(mixed)) + } + + @Test + fun theLinesOfANestedItemAreIndentedUnderItsText() { + val bullets = list(BlockType.LIST_BULLET, row("a"), row("b\nsecond line", 1)) + + assertEquals("- a\n - b\\\n second line", render(bullets)) + } + + @Test + fun aBlankItemIsLeftOutAndItsChildrenBecomeValid() { + val bullets = list(BlockType.LIST_BULLET, row("a"), row(" ", 1), row("c", 2)) + + assertEquals("- a\n - c", render(bullets)) + } + + @Test + fun aFlatListIsWrittenAsBefore() { + assertEquals( + "- [x] a\n- [ ] b", + render(list(BlockType.LIST_CHECK, row("a", check = true), row("b"))), + ) + } + + @Test + fun whatIsExportedReadsBackAsTheSameLevelsAndKinds() { + val original = list( + BlockType.LIST_NUMBER, + row("a"), + row("b", 1, BlockType.LIST_BULLET), + row("c", 2, BlockType.LIST_CHECK, check = true), + row("d", 2, BlockType.LIST_CHECK), + row("e", 1, BlockType.LIST_BULLET), + row("f"), + row("g", 1), + ) + + val back = MarkdownBlockMapper.toBlocks(render(original)).single() as Block.ListBlock + + assertEquals(BlockType.LIST_NUMBER, back.blockType) + assertEquals(original.items.value.map { it.value to it.level }, levels(back)) + assertEquals(original.items.value.map { it.kind }, back.items.value.map { it.kind }) + assertEquals(original.items.value.map { it.isCheck }, back.items.value.map { it.isCheck }) + } + + @Test + fun aNestedChecklistRoundTrips() { + val original = list(BlockType.LIST_CHECK, row("a", check = true), row("b", 1), row("c", 1, check = true)) + + val back = MarkdownBlockMapper.toBlocks(render(original)).single() as Block.ListBlock + + assertEquals(listOf("a" to 0, "b" to 1, "c" to 1), levels(back)) + assertEquals(listOf(true, false, true), back.items.value.map { it.isCheck }) + } + + @Test + fun plainTextIndentsNestedItemsTwoSpacesALevel() { + val numbers = list(BlockType.LIST_NUMBER, row("a"), row("b", 1), row("c", 1), row("d")) + val checks = list(BlockType.LIST_CHECK, row("x", check = true), row("y", 1)) + val mixed = list(BlockType.LIST_BULLET, row("p"), row("q", 1, BlockType.LIST_NUMBER)) + + assertEquals( + "1. a\n 1. b\n 2. c\n2. d\n[x] x\n [ ] y\n- p\n 1. q", + NotesTextFormatter.blocksToPlainText(listOf(numbers, checks, mixed)), + ) + } + + @Test + fun theSearchTextOfANestedListIsItsItemsInOrder() { + val bullets = list(BlockType.LIST_BULLET, row("a"), row("b", 1)) + + assertEquals("a\nb", NotesTextFormatter.blocksToSearchText(listOf(bullets))) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NoteDocumentTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NoteDocumentTest.kt new file mode 100644 index 00000000..2ea028f3 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NoteDocumentTest.kt @@ -0,0 +1,86 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.data.model.Note +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals + +class NoteDocumentTest { + private fun text(value: String) = Block.TextBlock(text = MutableStateFlow(value)) + + private fun heading(type: BlockType, value: String) = Block.HBlock(MutableStateFlow(value), type) + + @Test + fun everyKindOfBlockBecomesItsDocumentBlock() { + val blocks = listOf( + heading(BlockType.H1, "One"), + heading(BlockType.H4, "Four"), + text("Plain"), + Block.QuoteBlock(MutableStateFlow("Said")), + Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = " Site ", url = " https://example.org "))), + Block.SeparatorBlock(), + Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("a"), ItemListBlock(" "))), BlockType.LIST_BULLET), + Block.ListBlock(MutableStateFlow(listOf(ItemListBlock("b"))), BlockType.LIST_NUMBER), + Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("c", isCheck = true), ItemListBlock("d"))), + BlockType.LIST_CHECK, + ), + ) + + assertEquals( + listOf( + DocumentBlock.Heading(1, "One"), + DocumentBlock.Heading(4, "Four"), + DocumentBlock.Paragraph("Plain"), + DocumentBlock.Quote("Said"), + DocumentBlock.Link("Site", "https://example.org"), + DocumentBlock.Separator, + DocumentBlock.Items(ListKind.BULLET, listOf(DocumentItem("a"))), + DocumentBlock.Items(ListKind.NUMBERED, listOf(DocumentItem("b"))), + DocumentBlock.Items(ListKind.CHECKLIST, listOf(DocumentItem("c", true), DocumentItem("d", false))), + ), + NoteDocument.fromBlocks(blocks), + ) + } + + @Test + fun blocksWithoutContentAreLeftOut() { + assertEquals(listOf(DocumentBlock.Paragraph("kept")), NoteDocument.fromBlocks(listOf(text(" "), text("kept")))) + } + + @Test + fun aNoteBecomesADocumentWithItsTitleAndTags() { + val json = BlockConverter.blocksToJson(listOf(text("Body"))) + val note = Note(id = 1, title = " Plan ", value = json) + + val tags = listOf(" Work ", " ", "Home") + val document = NoteDocument.of(note, tags, untitled = "Untitled", unreadable = "Unreadable") + + assertEquals("Plan", document.title) + assertEquals(listOf("Work", "Home"), document.tags) + assertEquals(listOf(DocumentBlock.Paragraph("Body")), document.blocks) + } + + @Test + fun aNoteWithoutATitleOrTagIsNamedAndUntagged() { + val document = NoteDocument.of(Note(id = 1, title = " ", value = "[]"), emptyList(), "Untitled", "Unreadable") + + assertEquals("Untitled", document.title) + assertEquals(emptyList(), document.tags) + assertEquals(emptyList(), document.blocks) + } + + @Test + fun aNoteThatCannotBeReadSaysSo() { + val unreadable = Note(id = 1, title = "T", value = "{not json") + val document = NoteDocument.of(unreadable, listOf(""), "Untitled", "Unreadable") + + assertEquals(listOf(DocumentBlock.Paragraph("Unreadable")), document.blocks) + assertEquals(emptyList(), document.tags) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/data/export/TablesAndCodeExportTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/TablesAndCodeExportTest.kt new file mode 100644 index 00000000..8ca6e801 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/TablesAndCodeExportTest.kt @@ -0,0 +1,101 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.data.model.Note +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.codec.MarkdownBlockMapper +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.TableData +import com.pasich.encly.utils.NotesTextFormatter +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Code and tables in the Markdown export, the document for paper or PDF, and the plain-text renderings. */ +class TablesAndCodeExportTest { + private fun code(value: String) = Block.CodeBlock(MutableStateFlow(value)) + + private fun table(vararg rows: List, header: Boolean = true) = + Block.TableBlock(MutableStateFlow(TableData.of(rows.toList(), header))) + + private fun render(vararg blocks: Block) = MarkdownBlocks.render(blocks.toList()) + + private fun grid(block: Block) = (block as Block.TableBlock).table.value.rows.map { row -> + row.cells.map { it.value } + } + + // --- Markdown ---------------------------------------------------------------------------------------- + + @Test + fun codeIsAFencedBlockThatKeepsEverySpaceAndLine() { + assertEquals("```\n if (a < b) {\n\tx()\n}\n```", render(code(" if (a < b) {\n\tx()\n}"))) + } + + @Test + fun theFenceIsLongerThanAnyRunOfBackticksInsideTheCode() { + assertEquals("````\nuse ``` here\n````", render(code("use ``` here"))) + assertEquals("```\nuse `x` here\n```", render(code("use `x` here"))) + assertEquals("```\na\n```", render(code("a\n\n"))) + } + + @Test + fun aTableIsAGfmPipeTableWithEscapedBarsAndOneLineCells() { + val block = table(listOf("Name", "a|b"), listOf("x *y*", "line\nbreak")) + + assertEquals( + "| Name | a\\|b |\n| --- | --- |\n| x \\*y\\* | line break |", + render(block), + ) + } + + @Test + fun aTableWithoutAHeaderIsWrittenWithAnEmptyHeaderRow() { + assertEquals( + "| | |\n| --- | --- |\n| a | b |", + render(table(listOf("a", "b"), header = false)), + ) + } + + @Test + fun whatTheExportWritesTheImportReadsBackExactly() { + val blocks = listOf( + table(listOf("h1", "h|2"), listOf("c\\d", "e"), listOf("", "f")), + code("```kotlin\nval a = 1 | 2\n```\n\n indented"), + table(listOf("only", "body"), listOf("x", "y"), header = false), + ) + + val back = MarkdownBlockMapper.toBlocks(MarkdownBlocks.render(blocks)) + + assertEquals(3, back.size) + assertEquals(grid(blocks[0]), grid(back[0])) + assertEquals("```kotlin\nval a = 1 | 2\n```\n\n indented", (back[1] as Block.CodeBlock).text.value) + assertEquals(grid(blocks[2]), grid(back[2])) + assertEquals(false, (back[2] as Block.TableBlock).table.value.header) + } + + // --- the document for paper and PDF ------------------------------------------------------------------ + + @Test + fun codeAndTablesAreDrawnAsParagraphsOfTheirText() { + val note = Note.new( + title = "t", + value = BlockConverter.blocksToJson(listOf(code("a\n b"), table(listOf("x", "y"), listOf("z", "")))), + ) + + val document = NoteDocument.of(note, tagNames = emptyList(), untitled = "Untitled", unreadable = "?") + + assertEquals( + listOf(DocumentBlock.Paragraph("a\n b"), DocumentBlock.Paragraph("x | y\nz | ")), + document.blocks, + ) + } + + // --- plain text and search --------------------------------------------------------------------------- + + @Test + fun plainTextAndSearchReadCodeAndEveryCell() { + val blocks = listOf(code("run()"), table(listOf("a", "b"), listOf("c", "d"))) + + assertEquals("run()\na | b\nc | d", NotesTextFormatter.blocksToPlainText(blocks).trimEnd()) + assertEquals("run()\na\nb\nc\nd", NotesTextFormatter.blocksToSearchText(blocks)) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/domain/enums/NoteSortOptionTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/domain/enums/NoteSortOptionTest.kt new file mode 100644 index 00000000..813e117c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/domain/enums/NoteSortOptionTest.kt @@ -0,0 +1,71 @@ +package com.pasich.encly.domain.enums + +import com.pasich.encly.data.model.Note +import kotlin.test.Test +import kotlin.test.assertEquals + +class NoteSortOptionTest { + private val first = Note(id = 1, date = 500L, dateCreate = 100L) + private val second = Note(id = 2, date = 900L, dateCreate = 300L) + + // Lost its creation time: only the last edit is known. + private val lost = Note(id = 3, date = 200L, dateCreate = 0L) + + @Test + fun creationOrderFallsBackToTheEarliestKnownTimeNotToZero() { + val notes = listOf(second, lost, first) + + assertEquals(listOf(1L, 3L, 2L), notes.sortedWith(NoteSortOption.CREATED_ASC.comparator()).map { it.id }) + assertEquals(listOf(2L, 3L, 1L), notes.sortedWith(NoteSortOption.CREATED_DESC.comparator()).map { it.id }) + } + + @Test + fun editOrderIgnoresTheCreationTime() { + val notes = listOf(first, second, lost) + + assertEquals(listOf(3L, 1L, 2L), notes.sortedWith(NoteSortOption.UPDATED_ASC.comparator()).map { it.id }) + assertEquals(listOf(2L, 1L, 3L), notes.sortedWith(NoteSortOption.UPDATED_DESC.comparator()).map { it.id }) + } + + @Test + fun storedIndexesKeepTheirMeaningAndTheNewOrdersAreAppended() { + assertEquals( + listOf( + NoteSortOption.UPDATED_ASC, + NoteSortOption.UPDATED_DESC, + NoteSortOption.CREATED_ASC, + NoteSortOption.CREATED_DESC, + NoteSortOption.TITLE_ASC, + NoteSortOption.TITLE_DESC, + ), + (0..5).map(NoteSortOption::fromIndex), + ) + assertEquals(NoteSortOption.UPDATED_DESC, NoteSortOption.fromIndex(99)) + } + + private fun titled(id: Long, title: String, date: Long = id) = Note(id = id, title = title, date = date) + + @Test + fun titlesSortIgnoringCaseInBothDirections() { + val notes = listOf(titled(1, "banana"), titled(2, "Apple"), titled(3, "cherry"), titled(4, "Banana ")) + + assertEquals(listOf(2L, 4L, 1L, 3L), notes.sortedWith(NoteSortOption.TITLE_ASC.comparator()).map { it.id }) + assertEquals(listOf(3L, 4L, 1L, 2L), notes.sortedWith(NoteSortOption.TITLE_DESC.comparator()).map { it.id }) + } + + @Test + fun equalTitlesFallBackToTheNewestEditFirst() { + val notes = listOf(titled(1, "Same", date = 10), titled(2, "same", date = 30), titled(3, "SAME", date = 20)) + + assertEquals(listOf(2L, 3L, 1L), notes.sortedWith(NoteSortOption.TITLE_ASC.comparator()).map { it.id }) + assertEquals(listOf(2L, 3L, 1L), notes.sortedWith(NoteSortOption.TITLE_DESC.comparator()).map { it.id }) + } + + @Test + fun untitledNotesComeAfterTheTitledOnesWhicheverWayAndKeepTheirEditOrder() { + val notes = listOf(titled(1, "", date = 10), titled(2, "Zed"), titled(3, " ", date = 50), titled(4, "Alpha")) + + assertEquals(listOf(4L, 2L, 3L, 1L), notes.sortedWith(NoteSortOption.TITLE_ASC.comparator()).map { it.id }) + assertEquals(listOf(2L, 4L, 3L, 1L), notes.sortedWith(NoteSortOption.TITLE_DESC.comparator()).map { it.id }) + } +} diff --git a/app/src/test/java/com/pasich/encly/domain/model/NoteListItemTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/domain/model/NoteListItemTest.kt similarity index 75% rename from app/src/test/java/com/pasich/encly/domain/model/NoteListItemTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/domain/model/NoteListItemTest.kt index 99feb2c2..2ca78ca7 100644 --- a/app/src/test/java/com/pasich/encly/domain/model/NoteListItemTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/domain/model/NoteListItemTest.kt @@ -1,12 +1,12 @@ package com.pasich.encly.domain.model import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import com.pasich.encly.data.model.NoteWithTags +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class NoteListItemTest { @@ -39,6 +39,7 @@ class NoteListItemTest { assertFalse(item.matches("1.")) } - private fun item(title: String = "", description: String = "", value: String = "") = - NoteListItem.of(NoteWithTag(Note(id = 1, title = title, description = description, value = value), null)) + private fun item(title: String = "", description: String = "", value: String = "") = NoteListItem.of( + NoteWithTags(Note(id = 1, title = title, description = description, value = value), emptyList()), + ) } diff --git a/app/src/test/java/com/pasich/encly/domain/model/SwipeActionTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/domain/model/SwipeActionTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/domain/model/SwipeActionTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/domain/model/SwipeActionTest.kt index a62adf20..597c9331 100644 --- a/app/src/test/java/com/pasich/encly/domain/model/SwipeActionTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/domain/model/SwipeActionTest.kt @@ -4,9 +4,9 @@ import com.pasich.encly.data.model.Note import com.pasich.encly.presentation.components.swipe.effectiveFor import com.pasich.encly.presentation.components.swipe.toEvent import com.pasich.encly.presentation.viewmodel.NoteListEvent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class SwipeActionTest { private val note = Note(id = 3, color = "none") diff --git a/app/src/test/java/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCaseTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCaseTest.kt similarity index 90% rename from app/src/test/java/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCaseTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCaseTest.kt index 636be074..b599eb8b 100644 --- a/app/src/test/java/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/domain/usecase/tag/ReorderTagsUseCaseTest.kt @@ -3,9 +3,9 @@ package com.pasich.encly.domain.usecase.tag import com.pasich.encly.data.model.Tag import com.pasich.encly.testutil.TestTagsRepository import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class ReorderTagsUseCaseTest { private val repository = TestTagsRepository() diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationCommandRequestsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationCommandRequestsTest.kt new file mode 100644 index 00000000..3edcbc29 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationCommandRequestsTest.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.platform + +import com.pasich.encly.presentation.designsystem.AppCommand +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NavigationCommandRequestsTest { + @Test + fun commandsAreDeliveredOnceInOrderApartFromRoutes() = runTest { + val requests = NavigationRequests() + assertTrue(requests.request(AppCommand.NewNote)) + assertTrue(requests.request("BackupRoute")) + assertTrue(requests.request(AppCommand.LockApp)) + + assertEquals(AppCommand.NewNote, requests.commands.first()) + assertEquals(AppCommand.LockApp, requests.commands.first()) + assertEquals("BackupRoute", requests.routes.first()) + } + + @Test + fun aFullCommandQueueDropsTheRequestInsteadOfBlocking() { + val requests = NavigationRequests() + var accepted = 0 + repeat(1_000) { if (requests.request(AppCommand.Dismiss)) accepted++ } + assertTrue(accepted in 1..999, "the queue is bounded: $accepted") + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationRequestsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationRequestsTest.kt new file mode 100644 index 00000000..5dba8c9d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationRequestsTest.kt @@ -0,0 +1,50 @@ +package com.pasich.encly.platform + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NavigationRequestsTest { + @Test + fun requestsAreDeliveredOnceInOrder() = runTest { + val requests = NavigationRequests() + assertTrue(requests.request("BackupRoute")) + assertTrue(requests.request("SettingsRoute")) + assertEquals("BackupRoute", requests.routes.first()) + assertEquals("SettingsRoute", requests.routes.first()) + } + + @Test + fun theWindowSaysWhetherANoteIsOpenForTheMenu() { + val requests = NavigationRequests() + assertEquals(false, requests.editorOpen.value) + + requests.setEditorOpen(true) + assertEquals(true, requests.editorOpen.value) + + requests.setEditorOpen(false) + assertEquals(false, requests.editorOpen.value) + } + + @Test + fun eachWindowHasItsOwnQueue() = runTest { + val first = NavigationRequests() + val second = NavigationRequests() + + first.request("BackupRoute") + second.request("SettingsRoute") + + assertEquals("BackupRoute", first.routes.first()) + assertEquals("SettingsRoute", second.routes.first()) + } + + @Test + fun aFullQueueDropsTheRequestInsteadOfBlocking() { + val requests = NavigationRequests() + var accepted = 0 + repeat(1_000) { if (requests.request("HomeRoute")) accepted++ } + assertTrue(accepted in 1..999, "the queue is bounded: $accepted") + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/platform/NoteHostServicesTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NoteHostServicesTest.kt new file mode 100644 index 00000000..9e1ef05e --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NoteHostServicesTest.kt @@ -0,0 +1,81 @@ +package com.pasich.encly.platform + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NoteHostServicesTest { + private val registry = NoteEditRegistry() + private val first = registry.forWindow("first") + private val second = registry.forWindow("second") + + @Test + fun aNoteHasOneEditorWindowAtATime() { + assertTrue(first.claim(7)) + assertFalse(second.claim(7)) + assertTrue(registry.isClaimed(7)) + } + + @Test + fun otherNotesAreNotAffected() { + assertTrue(first.claim(7)) + assertTrue(second.claim(8)) + } + + @Test + fun aWindowMayClaimItsNoteAgainWhileAnEditorIsBeingReplaced() { + assertTrue(first.claim(7)) + // The new editor of the same note arrives before the old one is gone. + assertTrue(first.claim(7)) + first.release(7) + assertTrue(registry.isClaimed(7), "the replaced editor's claim is still held") + assertFalse(second.claim(7)) + first.release(7) + assertFalse(registry.isClaimed(7)) + assertTrue(second.claim(7)) + } + + @Test + fun releasingGivesTheNoteBack() { + assertTrue(first.claim(7)) + first.release(7) + assertTrue(second.claim(7)) + } + + @Test + fun aWindowCannotReleaseAnotherWindowsClaim() { + assertTrue(first.claim(7)) + second.release(7) + assertFalse(second.claim(7)) + assertTrue(registry.isClaimed(7)) + } + + @Test + fun releasingWhatWasNeverClaimedChangesNothing() { + first.release(7) + assertFalse(registry.isClaimed(7)) + } + + @Test + fun aRefusedWindowIsToldWhenANoteIsGivenBack() { + assertTrue(first.claim(7)) + val before = second.releaseCount() + assertFalse(second.claim(7)) + + first.release(7) + + assertTrue(second.releases.value != before) + assertTrue(second.claim(7)) + } + + @Test + fun aClaimStillHeldByTheReplacedEditorIsNotAGiveBack() { + assertTrue(first.claim(7)) + assertTrue(first.claim(7)) + val before = first.releaseCount() + + first.release(7) + + assertTrue(first.releaseCount() == before) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt similarity index 92% rename from app/src/test/java/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt index 324d24e2..38ae7484 100644 --- a/app/src/test/java/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt @@ -1,9 +1,9 @@ package com.pasich.encly.presentation.designsystem -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue /** The segment bar's layout: never wider than its 160 dp, and honest about done and open. */ class SegmentBarTest { diff --git a/app/src/test/java/com/pasich/encly/presentation/designsystem/WordGridColumnsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/designsystem/WordGridColumnsTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/designsystem/WordGridColumnsTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/designsystem/WordGridColumnsTest.kt index 1bdeff55..895c4033 100644 --- a/app/src/test/java/com/pasich/encly/presentation/designsystem/WordGridColumnsTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/designsystem/WordGridColumnsTest.kt @@ -1,7 +1,7 @@ package com.pasich.encly.presentation.designsystem -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals /** * Widths in dp at density 1: a 360 dp phone less the 24 dp gutters leaves 312 for the grid, an diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistenceTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistenceTest.kt new file mode 100644 index 00000000..5980ad4a --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistenceTest.kt @@ -0,0 +1,714 @@ +package com.pasich.encly.presentation.editor.persistence + +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.testutil.TestNotesRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NotePersistenceTest { + private val dispatcher = StandardTestDispatcher() + private val repository = TestNotesRepository().apply { insertResult = INSERTED_ID } + private var blocks: List = listOf(Block.TextBlock()) + + // --- saving ------------------------------------------------------------------------------- + + @Test + fun aNewNoteIsInsertedOnceAndThenUpdated() = runTest(dispatcher) { + val persistence = persistence() + persistence.updateNote { it.copy(title = "draft") } + + assertTrue(persistence.save()) + assertEquals(INSERTED_ID, persistence.state.value.note.id) + assertTrue(persistence.save()) + + assertEquals(1, repository.insertCalls) + assertEquals(1, repository.updateCalls) + assertEquals(SaveStatusNote.SAVED, persistence.status.value) + } + + @Test + fun theCreationTimeSurvivesTheAutosavesAfterTheFirstInsert() = runTest(dispatcher) { + val persistence = persistence() + persistence.updateNote { it.copy(title = "draft") } + + assertTrue(persistence.save()) + val created = repository.insertedNotes.single().dateCreate + assertTrue(created > 0L) + assertEquals(created, persistence.state.value.note.dateCreate) + + blocks = listOf(text("more")) + assertTrue(persistence.save()) + + assertEquals(created, repository.updatedNotes.single().dateCreate) + } + + @Test + fun aBlankNewNoteIsNotStored() = runTest(dispatcher) { + val persistence = persistence() + blocks = listOf(text(" \n "), Block.LinkBlock()) + + assertTrue(persistence.save()) + + assertEquals(0, repository.insertCalls) + } + + @Test + fun aFailedInsertIsReportedAndLeavesTheNoteNew() = runTest(dispatcher) { + repository.insertResult = 0L + val persistence = persistence() + persistence.updateNote { it.copy(title = "draft") } + + assertFalse(persistence.save()) + + assertEquals(-1L, persistence.state.value.note.id) + // The editor keeps saying so until a save succeeds. + assertEquals(SaveStatusNote.FAILED, persistence.status.value) + repository.insertResult = INSERTED_ID + assertTrue(persistence.save()) + assertEquals(SaveStatusNote.SAVED, persistence.status.value) + } + + @Test + fun aSavedEditMovesTheShownDateAndDropsAnImportedSummary() = runTest(dispatcher) { + repository.allNotesWithTags.value = listOf( + NoteWithTags( + Note( + id = NOTE_ID, + title = "title", + value = BlockConverter.blocksToJson(listOf(text("body"))), + description = "imported summary", + date = OLD_DATE, + ), + emptyList(), + ), + ) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + blocks = listOf(text("body, edited")) + + assertTrue(persistence.save()) + + val stored = repository.updatedNotes.single() + assertTrue(stored.date > OLD_DATE) + assertEquals("", stored.description) + assertEquals(stored.date, persistence.state.value.note.date) + } + + @Test + fun anUnchangedNoteKeepsItsDateAndSummary() = runTest(dispatcher) { + repository.allNotesWithTags.value = listOf( + NoteWithTags( + Note( + id = NOTE_ID, + title = "title", + value = BlockConverter.blocksToJson(listOf(text("body"))), + description = "imported summary", + date = OLD_DATE, + ), + emptyList(), + ), + ) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + + assertTrue(persistence.save()) + + assertEquals(OLD_DATE, repository.updatedNotes.single().date) + assertEquals("imported summary", repository.updatedNotes.single().description) + } + + @Test + fun changesAreWhatDiscardWouldUndo() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + assertFalse(persistence.hasChanges()) + + blocks = listOf(text("body, edited")) + assertTrue(persistence.hasChanges()) + + blocks = listOf(text("body")) + persistence.updateNote { it.copy(tagIds = setOf(TAG_ID)) } + assertTrue(persistence.hasChanges()) + } + + @Test + fun aNewNoteWithoutTitleOrContentIsABlankDraft() = runTest(dispatcher) { + val persistence = persistence() + assertTrue(persistence.isBlankDraft) + + blocks = listOf(text("x")) + assertFalse(persistence.isBlankDraft) + } + + @Test + fun anEmptyTableOrCodeBlockIsContentTheUserMadeNotABlankDraft() = runTest(dispatcher) { + val persistence = persistence() + + blocks = listOf(Block.TableBlock()) + assertFalse(persistence.isBlankDraft) + + blocks = listOf(Block.CodeBlock()) + assertFalse(persistence.isBlankDraft) + } + + @Test + fun nothingIsSavedWhileTheNoteIsLoading() = runTest(dispatcher) { + val persistence = persistence(loading = true) + persistence.updateNote { it.copy(title = "typed before the note arrived") } + + assertTrue(persistence.save()) + + assertEquals(0, repository.insertCalls) + assertEquals(0, repository.updateCalls) + } + + @Test + fun aReadOnlyNoteIsNeverSaved() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true, readOnly = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + blocks = listOf(text("edited")) + + assertTrue(persistence.save()) + + assertEquals(0, repository.updateCalls) + } + + @Test + fun aSuspendedEditorWritesNothingUntilItIsLifted() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + blocks = listOf(text("edited")) + + // Another window edits the note: whatever this editor holds must not overwrite it. + persistence.suspended = true + assertTrue(persistence.save()) + assertFalse(persistence.moveToTrash()) + assertFalse(persistence.setArchived(true)) + assertFalse(persistence.restoreFromTrash()) + assertFalse(persistence.discard()) + assertFalse(persistence.delete()) + assertEquals(0, repository.updateCalls) + assertTrue(repository.deletedIds.isEmpty()) + + persistence.suspended = false + assertTrue(persistence.save()) + assertEquals(1, repository.updateCalls) + } + + // --- loading ------------------------------------------------------------------------------ + + @Test + fun loadingShowsTheBlocksAndKeepsTheNoteId() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + + assertTrue(persistence.load(NOTE_ID, isCopy = false) { blocks = it }) + + assertEquals("body", (blocks.single() as Block.TextBlock).text.value) + assertEquals(NOTE_ID, persistence.state.value.note.id) + assertEquals(SaveStatusNote.OLD, persistence.status.value) + } + + @Test + fun aCopyIsANewUntaggedNote() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + + persistence.load(NOTE_ID, isCopy = true) { blocks = it } + + assertEquals(-1L, persistence.state.value.note.id) + assertEquals("title (копія)", persistence.state.value.note.title) + assertEquals(emptySet(), persistence.state.value.note.tagIds) + } + + @Test + fun aMissingNoteIsReportedAndNeverSaved() = runTest(dispatcher) { + val persistence = persistence(loading = true) + + assertFalse(persistence.load(NOTE_ID, isCopy = false) { blocks = it }) + persistence.updateNote { it.copy(title = "x") } + persistence.save() + + assertTrue(persistence.contentLoadFailed.value) + assertEquals(0, repository.insertCalls) + } + + @Test + fun unreadableContentIsNotOverwritten() = runTest(dispatcher) { + repository.allNotesWithTags.value = + listOf(NoteWithTags(Note(id = NOTE_ID, title = "t", value = "{not json"), emptyList())) + val persistence = persistence(loading = true) + + // Reported as not loaded, so the editor stays read-only; the title is still shown. + assertFalse(persistence.load(NOTE_ID, isCopy = false) { blocks = it }) + assertEquals("t", persistence.state.value.note.title) + blocks = listOf(text("edited")) + persistence.save() + + assertTrue(persistence.contentLoadFailed.value) + assertEquals(0, repository.updateCalls) + } + + @Test + fun duplicatingUnreadableContentCopiesTheStoredText() = runTest(dispatcher) { + repository.allNotesWithTags.value = + listOf(NoteWithTags(Note(id = NOTE_ID, title = "t", value = "{not json"), emptyList())) + val persistence = persistence(loading = true) + assertFalse(persistence.load(NOTE_ID, isCopy = false) { blocks = it }) + + persistence.duplicate() + + // The editor shows nothing it could read, so its empty blocks must not become the copy. + assertEquals("{not json", repository.insertedNotes.single().value) + } + + @Test + fun aCopyOfUnreadableContentIsReportedAndNeverInserted() = runTest(dispatcher) { + repository.allNotesWithTags.value = + listOf(NoteWithTags(Note(id = NOTE_ID, title = "t", value = "{not json"), emptyList())) + val persistence = persistence(loading = true) + + assertFalse(persistence.load(NOTE_ID, isCopy = true) { blocks = it }) + blocks = listOf(text("typed")) + persistence.save() + + assertEquals(0, repository.insertCalls) + } + + // --- closing: trash, discard, delete ------------------------------------------------------ + + @Test + fun aSaveAfterTrashCannotBringTheNoteBack() = runTest(dispatcher) { + storeNote(text("original")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + blocks = listOf(text("edited")) + + assertTrue(persistence.moveToTrash()) + persistence.save() + + // What was typed is stored first, then the trash columns are written; nothing after the close. + assertEquals(2, repository.updateCalls) + assertFalse(repository.updatedNotes.first().isTrash) + assertEquals(BlockConverter.blocksToJson(listOf(text("edited"))), repository.updatedNotes.first().value) + assertTrue(repository.updatedNotes.last().isTrash) + assertTrue(persistence.state.value.note.isTrash) + } + + @Test + fun trashingAnUntouchedNoteWritesTheTrashColumnsOnly() = runTest(dispatcher) { + storeNote(text("original")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + + assertTrue(persistence.moveToTrash()) + + assertEquals(1, repository.updateCalls) + assertTrue(repository.updatedNotes.single().isTrash) + assertTrue(repository.updateBases.isEmpty()) + } + + @Test + fun aSaveWritesOnlyAgainstTheRowTheEditorLastSaw() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + blocks = listOf(text("body, edited")) + + assertTrue(persistence.save()) + + // The base is the note as loaded (not trashed, not archived, no pin): the repository diffs against it. + val base = checkNotNull(repository.updateBases.single()) + assertEquals("title", base.title) + assertEquals(BlockConverter.blocksToJson(listOf(text("body"))), base.value) + } + + @Test + fun releasingSavesTheLastChangesAndThenWritesNothing() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + blocks = listOf(text("typed just before")) + + assertTrue(persistence.release()) + + assertEquals(BlockConverter.blocksToJson(blocks), repository.updatedNotes.single().value) + // Released: an autosave or a last-chance save finds nothing to do. + blocks = listOf(text("typed after")) + assertTrue(persistence.save()) + assertEquals(1, repository.updateCalls) + } + + @Test + fun anEditorThatRegainsTheNoteShowsWhatWasSavedMeanwhile() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + assertTrue(persistence.release()) + + // Another window saves new text. + val other = BlockConverter.blocksToJson(listOf(text("from the other window"))) + repository.allNotesWithTags.value = + listOf(NoteWithTags(Note(id = NOTE_ID, title = "title", value = other), emptyList())) + persistence.resume { blocks = it } + + assertEquals("from the other window", (blocks.single() as Block.TextBlock).text.value) + // Editing resumed on top of the fresh text: nothing is pending, and a change is saved again. + assertTrue(persistence.save(onlyIfChanged = true)) + assertEquals(0, repository.updateCalls) + blocks = listOf(text("and mine")) + assertTrue(persistence.save()) + assertEquals(1, repository.updateCalls) + } + + @Test + fun anEditorThatRegainsAnUnchangedNoteKeepsWhatItHolds() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + assertTrue(persistence.release()) + var reloaded = false + + persistence.resume { reloaded = true } + + assertFalse(reloaded) + } + + @Test + fun aViewOfANoteEditedElsewhereFollowsWhatTheOtherWindowSaves() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + persistence.suspended = true + + val saved = Note( + id = NOTE_ID, + title = "renamed", + value = BlockConverter.blocksToJson(listOf(text("new text"))), + ) + persistence.showStored(saved) { blocks = it } + + assertEquals("new text", (blocks.single() as Block.TextBlock).text.value) + assertEquals("renamed", persistence.state.value.note.title) + } + + @Test + fun anEditorThatIsNotSuspendedIgnoresTheStoredNote() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + var shown = false + + persistence.showStored(Note(id = NOTE_ID, title = "other", value = "[]")) { shown = true } + + assertFalse(shown) + assertEquals("title", persistence.state.value.note.title) + } + + @Test + fun restoringTakesTheNoteOutOfTheTrash() = runTest(dispatcher) { + storeNote(text("original"), isTrash = true) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + + assertTrue(persistence.restoreFromTrash()) + + assertFalse(repository.updatedNotes.single().isTrash) + assertNull(repository.updatedNotes.single().trashedAt) + } + + @Test + fun discardPutsBackTheOpenedNoteAndBlocksLaterSaves() = runTest(dispatcher) { + storeNote(text("original")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + blocks = listOf(text("edited")) + + assertTrue(persistence.discard()) + persistence.save() + + assertEquals(BlockConverter.blocksToJson(listOf(text("original"))), repository.updatedNotes.single().value) + } + + @Test + fun discardingAnAutosavedNewNoteDeletesIt() = runTest(dispatcher) { + val persistence = persistence() + persistence.updateNote { it.copy(title = "draft") } + persistence.save() + + assertTrue(persistence.discard()) + + assertEquals(listOf(INSERTED_ID), repository.deletedIds) + } + + @Test + fun deletingANewNoteTouchesNothing() = runTest(dispatcher) { + val persistence = persistence() + + assertTrue(persistence.delete()) + + assertTrue(repository.deletedIds.isEmpty()) + } + + @Test + fun aDuplicateIsStoredAsACopy() = runTest(dispatcher) { + val persistence = persistence() + persistence.updateNote { it.copy(title = "note") } + + assertEquals(INSERTED_ID, persistence.duplicate()) + assertEquals("note (копія)", repository.insertedNotes.single().title) + } + + @Test + fun titleAndTagChangedWhileTheFirstInsertRunsAreKeptAndStored() = runTest(dispatcher) { + val gate = CompletableDeferred() + repository.insertGate = gate + val persistence = persistence() + persistence.updateNote { it.copy(title = "dra") } + + val firstSave = async { persistence.save() } + runCurrent() // the insert is in flight + persistence.updateNote { it.copy(title = "draft", tagIds = setOf(TAG_ID)) } + gate.complete(Unit) + assertTrue(firstSave.await()) + + val note = persistence.state.value.note + assertEquals(INSERTED_ID, note.id) + assertEquals("draft", note.title) + assertEquals(setOf(TAG_ID), note.tagIds) + + // The later changes are not in the database yet, so the next save writes them. + assertTrue(persistence.save()) + assertEquals("draft", repository.updatedNotes.single().title) + assertEquals(setOf(TAG_ID), repository.updatedNotes.single().tagIds) + } + + @Test + fun aTagChangeAloneIsAutosaved() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + persistence.startAutosave(backgroundScope, contentChangesOf(MutableStateFlow(Unit))) + passQuietTime() + assertEquals(0, repository.updateCalls) + + persistence.updateNote { it.copy(tagIds = setOf(TAG_ID)) } + passQuietTime() + + assertEquals(setOf(TAG_ID), repository.updatedNotes.single().tagIds) + assertEquals(BlockConverter.blocksToJson(listOf(text("body"))), repository.updatedNotes.single().value) + } + + @Test + fun aSaveCarriesTheTagsWithTheBaseTheyChangedFrom() = runTest(dispatcher) { + storeNote(text("body"), tagIds = setOf(1L, 2L)) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = it } + assertEquals(setOf(1L, 2L), persistence.state.value.note.tagIds) + + persistence.updateNote { it.copy(tagIds = it.tagIds - 1L + 3L) } + assertTrue(persistence.hasChanges()) + assertTrue(persistence.save()) + + // The repository writes the difference: 3 on, 1 off, and 2 (untouched here) as it is stored. + assertEquals(setOf(2L, 3L), repository.updatedNotes.single().tagIds) + assertEquals(setOf(1L, 2L), repository.updateBases.single()?.tagIds) + } + + @Test + fun aCopyKeepsTheTagsOfItsOriginal() = runTest(dispatcher) { + storeNote(text("body"), tagIds = setOf(1L, 2L)) + val persistence = persistence(loading = true) + + persistence.load(NOTE_ID, isCopy = true) { blocks = it } + assertTrue(persistence.save()) + + assertEquals(setOf(1L, 2L), repository.insertedNotes.single().tagIds) + } + + // --- autosave ----------------------------------------------------------------------------- + + @Test + fun autosaveWritesOnceAfterTheQuietTime() = runTest(dispatcher) { + val changes = MutableSharedFlow(replay = 1) + val persistence = persistence() + persistence.startAutosave(backgroundScope, changes) + changes.emit(Unit) + + persistence.updateNote { it.copy(title = "a") } + runCurrent() + persistence.updateNote { it.copy(title = "ab") } + advanceTimeBy(AUTOSAVE_DEBOUNCE_MS - 1) + runCurrent() + assertEquals(0, repository.insertCalls) + + advanceTimeBy(2) + runCurrent() + assertEquals(1, repository.insertCalls) + } + + @Test + fun openingANoteAutosavesNothingButTheFirstEditIsSaved() = runTest(dispatcher) { + storeNote(text("body")) + val body = MutableStateFlow("body") + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { blocks = listOf(Block.TextBlock(body)) } + persistence.startAutosave(backgroundScope, contentChangesOf(body)) + passQuietTime() + assertEquals(0, repository.updateCalls) + + body.value = "body, edited" + passQuietTime() + + assertEquals(1, repository.updateCalls) + } + + @Test + fun autosaveStopsWhenTheEditorCloses() = runTest(dispatcher) { + val persistence = persistence() + persistence.startAutosave(backgroundScope, contentChangesOf(MutableStateFlow(Unit))) + persistence.updateNote { it.copy(title = "draft") } + + persistence.close() + passQuietTime() + + assertEquals(0, repository.insertCalls) + } + + // --- pin, archive and colour ---------------------------------------------------------------- + + @Test + fun aNewNoteKeepsItsPinAndColourWhenFirstStored() = runTest(dispatcher) { + val persistence = persistence() + persistence.updateNote { it.copy(title = "draft", isPinned = true, color = "teal") } + + assertTrue(persistence.save()) + + val inserted = repository.insertedNotes.single() + assertTrue(inserted.isPinned) + assertEquals("teal", inserted.color) + } + + @Test + fun aChangedPinOrColourIsAutosavedAlthoughTheContentIsTheSame() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { } + val changes = MutableStateFlow(0) + persistence.startAutosave(backgroundScope, contentChangesOf(changes)) + passQuietTime() + assertEquals(0, repository.updateCalls) + + persistence.updateNote { it.copy(isPinned = true) } + passQuietTime() + persistence.updateNote { it.copy(color = "blue") } + passQuietTime() + + assertEquals(listOf(true, true), repository.updatedNotes.map { it.isPinned }) + assertEquals(listOf("none", "blue"), repository.updatedNotes.map { it.color }) + } + + @Test + fun archivingUnpinsAndClosesTheEditor() = runTest(dispatcher) { + storeNote(text("body")) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = false) { } + persistence.updateNote { it.copy(isPinned = true) } + + assertTrue(persistence.setArchived(true)) + + assertEquals(listOf("archived:$NOTE_ID=true"), repository.columnWrites) + assertFalse(persistence.state.value.note.isPinned) + // Closed: a later save cannot write the note again. + assertTrue(persistence.save()) + assertEquals(0, repository.updateCalls) + } + + @Test + fun aNewUnsavedNoteCannotBeArchived() = runTest(dispatcher) { + assertFalse(persistence().setArchived(true)) + assertEquals(0, repository.updateCalls) + } + + @Test + fun aCopyKeepsTheColourButIsNeitherPinnedNorArchived() = runTest(dispatcher) { + repository.allNotesWithTags.value = listOf( + NoteWithTags( + Note(id = NOTE_ID, title = "t", value = "[]", isPinned = true, isArchived = true, color = "pink"), + emptyList(), + ), + ) + val persistence = persistence(loading = true) + persistence.load(NOTE_ID, isCopy = true) { } + + val copy = persistence.state.value.note + assertEquals(-1L, copy.id) + assertFalse(copy.isPinned) + assertFalse(copy.isArchived) + assertEquals("pink", copy.color) + } + + // The autosave runs in backgroundScope, whose timers advanceUntilIdle() does not wait for. + private fun TestScope.passQuietTime() { + advanceTimeBy(AUTOSAVE_DEBOUNCE_MS + 1) + runCurrent() + } + + private fun contentChangesOf(flow: MutableStateFlow<*>) = flow { + flow.collect { emit(Unit) } + } + + private fun persistence(loading: Boolean = false, readOnly: Boolean = false) = NotePersistence( + notesRepository = repository, + updateNoteTrashStatusUseCase = UpdateNoteTrashStatusUseCase(repository), + updateNoteArchiveStatusUseCase = UpdateNoteArchiveStatusUseCase(repository), + ioDispatcher = dispatcher, + readOnly = readOnly, + blocks = { blocks }, + copyTitle = { "$it (копія)" }, + loading = loading, + ) + + private fun storeNote(vararg content: Block, isTrash: Boolean = false, tagIds: Set = emptySet()) { + val note = Note( + id = NOTE_ID, + title = "title", + value = BlockConverter.blocksToJson(content.toList()), + isTrash = isTrash, + tagIds = tagIds, + ) + repository.allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) + } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private companion object { + const val NOTE_ID = 91L + const val INSERTED_ID = 73L + const val TAG_ID = 5L + const val OLD_DATE = 1_000L + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ContainerFadeTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ContainerFadeTest.kt new file mode 100644 index 00000000..cbc0f134 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ContainerFadeTest.kt @@ -0,0 +1,63 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.animation.EnterExitState +import androidx.compose.ui.graphics.CompositingStrategy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The editor's fade in the container transform. Whether a `graphicsLayer` block really allocates no + * offscreen surface cannot be read back from a node, so the strategy it is given is a named constant + * that is asserted here, and the alpha itself is a pure function of the transition's clock. + */ +class ContainerFadeTest { + private val visible = EnterExitState.Visible + private val preEnter = EnterExitState.PreEnter + private val postExit = EnterExitState.PostExit + + @Test + fun theFadeIsNeverCompositedAsAnOffscreenGroup() { + assertEquals(CompositingStrategy.ModulateAlpha, FADE_COMPOSITING) + assertNotEquals(CompositingStrategy.Auto, FADE_COMPOSITING) + assertNotEquals(CompositingStrategy.Offscreen, FADE_COMPOSITING) + } + + @Test + fun atRestTheEditorIsThereOrGone() { + assertEquals(1f, containerFadeAlpha(visible, visible, 0f)) + assertEquals(1f, containerFadeAlpha(visible, visible, 9_999f)) + assertEquals(0f, containerFadeAlpha(postExit, postExit, 0f)) + assertEquals(0f, containerFadeAlpha(preEnter, preEnter, 0f)) + } + + @Test + fun enteringWaitsThenEasesInAndEndsOpaque() { + assertEquals(0f, containerFadeAlpha(preEnter, visible, 0f)) + assertEquals(0f, containerFadeAlpha(preEnter, visible, CONTAINER_FADE_IN_DELAY.toFloat())) + val midway = containerFadeAlpha(preEnter, visible, (CONTAINER_FADE_IN_DELAY + CONTAINER_FADE_IN / 2).toFloat()) + assertTrue(midway > 0f && midway < 1f, "midway was $midway") + assertEquals(1f, containerFadeAlpha(preEnter, visible, (CONTAINER_FADE_IN_DELAY + CONTAINER_FADE_IN).toFloat())) + assertEquals(1f, containerFadeAlpha(preEnter, visible, 10_000f)) + } + + @Test + fun leavingEasesOutQuicklyAndEndsGone() { + assertEquals(1f, containerFadeAlpha(visible, postExit, 0f)) + val midway = containerFadeAlpha(visible, postExit, CONTAINER_FADE_OUT / 2f) + assertTrue(midway > 0f && midway < 1f, "midway was $midway") + assertEquals(0f, containerFadeAlpha(visible, postExit, CONTAINER_FADE_OUT.toFloat())) + assertEquals(0f, containerFadeAlpha(visible, postExit, 10_000f)) + } + + @Test + fun theAlphaNeverLeavesTheUnitRange() { + for (t in -50..1_000 step 7) { + for ((from, to) in listOf(preEnter to visible, visible to postExit, visible to visible)) { + val alpha = containerFadeAlpha(from, to, t.toFloat()) + assertTrue(alpha in 0f..1f, "alpha $alpha at $t ms ($from to $to)") + } + } + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ScreenFadeTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ScreenFadeTest.kt new file mode 100644 index 00000000..5c3be22b --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ScreenFadeTest.kt @@ -0,0 +1,107 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.animation.EnterExitState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The fade of a screen swap, now drawn from the transition's clock instead of by `fadeIn`/`fadeOut` + * (which put the whole window in an offscreen layer). It keeps the timings those had, and a screen + * that must not fade (the lock screen) does not. + */ +class ScreenFadeTest { + private val visible = EnterExitState.Visible + private val preEnter = EnterExitState.PreEnter + private val postExit = EnterExitState.PostExit + + private fun alpha(motion: ScreenMotion, from: EnterExitState, to: EnterExitState, ms: Float) = + fadeAlpha(motion.enterFade(), motion.exitFade(), from, to, ms) + + @Test + fun theSharedAxisKeepsItsOldTimings() { + val enter = assertNotNull(ScreenMotion.AXIS.enterFade()) + val exit = assertNotNull(ScreenMotion.AXIS.exitFade()) + assertEquals(60, enter.delayMillis) + assertEquals(210, enter.durationMillis) + assertEquals(0, exit.delayMillis) + assertEquals(120, exit.durationMillis) + } + + @Test + fun theContainerFadeKeepsItsOldTimings() { + val enter = assertNotNull(ScreenMotion.FADE.enterFade()) + val exit = assertNotNull(ScreenMotion.FADE.exitFade()) + assertEquals(CONTAINER_FADE_IN_DELAY, enter.delayMillis) + assertEquals(CONTAINER_FADE_IN, enter.durationMillis) + assertEquals(CONTAINER_FADE_IN, exit.durationMillis) + } + + @Test + fun noMotionNeverFades() { + assertNull(ScreenMotion.NONE.enterFade()) + assertNull(ScreenMotion.NONE.exitFade()) + for (t in 0..400 step 20) { + assertEquals(1f, alpha(ScreenMotion.NONE, preEnter, visible, t.toFloat())) + assertEquals(1f, alpha(ScreenMotion.NONE, visible, postExit, t.toFloat())) + } + } + + @Test + fun atRestAScreenIsThereOrGone() { + for (motion in ScreenMotion.entries) { + assertEquals(1f, alpha(motion, visible, visible, 0f)) + assertEquals(0f, alpha(motion, postExit, postExit, 0f)) + assertEquals(0f, alpha(motion, preEnter, preEnter, 0f)) + } + } + + @Test + fun enteringWaitsThenEndsOpaqueAndLeavingEndsGone() { + for (motion in listOf(ScreenMotion.AXIS, ScreenMotion.FADE)) { + val enter = assertNotNull(motion.enterFade()) + val exit = assertNotNull(motion.exitFade()) + assertEquals(0f, alpha(motion, preEnter, visible, 0f)) + assertEquals(1f, alpha(motion, preEnter, visible, enter.endMillis.toFloat())) + assertEquals(1f, alpha(motion, visible, postExit, 0f)) + assertEquals(0f, alpha(motion, visible, postExit, exit.endMillis.toFloat())) + } + } + + @Test + fun theAlphaNeverLeavesTheUnitRange() { + for (motion in ScreenMotion.entries) { + for (t in -50..1_000 step 7) { + for ((from, to) in listOf(preEnter to visible, visible to postExit)) { + val a = alpha(motion, from, to, t.toFloat()) + assertTrue(a in 0f..1f, "alpha $a at $t ms ($motion, $from to $to)") + } + } + } + } + + @Test + fun aFadingScreenIsHeldForAsLongAsItsFade() { + // Its motion is only a one-pixel hold, so the transition must last as long as the fade. + assertEquals(CONTAINER_FADE_IN_DELAY + CONTAINER_FADE_IN, ScreenMotion.FADE.enterFade()?.endMillis) + assertEquals(CONTAINER_FADE_IN, ScreenMotion.FADE.exitFade()?.endMillis) + } + + @Test + fun aScreenWithNoRecordedMotionDoesNotFade() { + assertEquals(ScreenMotion.NONE, ScreenMotions.of("never-seen")) + ScreenMotions.record("e1", ScreenMotion.AXIS) + assertEquals(ScreenMotion.AXIS, ScreenMotions.of("e1")) + ScreenMotions.record("e1", ScreenMotion.FADE) + assertEquals(ScreenMotion.FADE, ScreenMotions.of("e1")) + } + + @Test + fun theRegistryKeepsOnlyTheLatestEntries() { + repeat(100) { ScreenMotions.record("old-$it", ScreenMotion.AXIS) } + assertEquals(ScreenMotion.AXIS, ScreenMotions.of("old-99")) + assertEquals(ScreenMotion.NONE, ScreenMotions.of("old-0")) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/effects/ScreenMotionTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ScreenMotionTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/effects/ScreenMotionTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ScreenMotionTest.kt index b4df5aaf..bc0aa0fb 100644 --- a/app/src/test/java/com/pasich/encly/presentation/effects/ScreenMotionTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ScreenMotionTest.kt @@ -1,8 +1,8 @@ package com.pasich.encly.presentation.effects import com.pasich.encly.presentation.navigation.NavRoutes -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals /** Which motion swaps two screens: the note card grows into the editor, a re-lock never cross-fades. */ class ScreenMotionTest { diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouterTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouterTest.kt new file mode 100644 index 00000000..07dd341b --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouterTest.kt @@ -0,0 +1,228 @@ +package com.pasich.encly.presentation.navigation + +import com.pasich.encly.presentation.designsystem.AppCommand +import com.pasich.encly.presentation.pane.ActiveEditor +import com.pasich.encly.presentation.pane.NoteTabs +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The router runs what the screen that is on screen registered, and says "not handled" where nothing is. */ +class AppCommandRouterTest { + private val scope = TestScope(UnconfinedTestDispatcher()) + private var locks = 0 + private var fallbacks = 0 + private var fallbackResult = true + private val router = AppCommandRouter( + scope = scope, + lockApp = { locks++ }, + newNoteFallback = { + fallbacks++ + fallbackResult + }, + ) + + private class RecordingEditor(var allowTrash: Boolean = true, var findOpen: Boolean = true) { + val calls = mutableListOf() + val commands = EditorCommands( + find = { true.also { calls += "find" } }, + moveToTrash = { allowTrash.also { calls += "trash" } }, + dismiss = { findOpen.also { calls += "dismiss" } }, + ) + } + + @Test + fun aNewNoteIsStartedByTheHomeScreenWhenItIsThere() { + var started = 0 + router.attachNewNote { started++ } + + assertTrue(router.run(AppCommand.NewNote)) + + assertEquals(1, started) + assertEquals(0, fallbacks) + } + + @Test + fun withoutAHomeScreenTheFallbackStartsTheNote() { + assertTrue(router.run(AppCommand.NewNote)) + assertEquals(1, fallbacks) + + fallbackResult = false + assertFalse(router.run(AppCommand.NewNote)) + } + + @Test + fun aHomeScreenThatLeftNoLongerStartsNotes() { + val action: () -> Unit = { error("left") } + router.attachNewNote(action) + router.detachNewNote(action) + + router.run(AppCommand.NewNote) + + assertEquals(1, fallbacks) + } + + @Test + fun aNewerHomeScreenIsNotReplacedByTheOldOnesLeaving() { + var older = 0 + var newer = 0 + val first: () -> Unit = { older++ } + val second: () -> Unit = { newer++ } + router.attachNewNote(first) + router.attachNewNote(second) + router.detachNewNote(first) + + router.run(AppCommand.NewNote) + + assertEquals(0, older) + assertEquals(1, newer) + } + + @Test + fun lockingIsAlwaysDone() { + assertTrue(router.run(AppCommand.LockApp)) + assertEquals(1, locks) + } + + @Test + fun theEditorCommandsReachTheEditorOnScreen() { + val editor = RecordingEditor() + router.attachEditor(editor.commands) + + assertTrue(router.run(AppCommand.FindInNote)) + assertTrue(router.run(AppCommand.DeleteNote)) + assertTrue(router.run(AppCommand.Dismiss)) + + assertEquals(listOf("find", "trash", "dismiss"), editor.calls) + } + + @Test + fun printAndPdfReachTheEditorOnScreenAndOnlyThere() { + assertFalse(router.hasEditor) + assertFalse(router.run(AppCommand.PrintNote)) + assertFalse(router.run(AppCommand.ExportPdf)) + + val calls = mutableListOf() + val commands = EditorCommands( + find = { false }, + moveToTrash = { false }, + dismiss = { false }, + print = { true.also { calls += "print" } }, + exportPdf = { true.also { calls += "pdf" } }, + ) + router.attachEditor(commands) + + assertTrue(router.hasEditor) + assertTrue(router.run(AppCommand.PrintNote)) + assertTrue(router.run(AppCommand.ExportPdf)) + assertEquals(listOf("print", "pdf"), calls) + + router.detachEditor(commands) + assertFalse(router.hasEditor) + } + + @Test + fun anEditorWithoutDocumentSupportDoesNotPrint() { + router.attachEditor(RecordingEditor().commands) + + assertFalse(router.run(AppCommand.PrintNote)) + assertFalse(router.run(AppCommand.ExportPdf)) + } + + @Test + fun anEditorThatDoesNothingLeavesTheKeyUnused() { + val editor = RecordingEditor(allowTrash = false, findOpen = false) + router.attachEditor(editor.commands) + + assertFalse(router.run(AppCommand.DeleteNote)) + assertFalse(router.run(AppCommand.Dismiss)) + } + + @Test + fun withoutAnEditorTheNoteCommandsAreNotHandled() { + assertFalse(router.run(AppCommand.FindInNote)) + assertFalse(router.run(AppCommand.DeleteNote)) + assertFalse(router.run(AppCommand.Dismiss)) + } + + @Test + fun anEditorThatLeftIsForgottenButANewerOneStays() { + val older = RecordingEditor() + val newer = RecordingEditor() + router.attachEditor(older.commands) + router.attachEditor(newer.commands) + router.detachEditor(older.commands) + + router.run(AppCommand.FindInNote) + + assertEquals(emptyList(), older.calls) + assertEquals(listOf("find"), newer.calls) + + router.detachEditor(newer.commands) + assertFalse(router.run(AppCommand.FindInNote)) + } + + @Test + fun theTabCommandsActOnTheTabsOfThePane() = runTest { + val tabs = NoteTabs() + tabs.open(1) + tabs.open(2) + tabs.open(3) + router.attachTabs(tabs) + + assertTrue(router.run(AppCommand.NextTab)) + assertEquals(1L, tabs.active?.noteId) + assertTrue(router.run(AppCommand.PreviousTab)) + assertEquals(3L, tabs.active?.noteId) + assertTrue(router.run(AppCommand.CloseTab)) + assertEquals(listOf(1L, 2L), tabs.tabs.map { it.noteId }) + } + + @Test + fun closingTheShownTabAsksItsEditorToCloseItself() = runTest { + val tabs = NoteTabs() + var closes = 0 + tabs.attach(ActiveEditor(save = { true }, requestClose = { closes++ })) + tabs.open(1) + router.attachTabs(tabs) + + assertTrue(router.run(AppCommand.CloseTab)) + + assertEquals(1, closes) + assertEquals(1, tabs.tabs.size) + } + + @Test + fun withoutTabsOrWithJustOneTheTabKeysAreNotHandled() = runTest { + assertFalse(router.run(AppCommand.CloseTab)) + assertFalse(router.run(AppCommand.NextTab)) + assertFalse(router.run(AppCommand.PreviousTab)) + + val tabs = NoteTabs() + router.attachTabs(tabs) + assertFalse(router.run(AppCommand.CloseTab)) + tabs.open(1) + assertFalse(router.run(AppCommand.NextTab)) + assertFalse(router.run(AppCommand.PreviousTab)) + } + + @Test + fun tabsThatLeftAreForgottenButNewerOnesStay() = runTest { + val older = NoteTabs().also { it.open(1) } + val newer = NoteTabs().also { it.open(2) } + router.attachTabs(older) + router.attachTabs(newer) + router.detachTabs(older) + + assertTrue(router.run(AppCommand.CloseTab)) + assertTrue(newer.tabs.isEmpty()) + assertEquals(1, older.tabs.size) + + router.detachTabs(newer) + assertFalse(router.run(AppCommand.CloseTab)) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/navigation/DrawerNavItemsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/DrawerNavItemsTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/presentation/navigation/DrawerNavItemsTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/DrawerNavItemsTest.kt index ba6d237b..6c5d5f36 100644 --- a/app/src/test/java/com/pasich/encly/presentation/navigation/DrawerNavItemsTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/DrawerNavItemsTest.kt @@ -1,10 +1,11 @@ package com.pasich.encly.presentation.navigation -import com.pasich.encly.R -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.import_title +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class DrawerNavItemsTest { @@ -55,7 +56,7 @@ class DrawerNavItemsTest { fun importIsLabelledLikeTheImportScreen() { val import = drawerNavItems(tagCount = 0, openTasks = 0, donations = false) .single { it.route == NavRoutes.ImportNotesRoute.name } - assertEquals(R.string.import_title, import.titleRes) + assertEquals(Res.string.import_title, import.titleRes) } @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/navigation/RelockReturnTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/RelockReturnTest.kt similarity index 95% rename from app/src/test/java/com/pasich/encly/presentation/navigation/RelockReturnTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/RelockReturnTest.kt index e19caa3e..8d7d1667 100644 --- a/app/src/test/java/com/pasich/encly/presentation/navigation/RelockReturnTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/RelockReturnTest.kt @@ -1,8 +1,8 @@ package com.pasich.encly.presentation.navigation -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class RelockReturnTest { private val editorRoute = diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/pane/NoteTabsBackgroundTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/pane/NoteTabsBackgroundTest.kt new file mode 100644 index 00000000..859afef6 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/pane/NoteTabsBackgroundTest.kt @@ -0,0 +1,106 @@ +package com.pasich.encly.presentation.pane + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Background tabs (open in a new tab) and stepping through the tabs from the keyboard. */ +class NoteTabsBackgroundTest { + private val tabs = NoteTabs() + + private fun editorCountingSaves(counter: IntArray) = ActiveEditor( + save = { + counter[0]++ + true + }, + requestClose = {}, + ) + + @Test + fun aBackgroundTabIsAddedWithoutLeavingTheShownOne() = runTest { + val saves = IntArray(1) + tabs.attach(editorCountingSaves(saves)) + tabs.open(1) + val shown = tabs.activeKey + + assertTrue(tabs.openInBackground(noteId = 2, title = "Trip", epoch = 5L)) + + assertEquals(listOf(1L, 2L), tabs.tabs.map { it.noteId }) + assertEquals("Trip", tabs.tabs.last().title) + assertEquals(5L, tabs.tabs.last().epoch) + assertEquals(shown, tabs.activeKey) + assertEquals(0, saves[0]) + } + + @Test + fun aNoteThatHasATabAlreadyGetsNoSecondOne() = runTest { + tabs.open(1) + tabs.openInBackground(noteId = 2, title = "Trip") + + assertTrue(tabs.openInBackground(noteId = 2, title = "Trip")) + + assertEquals(2, tabs.tabs.size) + } + + @Test + fun aNoteThatIsNotStoredYetHasNoBackgroundTab() = runTest { + tabs.open(1) + + assertTrue(tabs.openInBackground(noteId = -1, title = "")) + + assertEquals(1, tabs.tabs.size) + } + + @Test + fun withNothingShownABackgroundTabIsShown() = runTest { + assertTrue(tabs.openInBackground(noteId = 3, title = "Trip")) + + assertEquals(3L, tabs.active?.noteId) + } + + @Test + fun cyclingShowsTheNextTabAndWrapsAround() = runTest { + tabs.open(1) + tabs.open(2) + tabs.open(3) + + assertTrue(tabs.cycle(forward = true)) + assertEquals(1L, tabs.active?.noteId) + assertTrue(tabs.cycle(forward = true)) + assertEquals(2L, tabs.active?.noteId) + } + + @Test + fun cyclingBackwardWrapsToTheLastTab() = runTest { + tabs.open(1) + tabs.open(2) + tabs.activate(tabs.tabs.first().key) + + assertTrue(tabs.cycle(forward = false)) + + assertEquals(2L, tabs.active?.noteId) + } + + @Test + fun cyclingSavesTheTabItLeaves() = runTest { + val saves = IntArray(1) + tabs.attach(editorCountingSaves(saves)) + tabs.open(1) + tabs.open(2) + val before = saves[0] + + tabs.cycle(forward = true) + + assertEquals(before + 1, saves[0]) + } + + @Test + fun withFewerThanTwoTabsThereIsNothingToCycleTo() = runTest { + assertFalse(tabs.cycle(forward = true)) + tabs.open(1) + assertFalse(tabs.cycle(forward = false)) + assertEquals(1L, tabs.active?.noteId) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/pane/NoteTabsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/pane/NoteTabsTest.kt new file mode 100644 index 00000000..00c529c1 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/pane/NoteTabsTest.kt @@ -0,0 +1,266 @@ +package com.pasich.encly.presentation.pane + +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.presentation.navigation.RelockReturn +import com.pasich.encly.presentation.viewmodel.NoteTabsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NoteTabsTest { + private val tabs = NoteTabs() + + /** An editor for the active tab: [saves] counts its saves, [closes] its close requests. */ + private inner class FakeEditor(var result: Boolean = true) { + var saves = 0 + var closes = 0 + val editor = ActiveEditor( + save = { + saves++ + result + }, + requestClose = { closes++ }, + ) + + fun attach() = tabs.attach(editor) + } + + @Test + fun aNoteIsOpenedInANewActiveTab() = runTest { + assertTrue(tabs.open(noteId = 7)) + + assertEquals(listOf(7L), tabs.tabs.map { it.noteId }) + assertEquals(tabs.tabs.single().key, tabs.activeKey) + } + + @Test + fun aNoteThatIsAlreadyOpenIsShownInItsTabInsteadOfASecondOne() = runTest { + tabs.open(1) + tabs.open(2) + val first = tabs.tabs.first().key + + assertTrue(tabs.open(1)) + + assertEquals(2, tabs.tabs.size) + assertEquals(first, tabs.activeKey) + } + + @Test + fun twoNewNotesAreTwoTabs() = runTest { + FakeEditor().attach() + tabs.open(-1) + // The first one has an id by now (the editor stored it), so it is not dropped as blank. + tabs.report(tabs.activeKey!!, noteId = 9, title = "A") + tabs.open(-1) + + assertEquals(listOf(9L, -1L), tabs.tabs.map { it.noteId }) + } + + @Test + fun leavingATabSavesItFirst() = runTest { + val editor = FakeEditor().also { it.attach() } + tabs.open(1) + tabs.open(2) + + assertEquals(1, editor.saves) + assertEquals(2L, tabs.active?.noteId) + } + + @Test + fun aFailedSaveKeepsTheNoteOnScreen() = runTest { + val editor = FakeEditor(result = false).also { it.attach() } + tabs.open(1) + val shown = tabs.activeKey + + assertFalse(tabs.open(2)) + + assertEquals(1, editor.saves) + assertEquals(shown, tabs.activeKey) + assertEquals(1, tabs.tabs.size) + } + + @Test + fun aBlankNewNoteLeavesNoTabWhenItIsLeft() = runTest { + FakeEditor().attach() + tabs.open(-1) + tabs.open(5) + + assertEquals(listOf(5L), tabs.tabs.map { it.noteId }) + } + + @Test + fun anEditorThatStoredItsNoteKeepsItsTabWhenLeft() = runTest { + tabs.attach( + ActiveEditor( + save = { + tabs.report(tabs.activeKey!!, noteId = 11, title = "Draft") + true + }, + requestClose = {}, + ), + ) + tabs.open(-1) + tabs.open(5) + + assertEquals(listOf(11L, 5L), tabs.tabs.map { it.noteId }) + assertEquals("Draft", tabs.tabs.first().title) + } + + @Test + fun theShownTabIsClosedThroughItsEditorSoItSavesFirst() = runTest { + val editor = FakeEditor().also { it.attach() } + tabs.open(1) + + tabs.close(tabs.activeKey!!) + + assertEquals(1, editor.closes) + // Still there until the editor, once saved, removes it. + assertEquals(1, tabs.tabs.size) + } + + @Test + fun aBackgroundTabJustGoes() = runTest { + val editor = FakeEditor().also { it.attach() } + tabs.open(1) + tabs.open(2) + val background = tabs.tabs.first().key + + tabs.close(background) + + assertEquals(0, editor.closes) + assertEquals(listOf(2L), tabs.tabs.map { it.noteId }) + } + + @Test + fun removingTheShownTabShowsItsNeighbour() = runTest { + tabs.open(1) + tabs.open(2) + tabs.open(3) + tabs.activate(tabs.tabs[1].key) + + tabs.remove(tabs.activeKey!!) + assertEquals(1L, tabs.active?.noteId) + + tabs.remove(tabs.activeKey!!) + assertEquals(3L, tabs.active?.noteId) + + tabs.remove(tabs.activeKey!!) + assertNull(tabs.activeKey) + assertTrue(tabs.tabs.isEmpty()) + } + + @Test + fun aCopyTakesThePlaceOfItsSource() = runTest { + tabs.open(1) + tabs.open(2) + tabs.activate(tabs.tabs.first().key) + + tabs.replace(tabs.activeKey!!, noteId = 3) + + assertEquals(listOf(3L, 2L), tabs.tabs.map { it.noteId }) + assertEquals(3L, tabs.active?.noteId) + } + + @Test + fun anEditorThatLeftDoesNotDetachItsSuccessor() = runTest { + val first = FakeEditor() + val second = FakeEditor() + tabs.open(1) + first.attach() + second.attach() + + tabs.detach(first.editor) + tabs.open(2) + + assertEquals(1, second.saves) + assertEquals(0, first.saves) + } + + @Test + fun clearingDropsEveryTabAndTitle() = runTest { + tabs.open(1) + tabs.report(tabs.activeKey!!, 1, "Secret title") + tabs.open(2) + + tabs.clear() + + assertTrue(tabs.tabs.isEmpty()) + assertNull(tabs.activeKey) + assertNull(tabs.active) + } + + @Test + fun anEditorStillLoadingDoesNotMakeItsStoredNoteLookBlank() = runTest { + FakeEditor().attach() + tabs.open(1) + + // The editor's first state, before the stored note is loaded. + tabs.report(tabs.activeKey!!, noteId = -1, title = "") + tabs.open(2) + + assertEquals(listOf(1L, 2L), tabs.tabs.map { it.noteId }) + } + + @Test + fun reportingAClosedTabChangesNothing() = runTest { + tabs.open(1) + val key = tabs.activeKey!! + tabs.remove(key) + + tabs.report(key, 1, "late") + + assertTrue(tabs.tabs.isEmpty()) + } + + @Test + fun theTabsAreDroppedTheMomentTheSessionLocks() { + val dispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(dispatcher) + try { + val locked = MutableStateFlow(false) + val viewModel = NoteTabsViewModel( + object : VaultLockEvents { + override val locked: StateFlow = locked + }, + ) + runTest(dispatcher) { + viewModel.tabs.open(1) + assertEquals(1, viewModel.tabs.tabs.size) + + locked.value = true + + assertTrue(viewModel.tabs.tabs.isEmpty()) + assertNull(viewModel.tabs.activeKey) + } + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun theEditorRouteOfATabCarriesItsKeyAndArguments() { + assertEquals("PaneEditor/4?tab=2", paneEditorRoute(NoteTab(key = 2, noteId = 4))) + assertEquals( + "PaneEditor/-1?copySource=4&tab=3", + paneEditorRoute(NoteTab(key = 3, noteId = -1, params = "?copySource=4")), + ) + } + + @Test + fun aRelockInThePaneReturnsToTheShownNoteOnlyFromHome() { + assertEquals("EditNoteRoute/42", RelockReturn.paneRouteFor("HomeRoute", openNoteId = 42)) + assertNull(RelockReturn.paneRouteFor("HomeRoute", openNoteId = -1)) + assertNull(RelockReturn.paneRouteFor("HomeRoute", openNoteId = null)) + assertNull(RelockReturn.paneRouteFor("SettingsRoute", openNoteId = 42)) + assertNull(RelockReturn.paneRouteFor(null, openNoteId = 42)) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/screen/PinLockoutTextTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/screen/PinLockoutTextTest.kt new file mode 100644 index 00000000..8ad32b79 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/screen/PinLockoutTextTest.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.presentation.screen + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_lockout_hours +import com.pasich.encly.core.resources.lock_lockout_minutes +import com.pasich.encly.core.resources.lock_lockout_seconds +import com.pasich.encly.presentation.screen.pincode.lockoutUnit +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Lockouts now run up to a day: the countdown switches to minutes and hours, rounded up. */ +class PinLockoutTextTest { + @Test + fun shortLockoutsCountSecondsLongOnesMinutesThenHours() { + assertEquals(Res.plurals.lock_lockout_seconds to 30, lockoutUnit(30)) + assertEquals(Res.plurals.lock_lockout_seconds to 119, lockoutUnit(119)) + assertEquals(Res.plurals.lock_lockout_minutes to 2, lockoutUnit(120)) + assertEquals(Res.plurals.lock_lockout_minutes to 16, lockoutUnit(15 * 60 + 1)) + assertEquals(Res.plurals.lock_lockout_hours to 2, lockoutUnit(2 * 3600)) + assertEquals(Res.plurals.lock_lockout_hours to 24, lockoutUnit(24 * 3600)) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommandsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommandsTest.kt new file mode 100644 index 00000000..7811e018 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommandsTest.kt @@ -0,0 +1,15 @@ +package com.pasich.encly.presentation.screen.editnote + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EditorShortcutCommandsTest { + @Test + fun deleteIsOfferedOnlyForANoteThisWindowEditsAndThatHasContent() { + assertTrue(canMoveToTrashByShortcut(trashOnly = false, blankDraft = false, editedElsewhere = false)) + assertFalse(canMoveToTrashByShortcut(trashOnly = true, blankDraft = false, editedElsewhere = false)) + assertFalse(canMoveToTrashByShortcut(trashOnly = false, blankDraft = true, editedElsewhere = false)) + assertFalse(canMoveToTrashByShortcut(trashOnly = false, blankDraft = false, editedElsewhere = true)) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt similarity index 82% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt index 746d8392..00063358 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt @@ -2,7 +2,7 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.core.common.LoadState import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.domain.model.NoteColor import com.pasich.encly.domain.usecase.note.ObserveNotesUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase @@ -18,12 +18,13 @@ 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.assertTrue -import org.junit.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ArchiveViewModelTest { @@ -31,7 +32,7 @@ class ArchiveViewModelTest { private val repository = TestNotesRepository() private lateinit var viewModel: ArchiveViewModel - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) viewModel = ArchiveViewModel( @@ -44,13 +45,13 @@ class ArchiveViewModelTest { ) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } private fun archived(id: Long, date: Long) = - NoteWithTag(Note(id = id, title = "n$id", date = date, isArchived = true), null) + NoteWithTags(Note(id = id, title = "n$id", date = date, isArchived = true), emptyList()) @Test fun showsTheArchivedNotesNewestFirst() = runTest(dispatcher) { @@ -88,7 +89,12 @@ class ArchiveViewModelTest { viewModel.onEvent(NoteListEvent.NoteToTrash(note)) advanceUntilIdle() - assertEquals(note.copy(isTrash = true), repository.updatedNotes.single()) + val trashed = repository.updatedNotes.single() + assertNotNull(trashed.trashedAt, "the time it went to the trash is remembered") + assertEquals(1L, trashed.id) + assertTrue(trashed.isTrash) + // Two columns of the row by id, never the note a screen read earlier: its archive state is not written. + assertTrue(repository.updateBases.isEmpty()) } @Test diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt new file mode 100644 index 00000000..65fbe735 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt @@ -0,0 +1,327 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModelStore +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.presentation.editor.persistence.SaveStatusNote +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.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The editor around its content: arguments, settings, saves on leaving, lock and status. */ +@OptIn(ExperimentalCoroutinesApi::class) +class EditNoteViewModelLifecycleTest { + private val dispatcher = StandardTestDispatcher() + private val repository = TestNotesRepository().apply { insertResult = INSERTED_ID } + private val settings = TestSettingsRepository() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun aNoteCreatedFromATagsListStartsInThatTag() = runTest(dispatcher) { + val viewModel = viewModel(SavedStateHandle(mapOf("addTag" to TAG_ID))) + advanceUntilIdle() + + assertEquals(setOf(TAG_ID), viewModel.state.value.note.tagIds) + assertTrue(viewModel.isNewNote) + } + + @Test + fun clearingTheScreenSavesWhatWasTypedOnce() = runTest(dispatcher) { + val store = ViewModelStore() + val viewModel = viewModel(SavedStateHandle()) + store.put("editor", viewModel) + advanceUntilIdle() + viewModel.updateTitle("Typed just before leaving") + + store.clear() + advanceUntilIdle() + + assertEquals("Typed just before leaving", repository.insertedNotes.single().title) + } + + @Test + fun aHandledExitStopsTheLastChanceSave() = runTest(dispatcher) { + val store = ViewModelStore() + val viewModel = viewModel(SavedStateHandle()) + store.put("editor", viewModel) + advanceUntilIdle() + viewModel.updateTitle("Discarded") + + viewModel.markExitHandled() + store.clear() + advanceUntilIdle() + + assertEquals(0, repository.insertCalls) + } + + @Test + fun goingToTheBackgroundSavesWithoutWaitingForThePause() = runTest(dispatcher) { + val viewModel = viewModel(SavedStateHandle()) + advanceUntilIdle() + viewModel.updateTitle("Before the re-lock") + + viewModel.saveForBackground() + dispatcher.scheduler.runCurrent() + advanceUntilIdle() + + assertEquals(1, repository.insertCalls) + assertEquals(SaveStatusNote.SAVED, viewModel.status.value) + } + + @Test + fun aFailedSaveIsShownAsNotSaved() = runTest(dispatcher) { + repository.insertResult = 0 + val viewModel = viewModel(SavedStateHandle()) + advanceUntilIdle() + viewModel.updateTitle("Will fail") + + assertFalse(viewModel.saveNote()) + + assertEquals(SaveStatusNote.FAILED, viewModel.status.value) + } + + @Test + fun theFontSizeOfTheMenuIsTheNotesOwnAndFollowed() = runTest(dispatcher) { + val viewModel = viewModel(SavedStateHandle()) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.fontSize.collect {} } + advanceUntilIdle() + + viewModel.updateFontSize(FONT_SIZE) + advanceUntilIdle() + + // The note's own size; the default in Settings is left as it was. + assertEquals(DEFAULT_FONT_SIZE, settings.fontSizeFlow.value) + assertEquals(FONT_SIZE, viewModel.fontSize.value) + } + + @Test + fun theLockToggleLocksAndUnlocksAnOpenedNote() = runTest(dispatcher) { + storeNote() + val viewModel = viewModel(SavedStateHandle(mapOf("idNote" to NOTE_ID))) + advanceUntilIdle() + assertFalse(viewModel.lockEditor.value) + + viewModel.toggleLockEditor() + assertTrue(viewModel.lockEditor.value) + viewModel.toggleLockEditor() + assertFalse(viewModel.lockEditor.value) + } + + @Test + fun aNoteEditedInAnotherWindowIsOnlyShownAndNothingOfItIsSaved() = runTest(dispatcher) { + storeNote() + val viewModel = viewModel(SavedStateHandle(mapOf("idNote" to NOTE_ID))) + advanceUntilIdle() + + viewModel.setEditedElsewhere(true) + + assertTrue(viewModel.editedElsewhere.value) + assertTrue(viewModel.lockEditor.value) + // The lock cannot be lifted, the text settings do not change, and a save writes nothing. + viewModel.toggleLockEditor() + assertTrue(viewModel.lockEditor.value) + viewModel.updateFontSize(FONT_SIZE) + viewModel.updateColor(com.pasich.encly.domain.model.NoteColor.entries.last()) + assertTrue(viewModel.saveNote()) + assertFalse(viewModel.noteMoveToTrash()) + assertEquals(0, repository.updateCalls) + assertEquals(null, viewModel.state.value.note.textSize) + + // When the other window is done the editor may be lifted again by the user. + viewModel.setEditedElsewhere(false) + assertFalse(viewModel.editedElsewhere.value) + viewModel.toggleLockEditor() + assertFalse(viewModel.lockEditor.value) + } + + @Test + fun aClaimGrantedAgainAfterAGiveBackLeavesTheEditorEditableWhicheverRunsFirst() = runTest(dispatcher) { + storeNote() + // The give-back runs on the app's scope, the claim on the screen's: two queues that need not run in order. + val appDispatcher = StandardTestDispatcher() + val viewModel = EditNoteViewModel( + notesRepository = repository, + savedStateHandle = SavedStateHandle(mapOf("idNote" to NOTE_ID)), + updateNoteTrashStatusUseCase = UpdateNoteTrashStatusUseCase(repository), + updateNoteArchiveStatusUseCase = UpdateNoteArchiveStatusUseCase(repository), + settingsRepository = settings, + appScope = CoroutineScope(appDispatcher), + ioDispatcher = UnconfinedTestDispatcher(), + copyTitle = { "$it (Copy)" }, + ) + advanceUntilIdle() + + // The window recreates the editor's screen: the old one gives the note back, the new one claims it again. + var released = false + viewModel.finishEditing { released = true } + viewModel.onClaimResult(granted = true) + advanceUntilIdle() + appDispatcher.scheduler.advanceUntilIdle() + advanceUntilIdle() + + assertTrue(released) + // A late "suspended" from the give-back must not have landed on an editor that holds the claim again. + viewModel.onBlockTextChanged(viewModel.blocks.first(), "typed after") + assertTrue(viewModel.saveNote()) + assertEquals(1, repository.updateCalls) + } + + @Test + fun aRefusedClaimShowsWhatTheOtherWindowSavesAndKeepsNothingOfItsOwn() = runTest(dispatcher) { + storeNote() + val viewModel = viewModel(SavedStateHandle(mapOf("idNote" to NOTE_ID))) + advanceUntilIdle() + + viewModel.onClaimResult(granted = false) + advanceUntilIdle() + assertTrue(viewModel.editedElsewhere.value) + + val saved = Note( + id = NOTE_ID, + title = "Renamed elsewhere", + value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("new text")))), + ) + repository.allNotesWithTags.value = listOf(NoteWithTags(saved, emptyList())) + advanceUntilIdle() + + assertEquals("Renamed elsewhere", viewModel.state.value.note.title) + assertEquals("new text", (viewModel.blocks.first() as Block.TextBlock).text.value) + assertEquals(0, repository.updateCalls) + } + + @Test + fun aNoteInTheTrashCannotBeUnlocked() = runTest(dispatcher) { + storeNote(isTrash = true) + val viewModel = viewModel( + SavedStateHandle(mapOf("idNote" to NOTE_ID, "isReadTrashOnly" to true)), + ) + advanceUntilIdle() + + viewModel.toggleLockEditor() + + assertTrue(viewModel.lockEditor.value) + } + + @Test + fun hasChangesIsWhatDiscardWouldUndo() = runTest(dispatcher) { + storeNote() + val viewModel = viewModel(SavedStateHandle(mapOf("idNote" to NOTE_ID))) + advanceUntilIdle() + assertFalse(viewModel.hasChanges()) + + viewModel.onBlockTextChanged(viewModel.blocks.first(), "changed") + assertTrue(viewModel.hasChanges()) + + assertTrue(viewModel.discardChanges()) + advanceUntilIdle() + assertEquals( + "body", + BlockConverter.jsonToBlocksOrNull(repository.updatedNotes.last().value)!!.let { + (it.single() as Block.TextBlock).text.value + }, + ) + } + + @Test + fun deletingAStoredNoteRemovesIt() = runTest(dispatcher) { + storeNote(isTrash = true) + val viewModel = viewModel(SavedStateHandle(mapOf("idNote" to NOTE_ID, "isReadTrashOnly" to true))) + advanceUntilIdle() + + assertTrue(viewModel.noteDelete()) + + assertEquals(listOf(NOTE_ID), repository.deletedIds) + } + + @Test + fun undoAndRedoFollowTheEdits() = runTest(dispatcher) { + val viewModel = viewModel(SavedStateHandle()) + advanceUntilIdle() + assertFalse(viewModel.canUndo.value) + + viewModel.onBlockTextChanged(viewModel.blocks.first(), "first") + assertTrue(viewModel.canUndo.value) + + viewModel.undo() + assertEquals("", (viewModel.blocks.first() as Block.TextBlock).text.value) + assertTrue(viewModel.canRedo.value) + + viewModel.redo() + assertEquals("first", (viewModel.blocks.first() as Block.TextBlock).text.value) + } + + @Test + fun focusFollowsTheBlockTheUserWorksOn() = runTest(dispatcher) { + val viewModel = viewModel(SavedStateHandle()) + advanceUntilIdle() + val block = viewModel.blocks.first() + + viewModel.onBlockFocused(block) + assertEquals(block.id, viewModel.interactedBlockId.value) + assertEquals(0, viewModel.indexOfBlock(block)) + + viewModel.onBlockFocusLost(block) + viewModel.onBlockInteraction(block) + assertEquals(block.id, viewModel.interactedBlockId.value) + assertEquals(-1, viewModel.indexOfBlock(Block.TextBlock())) + } + + private fun storeNote(isTrash: Boolean = false) { + val note = Note( + id = NOTE_ID, + title = "Stored", + value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("body")))), + isTrash = isTrash, + ) + repository.allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) + } + + private fun viewModel(handle: SavedStateHandle) = EditNoteViewModel( + notesRepository = repository, + savedStateHandle = handle, + updateNoteTrashStatusUseCase = UpdateNoteTrashStatusUseCase(repository), + updateNoteArchiveStatusUseCase = UpdateNoteArchiveStatusUseCase(repository), + settingsRepository = settings, + appScope = CoroutineScope(dispatcher), + ioDispatcher = dispatcher, + copyTitle = { "$it (Copy)" }, + ) + + private companion object { + const val NOTE_ID = 12L + const val INSERTED_ID = 40L + const val TAG_ID = 6L + const val FONT_SIZE = 20 + const val DEFAULT_FONT_SIZE = 16 + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/LockClearsViewModelsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/LockClearsViewModelsTest.kt new file mode 100644 index 00000000..bcf46ad0 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/LockClearsViewModelsTest.kt @@ -0,0 +1,188 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.core.common.LoadState +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.domain.usecase.note.CleanTrashNotesUseCase +import com.pasich.encly.domain.usecase.note.ObserveNotesUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteColorUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteDescriptionUseCase +import com.pasich.encly.domain.usecase.note.UpdateNotePinStatusUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteTagUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase +import com.pasich.encly.domain.usecase.tag.ReorderTagsUseCase +import com.pasich.encly.testutil.TestNotesRepository +import com.pasich.encly.testutil.TestSettingsRepository +import com.pasich.encly.testutil.TestTagsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** What a lock leaves behind in the screens that hold the vault's content, and in the tag selection. */ +@OptIn(ExperimentalCoroutinesApi::class) +class LockClearsViewModelsTest { + private val dispatcher = StandardTestDispatcher() + private val locked = MutableStateFlow(false) + private val lockEvents = object : VaultLockEvents { + override val locked = this@LockClearsViewModelsTest.locked + } + private val repository = TestNotesRepository() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun noteList(holder: SelectedTagHolder) = NoteListViewModel( + observeNotesUseCase = ObserveNotesUseCase(repository, dispatcher), + updateNoteTrashStatusUseCase = UpdateNoteTrashStatusUseCase(repository), + updateNoteTagUseCase = UpdateNoteTagUseCase(repository), + updateNotePinStatusUseCase = UpdateNotePinStatusUseCase(repository), + updateNoteArchiveStatusUseCase = UpdateNoteArchiveStatusUseCase(repository), + updateNoteColorUseCase = UpdateNoteColorUseCase(repository), + selectedTagHolder = holder, + settingsRepository = TestSettingsRepository(), + updateNoteDescriptionUseCase = UpdateNoteDescriptionUseCase(repository), + lockEvents = lockEvents, + ) + + // --- the tag selection ------------------------------------------------------------------------- + + @Test + fun theSelectedTagDoesNotSurviveALock() = runTest(dispatcher) { + val holder = SelectedTagHolder(lockEvents) + holder.selectTag(Tag(id = REAL_TAG, nameTag = "Lawyer")) + + locked.value = true + locked.value = false + + assertEquals(Tag(), holder.selectedTagFlow.first()) + } + + @Test + fun aDecoyVaultOpenedAfterTheRealOneIsNotFilteredByItsTag() = runTest(dispatcher) { + val holder = SelectedTagHolder(lockEvents) + val real = noteList(holder) + advanceUntilIdle() + real.onEvent(NoteListEvent.SelectTag(Tag(id = REAL_TAG, nameTag = "Lawyer"))) + advanceUntilIdle() + assertEquals(REAL_TAG, real.state.value.selectedTag) + + // Lock, then the duress PIN opens the decoy vault with notes of its own. + locked.value = true + advanceUntilIdle() + locked.value = false + repository.allNotesWithTags.value = listOf(NoteWithTags(Note(id = 1, title = "Shopping"), emptyList())) + val decoy = noteList(holder) + advanceUntilIdle() + + assertEquals(0L, decoy.state.value.selectedTag) + assertEquals("", decoy.state.value.selectedTagName) + assertEquals(listOf("Shopping"), decoy.state.value.notes.map { it.note.title }) + assertEquals(0L, decoy.state.value.selectedTag) // so a new note there starts untagged + } + + @Test + fun aSelectionThatIsNoTagOfTheOpenVaultIsDropped() = runTest(dispatcher) { + val holder = SelectedTagHolder() + val tags = TagListViewModel( + TestTagsRepository(listOf(Tag(id = 1, nameTag = "Work"))), + ReorderTagsUseCase(TestTagsRepository()), + holder, + repository, + ) + holder.selectTag(Tag(id = REAL_TAG, nameTag = "Lawyer")) + advanceUntilIdle() + + assertEquals(0L, holder.selectedTagFlow.first().id) + assertEquals(0L, tags.state.value.selectedTagId) + } + + // --- the screens that hold content -------------------------------------------------------------- + + @Test + fun theTrashIsEmptiedByALock() = runTest(dispatcher) { + repository.trashNotes.value = listOf(Note(id = 1, title = "secret", isTrash = true)) + val viewModel = TrashViewModel( + repository, + UpdateNoteTrashStatusUseCase(repository), + CleanTrashNotesUseCase(repository), + lockEvents, + ) + advanceUntilIdle() + assertEquals(1, viewModel.state.value.notes.size) + + locked.value = true + advanceUntilIdle() + + assertEquals(LoadState.Loading, viewModel.state.value.notesLoad) + assertTrue(viewModel.state.value.notes.isEmpty()) + // What the closed vault might still emit does not come back. + repository.trashNotes.value = listOf(Note(id = 2, title = "later", isTrash = true)) + advanceUntilIdle() + assertTrue(viewModel.state.value.notes.isEmpty()) + } + + @Test + fun theSearchQueryAndResultsAreClearedByALock() = runTest(dispatcher) { + repository.allNotesWithTags.value = listOf( + NoteWithTags( + Note(id = 1, title = "salary", value = """[{"blockType":"TEXT","text":"salary 100"}]"""), + emptyList(), + ), + ) + val viewModel = NoteSearchViewModel(ObserveNotesUseCase(repository, dispatcher), dispatcher, lockEvents) + viewModel.onQueryChange("salary") + advanceUntilIdle() + assertEquals(1, viewModel.results.value.notes.size) + + locked.value = true + advanceUntilIdle() + + assertEquals("", viewModel.query.value) + assertEquals(NoteSearchViewModel.SearchResults(), viewModel.results.value) + } + + @Test + fun theTagNamesAreClearedByALock() = runTest(dispatcher) { + val tagsRepository = TestTagsRepository(listOf(Tag(id = 1, nameTag = "Lawyer", isVisible = false))) + val viewModel = TagListViewModel( + tagsRepository, + ReorderTagsUseCase(tagsRepository), + SelectedTagHolder(), + repository, + lockEvents, + ) + advanceUntilIdle() + assertEquals(1, viewModel.state.value.listTags.size) + + locked.value = true + advanceUntilIdle() + + assertTrue(viewModel.state.value.listTags.isEmpty()) + assertEquals(LoadState.Loading, viewModel.state.value.tagsLoad) + } + + private companion object { + const val REAL_TAG = 5L + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt similarity index 77% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt index ae189062..fcc36d82 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt @@ -2,15 +2,15 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.core.common.LoadState import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.domain.model.NoteListItem -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class NoteListEmptyStateTest { - private val note = NoteListItem(NoteWithTag(Note(id = 1, title = "a"), null), preview = "", searchText = "") + private val note = NoteListItem(NoteWithTags(Note(id = 1, title = "a"), emptyList()), preview = "", searchText = "") @Test fun anEmptyVaultSaysNoNotes() { diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteSearchViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModelTest.kt similarity index 80% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteSearchViewModelTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModelTest.kt index ff72db08..6ac75ce4 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteSearchViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModelTest.kt @@ -1,7 +1,7 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.data.model.Tag import com.pasich.encly.domain.usecase.note.ObserveNotesUseCase import com.pasich.encly.testutil.TestNotesRepository @@ -14,23 +14,23 @@ 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.assertTrue -import org.junit.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class NoteSearchViewModelTest { private val dispatcher = StandardTestDispatcher() private val repository = TestNotesRepository() - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } @@ -55,7 +55,13 @@ class NoteSearchViewModelTest { @Test fun notesUnderAHiddenTagAreNotSearched() = runTest(dispatcher) { val hidden = Tag(id = 3, nameTag = "Secret", isVisible = false) - repository.allNotesWithTags.value = listOf(note(1, text = "plan", tag = hidden), note(2, text = "plan")) + val visible = Tag(id = 4, nameTag = "Work") + repository.allNotesWithTags.value = listOf( + note(1, text = "plan", tag = hidden), + note(2, text = "plan"), + // A visible tag does not bring a note with a hidden one back into search. + note(3, text = "plan", tag = visible).let { it.copy(tags = it.tags + hidden) }, + ) val viewModel = createViewModel() backgroundScope.launch { viewModel.results.collect {} } @@ -68,7 +74,7 @@ class NoteSearchViewModelTest { @Test fun typingIsDebouncedAndMarkersDoNotMatchEveryChecklist() = runTest(dispatcher) { repository.allNotesWithTags.value = listOf( - NoteWithTag(Note(id = 1, title = "list", value = CHECKED_MILK), null), + NoteWithTags(Note(id = 1, title = "list", value = CHECKED_MILK), emptyList()), ) val viewModel = createViewModel() backgroundScope.launch { viewModel.results.collect {} } @@ -88,15 +94,15 @@ class NoteSearchViewModelTest { private fun createViewModel() = NoteSearchViewModel(ObserveNotesUseCase(repository, dispatcher), dispatcher) - private fun note(id: Long, text: String, tag: Tag? = null) = NoteWithTag( - note = Note( + private fun note(id: Long, text: String, tag: Tag? = null) = NoteWithTags( + Note( id = id, title = "note $id", value = """[{"blockType":"TEXT","text":"$text"}]""", date = id, - tagId = tag?.id, + tagIds = setOfNotNull(tag?.id), ), - tag = tag, + listOfNotNull(tag), ) private companion object { diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt similarity index 92% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt index 58a845ba..782d4a5f 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/StatisticViewModelTest.kt @@ -12,10 +12,10 @@ 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.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals /** The drawer's counts: a read that fails once the vault closed keeps the last count. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -24,10 +24,10 @@ class StatisticViewModelTest { private val tags = TestTagsRepository(listOf(Tag(id = 1, nameTag = "a"), Tag(id = 2, nameTag = "b"))) private val tasks = TestTasksRepository(listOf(Task(id = 1, title = "Open"))) - @Before + @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) - @After + @AfterTest fun tearDown() = Dispatchers.resetMain() @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt index baed148e..328c4c05 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt @@ -1,11 +1,11 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Subtask -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue class SubtaskDraftsTest { private val drafts = listOf("a", "b", "c", "d").mapIndexed { i, title -> diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TagCrudTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TagCrudTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TagCrudTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TagCrudTest.kt index e6bf4568..9c9de583 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TagCrudTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TagCrudTest.kt @@ -18,12 +18,12 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent 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.assertTrue -import org.junit.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** Tags: create, rename, hide, delete, select and reorder, including the failure paths. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -35,13 +35,13 @@ class TagCrudTest { private val holder = SelectedTagHolder() private lateinit var viewModel: TagListViewModel - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) viewModel = TagListViewModel(repository, ReorderTagsUseCase(repository), holder, TestNotesRepository()) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TagListViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModelTest.kt similarity index 88% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TagListViewModelTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModelTest.kt index 99606eff..eca9c0fd 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TagListViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModelTest.kt @@ -1,7 +1,7 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.data.model.Tag import com.pasich.encly.domain.repository.TagsRepository import com.pasich.encly.domain.usecase.tag.ReorderTagsUseCase @@ -18,24 +18,24 @@ 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.assertTrue -import org.junit.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class TagListViewModelTest { private val dispatcher = StandardTestDispatcher() private val repository = FakeTagsRepository(listOf(Tag(id = 1, nameTag = "Work"), Tag(id = 2, nameTag = "Home"))) - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } @@ -46,10 +46,10 @@ class TagListViewModelTest { @Test fun theNoteCountOfATagIncludesItsArchivedNotes() = runTest(dispatcher) { - notes.allNotesWithTags.value = listOf(NoteWithTag(Note(id = 1, tagId = 1), null)) + notes.allNotesWithTags.value = listOf(NoteWithTags(Note(id = 1), listOf(Tag(id = 1)))) notes.archivedNotesWithTags.value = listOf( - NoteWithTag(Note(id = 2, tagId = 1, isArchived = true), null), - NoteWithTag(Note(id = 3, tagId = 2, isArchived = true), null), + NoteWithTags(Note(id = 2, isArchived = true), listOf(Tag(id = 1))), + NoteWithTags(Note(id = 3, isArchived = true), listOf(Tag(id = 2))), ) val viewModel = viewModel() val counts = backgroundScope.launch { viewModel.noteCounts.collect { } } @@ -166,16 +166,18 @@ class TagListViewModelTest { @Test fun noteCountsAreByTag() = runTest(dispatcher) { notes.allNotesWithTags.value = listOf( - NoteWithTag(Note(id = 1, tagId = 1), null), - NoteWithTag(Note(id = 2, tagId = 1), null), - NoteWithTag(Note(id = 3, tagId = 2), null), - NoteWithTag(Note(id = 4), null), + NoteWithTags(Note(id = 1), listOf(Tag(id = 1))), + NoteWithTags(Note(id = 2), listOf(Tag(id = 1))), + NoteWithTags(Note(id = 3), listOf(Tag(id = 2))), + NoteWithTags(Note(id = 4), emptyList()), + // A note with two tags counts in each of them. + NoteWithTags(Note(id = 5), listOf(Tag(id = 1), Tag(id = 2))), ) val viewModel = viewModel() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.noteCounts.collect {} } advanceUntilIdle() - assertEquals(mapOf(1L to 2, 2L to 1), viewModel.noteCounts.value) + assertEquals(mapOf(1L to 3, 2L to 2), viewModel.noteCounts.value) } private class FakeTagsRepository(initial: List) : TagsRepository { diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TaskFilterEngineTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TaskFilterEngineTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TaskFilterEngineTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TaskFilterEngineTest.kt index d2901ab8..6eee289f 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TaskFilterEngineTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TaskFilterEngineTest.kt @@ -1,9 +1,9 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Task -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class TaskFilterEngineTest { private val high = Task(id = 1, title = "high", priority = 2) diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt index 802867c4..a939eb5c 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt @@ -16,13 +16,13 @@ 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 +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue /** Unsaved task-editor input must survive the background re-lock (which pops every screen). */ @OptIn(ExperimentalCoroutinesApi::class) @@ -31,7 +31,7 @@ class TasksViewModelDraftTest { private lateinit var repository: InMemoryTasksRepository private lateinit var viewModel: TasksViewModel - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) repository = InMemoryTasksRepository() @@ -41,7 +41,7 @@ class TasksViewModelDraftTest { ) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt index 19c2dcc5..0628a8b2 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt @@ -16,13 +16,13 @@ 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 +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue /** * Sub-tasks edited on the Tasks list itself: open/closed trees, the inline "Add sub-task" field, @@ -37,7 +37,7 @@ class TasksViewModelInlineSubtasksTest { private val shop get() = repository.subtasks.value.filter { it.taskId == 1L }.sortedBy { it.position } - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) repository = TestTasksRepository( @@ -52,7 +52,7 @@ class TasksViewModelInlineSubtasksTest { viewModel = newViewModel() } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt index 9727b866..41f5837a 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt @@ -16,14 +16,14 @@ 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.assertSame -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue /** Sub-tasks on the Tasks screen: editing, progress, the completion offer, delete and undo. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -32,7 +32,7 @@ class TasksViewModelSubtasksTest { private lateinit var repository: TestTasksRepository private lateinit var viewModel: TasksViewModel - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) repository = TestTasksRepository( @@ -45,7 +45,7 @@ class TasksViewModelSubtasksTest { viewModel = TasksViewModel(repository, UpdateTaskStatusUseCase(repository)) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt similarity index 89% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt index 2aefaacf..b32baba1 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt @@ -13,14 +13,14 @@ 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.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue /** The Tasks screen: loading, create/edit/complete/delete/undo, clearing and failures. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -29,7 +29,7 @@ class TasksViewModelTest { private lateinit var repository: TestTasksRepository private lateinit var viewModel: TasksViewModel - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) repository = TestTasksRepository( @@ -42,7 +42,7 @@ class TasksViewModelTest { viewModel = TasksViewModel(repository, UpdateTaskStatusUseCase(repository)) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } @@ -64,6 +64,27 @@ class TasksViewModelTest { assertEquals(TaskFilterEngine.ALL_FILTER_ID, state.selectedActiveFilter?.id) } + @Test + fun aTaskBeingCheckedOrUncheckedIsNeverInBothListsAtOnce() = runTest(dispatcher) { + advanceUntilIdle() + // Every state the screen sees: the list keys tasks by id, so a task in both the open and + // the done list at once crashes it. + val states = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect(states::add) } + + viewModel.toggleTaskCompletion(taskId = 1, isCompleted = true) + advanceUntilIdle() + viewModel.toggleTaskCompletion(taskId = 3, isCompleted = false) + advanceUntilIdle() + + assertTrue(states.size > 1) + states.forEach { state -> + val open = state.activeTasks.map { it.id }.toSet() + assertTrue(state.completedTasks.none { it.id in open }, "a task is in both lists: $state") + } + assertEquals(listOf(2L, 3L), viewModel.uiState.value.activeTasks.map { it.id }) + } + @Test fun aReadFailingAfterTheVaultClosedKeepsTheListInsteadOfCrashing() = runTest(dispatcher) { advanceUntilIdle() diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TrashViewModelActionsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModelActionsTest.kt similarity index 95% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TrashViewModelActionsTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModelActionsTest.kt index ba25d980..a3c56a9a 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TrashViewModelActionsTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModelActionsTest.kt @@ -12,12 +12,12 @@ 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.assertTrue -import org.junit.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** Trash: selection, restore, delete forever, empty trash. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -32,7 +32,7 @@ class TrashViewModelActionsTest { } private lateinit var viewModel: TrashViewModel - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) viewModel = TrashViewModel( @@ -42,7 +42,7 @@ class TrashViewModelActionsTest { ) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TrashViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModelTest.kt similarity index 90% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/TrashViewModelTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModelTest.kt index 354d3318..c0ea9f03 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TrashViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModelTest.kt @@ -11,12 +11,12 @@ 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.assertTrue -import org.junit.Before -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class TrashViewModelTest { @@ -25,12 +25,12 @@ class TrashViewModelTest { trashNotes.value = listOf(Note(id = 1, title = "a", isTrash = true), Note(id = 2, title = "b", isTrash = true)) } - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/WidgetTasksTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/WidgetTasksTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/WidgetTasksTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/WidgetTasksTest.kt index dac98780..b3093551 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/WidgetTasksTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/WidgetTasksTest.kt @@ -1,8 +1,8 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Task -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals /** The two tasks the notes screen previews (design spec §4.3). */ class WidgetTasksTest { diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/WindowSelectionsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/WindowSelectionsTest.kt new file mode 100644 index 00000000..8a278113 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/WindowSelectionsTest.kt @@ -0,0 +1,52 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.CreationExtras +import com.pasich.encly.core.di.AppViewModelFactory +import com.pasich.encly.core.security.NeverLocked +import kotlin.test.Test +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class WindowSelectionsTest { + private class HoldsSelection(val holder: SelectedTagHolder) : ViewModel() + + private val tags = SelectedTags(NeverLocked) + private val factory = AppViewModelFactory( + viewModelProviders = mapOf(HoldsSelection::class to { HoldsSelection(tags.forViewModel()) }), + assistedFactoryProviders = emptyMap(), + manualAssistedFactoryProviders = emptyMap(), + ) + + @Test + fun aHostWithOneWindowSharesOneSelection() { + val first = factory.create(HoldsSelection::class, CreationExtras.Empty) + val second = factory.create(HoldsSelection::class, CreationExtras.Empty) + + assertSame(first.holder, second.holder) + } + + @Test + fun theViewModelsOfAWindowGetThatWindowsSelectionAndNoOtherWindowSeesIt() { + val windowA = SelectedTagHolder() + val windowB = SelectedTagHolder() + + val inA = factory.forWindow(windowA).create(HoldsSelection::class, CreationExtras.Empty) + val alsoInA = factory.forWindow(windowA).create(HoldsSelection::class, CreationExtras.Empty) + val inB = factory.forWindow(windowB).create(HoldsSelection::class, CreationExtras.Empty) + + assertSame(windowA, inA.holder) + assertSame(windowA, alsoInA.holder) + assertSame(windowB, inB.holder) + assertNotSame(inA.holder, factory.create(HoldsSelection::class, CreationExtras.Empty).holder) + } + + @Test + fun theWindowOnlyHoldsWhileItsViewModelIsBeingCreated() { + val window = SelectedTagHolder() + + WindowSelections.creatingFor(window) { assertSame(window, tags.forViewModel()) } + + assertNotSame(window, tags.forViewModel()) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/FakeAppLocales.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/FakeAppLocales.kt new file mode 100644 index 00000000..c86d96de --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/FakeAppLocales.kt @@ -0,0 +1,17 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.platform.AppLocaleController + +/** The in-app language, in memory. */ +internal class FakeAppLocales(var language: AppLanguage = AppLanguage.SYSTEM) : AppLocaleController { + var applied = 0 + private set + + override fun current(): AppLanguage = language + + override fun apply(language: AppLanguage) { + applied++ + this.language = language + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/FakeDocuments.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/FakeDocuments.kt new file mode 100644 index 00000000..ce17f5bc --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/FakeDocuments.kt @@ -0,0 +1,70 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.data.importer.DocumentTree +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.PlatformDocuments +import kotlinx.io.Buffer +import kotlinx.io.RawSink +import kotlinx.io.RawSource +import kotlinx.io.readByteArray + +/** + * The picked documents in memory. A document nobody wrote to is empty (as the one the save dialog just + * created). The failures are set per test. + */ +internal class FakeDocuments : PlatformDocuments { + val files = mutableMapOf() + val names = mutableMapOf() + val deleted = mutableListOf() + var opensForWrite = 0 + private set + + /** Thrown by [openRead] and [displayName]. */ + var readFailure: Throwable? = null + + /** Thrown by [openWrite]. */ + var writeFailure: Throwable? = null + + /** [openRead] has no stream to give. */ + var noStream = false + + /** A provider that silently loses the last byte of what is written. */ + var losesLastByte = false + + /** The folders [openTree] hands out. */ + val trees = mutableMapOf() + + override fun openRead(doc: DocumentRef): RawSource? { + readFailure?.let { throw it } + if (noStream) return null + return Buffer().apply { write(files[doc] ?: ByteArray(0)) } + } + + override fun openWrite(doc: DocumentRef): RawSink? { + writeFailure?.let { throw it } + opensForWrite++ + val written = Buffer() + return object : RawSink { + override fun write(source: Buffer, byteCount: Long) = written.write(source, byteCount) + + override fun flush() = Unit + + override fun close() { + val bytes = written.readByteArray() + files[doc] = if (losesLastByte) bytes.copyOf(bytes.size - 1) else bytes + } + } + } + + override fun displayName(doc: DocumentRef): String? { + readFailure?.let { throw it } + return names[doc] + } + + override fun delete(doc: DocumentRef) { + deleted += doc + files.remove(doc) + } + + override fun openTree(root: DocumentRef, maxEntries: Int): DocumentTree = trees.getValue(root) +} diff --git a/app/src/test/java/com/pasich/encly/testutil/InMemoryVaultDataStore.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultDataStore.kt similarity index 81% rename from app/src/test/java/com/pasich/encly/testutil/InMemoryVaultDataStore.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultDataStore.kt index 44fbd798..ef91d6fb 100644 --- a/app/src/test/java/com/pasich/encly/testutil/InMemoryVaultDataStore.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultDataStore.kt @@ -3,6 +3,7 @@ package com.pasich.encly.testutil import com.pasich.encly.data.backup.VaultDataStore import com.pasich.encly.data.backup.VaultSnapshot import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task @@ -16,6 +17,10 @@ internal class InMemoryVaultDataStore : VaultDataStore { val tags = mutableListOf() val tasks = mutableListOf() val subtasks = mutableListOf() + val noteTags = mutableListOf() + + /** The ids of note [noteId]'s tags. */ + fun tagIdsOf(noteId: Long): Set = noteTags.filter { it.noteId == noteId }.mapTo(HashSet()) { it.tagId } /** Makes the n-th insert (1-based, counted across tables) fail, like a constraint error. */ var failOnInsert: Int? = null @@ -27,11 +32,13 @@ internal class InMemoryVaultDataStore : VaultDataStore { tags = tags.map { it.copy() }, tasks = tasks.toList(), subtasks = subtasks.toList(), + noteTags = noteTags.toList(), ) override suspend fun inTransaction(block: suspend () -> R): R { val saved = Triple(notes.toList(), tags.toList(), tasks.toList()) val savedSubtasks = subtasks.toList() + val savedNoteTags = noteTags.toList() return try { block() } catch (e: RuntimeException) { @@ -39,11 +46,13 @@ internal class InMemoryVaultDataStore : VaultDataStore { restore(tags, saved.second) restore(tasks, saved.third) restore(subtasks, savedSubtasks) + restore(noteTags, savedNoteTags) throw e } } override suspend fun deleteAll() { + noteTags.clear() notes.clear() tags.clear() tasks.clear() @@ -62,6 +71,13 @@ internal class InMemoryVaultDataStore : VaultDataStore { return id } + override suspend fun insertNoteTag(link: NoteTag) { + val linked = notes.any { it.id == link.noteId } && tags.any { it.id == link.tagId } + check(linked) { "FOREIGN KEY constraint failed" } + check(link !in noteTags) { "UNIQUE constraint failed: note_tags" } + noteTags += link + } + override suspend fun insertTask(task: Task): Long { val id = nextInsert(task.uid, tasks.map { it.uid }) tasks += task.copy(id = id, uid = task.uid.ifBlank { "task-$id" }) diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt new file mode 100644 index 00000000..b266c421 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt @@ -0,0 +1,145 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.domain.repository.NotesRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +internal class TestNotesRepository : NotesRepository { + val allNotesWithTags = MutableStateFlow>(emptyList()) + + /** The id an insert returns; zero or less makes the insert fail. */ + var insertResult: Long = DEFAULT_INSERT_ID + var updateResult: Boolean = true + var insertCalls: Int = 0 + private set + var updateCalls: Int = 0 + private set + + private val notesByTag = mutableMapOf>>() + + fun tagFlow(tagId: Long): MutableStateFlow> = notesByTag.getOrPut(tagId) { + MutableStateFlow(emptyList()) + } + + override fun getAllNotesWithTag(): Flow> = allNotesWithTags + + override suspend fun getNoteById(noteId: Long): Note? = allNotesWithTags.value.firstOrNull { + it.note.id == noteId + }?.note + + /** Every note passed to [insertNote], in call order. */ + val insertedNotes = mutableListOf() + + /** When set, [insertNote] waits for it: a test can act while an insert is in flight. */ + var insertGate: CompletableDeferred? = null + + override suspend fun insertNote(note: Note): Result { + insertCalls += 1 + insertedNotes += note + insertGate?.await() + return if (insertResult > 0L) Result.success(insertResult) else Result.failure(IllegalStateException()) + } + + /** Every note passed to [updateEdited], and every trash write as the note it was made on, in call order. */ + val updatedNotes = mutableListOf() + + /** The `base` of every [updateEdited], in call order. */ + val updateBases = mutableListOf() + + override suspend fun updateEdited(note: Note, base: Note?): Result { + updateCalls += 1 + updatedNotes += note + updateBases += base + return if (updateResult) Result.success(Unit) else Result.failure(IllegalStateException()) + } + + override suspend fun setTrashed(noteId: Long, trashed: Boolean, at: Long?): Result { + updateCalls += 1 + val known = + (allNotesWithTags.value.map { it.note } + archivedNotesWithTags.value.map { it.note } + trashNotes.value) + .firstOrNull { it.id == noteId } ?: Note(id = noteId) + updatedNotes += known.copy(isTrash = trashed, trashedAt = at) + return if (updateResult) Result.success(Unit) else Result.failure(IllegalStateException()) + } + + val archivedNotesWithTags = MutableStateFlow>(emptyList()) + + override fun getArchivedNotesWithTag(): Flow> = archivedNotesWithTags + + override fun getNotesOfTagWithArchived(tagId: Long): Flow> = + combine(tagFlow(tagId), archivedNotesWithTags) { live, archived -> + live + archived.filter { item -> item.tags.any { it.id == tagId } } + } + + override fun observeNote(noteId: Long): Flow = combine(allNotesWithTags, archivedNotesWithTags) { + a, + b, + -> + (a + b).firstOrNull { it.note.id == noteId } + } + + /** Every single-column write, as "column:id=value", in call order. */ + val columnWrites = mutableListOf() + + var columnWriteResult: Boolean = true + + private fun column(write: String): Result { + columnWrites += write + return if (columnWriteResult) Result.success(Unit) else Result.failure(IllegalStateException()) + } + + override suspend fun setColor(noteId: Long, color: String) = column("color:$noteId=$color") + + override suspend fun setTagged(noteId: Long, tagId: Long, tagged: Boolean) = + column(if (tagged) "tag:$noteId=$tagId" else "untag:$noteId=$tagId") + + override suspend fun clearTags(noteId: Long) = column("tag:$noteId=null") + + /** What [tagNoteCounts] gives: every note of the list and the archive, counted per tag. */ + override fun tagNoteCounts(): Flow> = combine(allNotesWithTags, archivedNotesWithTags) { a, b -> + (a + b).flatMap { item -> item.tags.map { it.id } }.groupingBy { it }.eachCount() + } + + override suspend fun setDescription(noteId: Long, description: String) = column("description:$noteId=$description") + + override suspend fun setPinned(noteId: Long, pinned: Boolean) = column("pinned:$noteId=$pinned") + + override suspend fun setArchived(noteId: Long, archived: Boolean) = column("archived:$noteId=$archived") + + val trashNotes = MutableStateFlow>(emptyList()) + + override fun getTrashNotes(): Flow> = trashNotes + + /** Every id passed to [deleteNoteById], in call order. */ + val deletedIds = mutableListOf() + + override suspend fun deleteNoteById(id: Long): Result { + deletedIds += id + return Result.success(Unit) + } + + override suspend fun stampUntimedTrash(at: Long): Result { + trashNotes.value = trashNotes.value.map { if (it.trashedAt == null) it.copy(trashedAt = at) else it } + return Result.success(Unit) + } + + /** Every cutoff passed to [deleteTrashedBefore], in call order. */ + val purgeCutoffs = mutableListOf() + + override suspend fun deleteTrashedBefore(cutoff: Long): Result { + purgeCutoffs += cutoff + val (gone, kept) = trashNotes.value.partition { it.isTrash && (it.trashedAt ?: Long.MAX_VALUE) < cutoff } + deletedIds += gone.map { it.id } + trashNotes.value = kept + return Result.success(gone.size) + } + + private companion object { + const val DEFAULT_INSERT_ID = 42L + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestScreen.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestScreen.kt new file mode 100644 index 00000000..92469ab2 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestScreen.kt @@ -0,0 +1,46 @@ +package com.pasich.encly.testutil + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.pasich.encly.core.security.BiometricHost +import com.pasich.encly.platform.ScreenBiometricHost + +/** + * A screen a biometric prompt is shown on, for ViewModels that tie a system prompt to the screen showing it: a + * lifecycle (resumed unless [initial] says otherwise) and a host of its own, so calls made for one screen are + * told from another's. [stop] and [resume] move it to and from the background; [destroy] ends it, as a rotation + * does. + */ +internal class TestScreen(initial: Lifecycle.State = Lifecycle.State.RESUMED) { + /** What the security core is given. */ + val biometric: BiometricHost = TestBiometricHost(nextId++) + + private val owner = object : LifecycleOwner { + override val lifecycle: LifecycleRegistry = LifecycleRegistry.createUnsafe(this).apply { + currentState = initial + } + } + + /** What the ViewModels are given. */ + val host = ScreenBiometricHost(biometric, owner) + + /** The app leaves the foreground: the activity is stopped. */ + fun stop() { + owner.lifecycle.currentState = Lifecycle.State.CREATED + } + + fun resume() { + owner.lifecycle.currentState = Lifecycle.State.RESUMED + } + + fun destroy() { + owner.lifecycle.currentState = Lifecycle.State.DESTROYED + } + + private data class TestBiometricHost(val id: Int) : BiometricHost + + private companion object { + var nextId = 0 + } +} diff --git a/app/src/test/java/com/pasich/encly/testutil/TestSettingsRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestSettingsRepository.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/testutil/TestSettingsRepository.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestSettingsRepository.kt index c2bdffb2..e87f9d33 100644 --- a/app/src/test/java/com/pasich/encly/testutil/TestSettingsRepository.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestSettingsRepository.kt @@ -13,7 +13,8 @@ import com.pasich.encly.domain.repository.SettingsRepository import kotlinx.coroutines.flow.MutableStateFlow /** Settings in memory: every setter writes the flow the app observes. */ -internal class TestSettingsRepository(override var latestThemeSettings: ThemeSettings? = null) : SettingsRepository { +internal open class TestSettingsRepository(override var latestThemeSettings: ThemeSettings? = null) : + SettingsRepository { override val themeSettingsFlow = MutableStateFlow(latestThemeSettings ?: ThemeSettings()) override val isGridNoteList = MutableStateFlow(false) override val getSortNotes = MutableStateFlow(NoteSortOption.UPDATED_DESC) diff --git a/app/src/test/java/com/pasich/encly/testutil/TestTagsRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestTagsRepository.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/testutil/TestTagsRepository.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestTagsRepository.kt diff --git a/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestTasksRepository.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestTasksRepository.kt index 7479c187..bcc38bb8 100644 --- a/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestTasksRepository.kt @@ -37,7 +37,7 @@ internal class TestTasksRepository(initial: List = emptyList(), initialSub tasks.map { list -> read { list.filter { it.isCompleted } } } private fun read(block: () -> T): T = if (failReads) error("Database session expired") else block() - override fun getAllTasks(): Flow> = tasks + override fun getAllTasks(): Flow> = tasks.map { list -> read { list } } override fun getActiveTasksCount(): Flow = getAllActiveTasks().map { it.size } override fun getCompletedTasksCount(): Flow = getAllCompletedTasks().map { it.size } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt b/shared/src/commonTest/kotlin/com/pasich/encly/ui/screens/Fakes.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/ui/screens/Fakes.kt diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/ui/theme/AdaptiveTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/ui/theme/AdaptiveTest.kt new file mode 100644 index 00000000..30de6ef4 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/ui/theme/AdaptiveTest.kt @@ -0,0 +1,67 @@ +package com.pasich.encly.ui.theme + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class AdaptiveTest { + @Test + fun aPhoneIsCompactInBothOrientations() { + assertEquals(WidthClass.Compact, WidthClass.of(width = 411.dp, height = 891.dp)) + // Held sideways it is wide but low: still a phone. + assertEquals(WidthClass.Compact, WidthClass.of(width = 891.dp, height = 411.dp)) + } + + @Test + fun theClassGrowsWithTheWidthAtTheMaterialBreakpoints() { + assertEquals(WidthClass.Compact, WidthClass.of(width = 599.dp, height = 800.dp)) + assertEquals(WidthClass.Medium, WidthClass.of(width = 600.dp, height = 800.dp)) + assertEquals(WidthClass.Medium, WidthClass.of(width = 839.dp, height = 800.dp)) + assertEquals(WidthClass.Expanded, WidthClass.of(width = 840.dp, height = 800.dp)) + assertEquals(WidthClass.Expanded, WidthClass.of(width = 1920.dp, height = 1080.dp)) + } + + @Test + fun aNarrowDesktopWindowFallsBackToThePhoneLayout() { + assertEquals(WidthClass.Compact, WidthClass.of(width = 480.dp, height = 900.dp)) + } + + @Test + fun onlyWideClassesAreWide() { + assertTrue(!WidthClass.Compact.isWide) + assertTrue(WidthClass.Medium.isWide) + assertTrue(WidthClass.Expanded.isWide) + } + + @Test + fun theDesktopDensityNeedsAnExpandedWindowAndAPointer() { + assertEquals(DensityProfile.Desktop, DensityProfile.of(WidthClass.Expanded, pointerFirst = true)) + assertEquals(DensityProfile.Touch, DensityProfile.of(WidthClass.Expanded, pointerFirst = false)) + assertEquals(DensityProfile.Touch, DensityProfile.of(WidthClass.Medium, pointerFirst = true)) + assertEquals(DensityProfile.Touch, DensityProfile.of(WidthClass.Compact, pointerFirst = true)) + } + + @Test + fun theTouchSpacingIsExactlyTheOneThePhoneAlwaysHad() { + assertEquals(EnclySpacing(), enclySpacingFor(DensityProfile.Touch)) + assertEquals(56.dp, enclySpacingFor(DensityProfile.Touch).rowHeight) + assertEquals(64.dp, enclySpacingFor(DensityProfile.Touch).topBarHeight) + } + + @Test + fun theDesktopSpacingIsTighterThanTheTouchOne() { + val touch = enclySpacingFor(DensityProfile.Touch) + val desktop = enclySpacingFor(DensityProfile.Desktop) + + assertNotEquals(touch, desktop) + assertTrue(desktop.rowHeight < touch.rowHeight) + assertTrue(desktop.topBarHeight < touch.topBarHeight) + assertTrue(desktop.buttonHeight < touch.buttonHeight) + assertTrue(desktop.listGutter < touch.listGutter) + // The scale itself (4, 8, 12, 16, 24, 32, 48) does not change, and neither does the icon size. + assertEquals(touch.m, desktop.m) + assertEquals(touch.icon, desktop.icon) + } +} diff --git a/app/src/test/java/com/pasich/encly/utils/NotesTextFormatterBlocksTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/utils/NotesTextFormatterBlocksTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/utils/NotesTextFormatterBlocksTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/utils/NotesTextFormatterBlocksTest.kt index 8ed975e9..2853e8a5 100644 --- a/app/src/test/java/com/pasich/encly/utils/NotesTextFormatterBlocksTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/utils/NotesTextFormatterBlocksTest.kt @@ -1,12 +1,12 @@ package com.pasich.encly.utils -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 com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals /** The plain-text and search renderings of every block type: what is kept, what is dropped, in what order. */ class NotesTextFormatterBlocksTest { diff --git a/app/src/test/java/com/pasich/encly/utils/NotesTextFormatterTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/utils/NotesTextFormatterTest.kt similarity index 88% rename from app/src/test/java/com/pasich/encly/utils/NotesTextFormatterTest.kt rename to shared/src/commonTest/kotlin/com/pasich/encly/utils/NotesTextFormatterTest.kt index 7e32fdb0..acc52f8c 100644 --- a/app/src/test/java/com/pasich/encly/utils/NotesTextFormatterTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/utils/NotesTextFormatterTest.kt @@ -1,8 +1,8 @@ package com.pasich.encly.utils -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull /** List previews and search run this for every note; one bad note must not crash them. */ class NotesTextFormatterTest { diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/utils/RelativeDayTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/utils/RelativeDayTest.kt new file mode 100644 index 00000000..1d4fda56 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/utils/RelativeDayTest.kt @@ -0,0 +1,74 @@ +package com.pasich.encly.utils + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class RelativeDayTest { + + private val zone = TimeZone.of("Europe/Kyiv") + + private fun at(year: Int, month: Int, day: Int, hour: Int, minute: Int = 30): Instant = + LocalDateTime(year, month, day, hour, minute, 0).toInstant(zone) + + private val now = at(2026, 3, 10, 12, 0) + + private fun march(day: Int, hour: Int) = at(2026, 3, day, hour) + + @Test + fun sameDayIsToday() { + assertEquals(RelativeDay.TODAY, relativeDay(march(10, 0), now, zone)) + assertEquals(RelativeDay.TODAY, relativeDay(march(10, 23), now, zone)) + } + + @Test + fun previousDayIsYesterday() { + assertEquals(RelativeDay.YESTERDAY, relativeDay(march(9, 8), now, zone)) + } + + /** Regression: the old formatter compared "now" with itself and called every date "today". */ + @Test + fun olderDatesAreNotToday() { + assertEquals(RelativeDay.OTHER, relativeDay(march(1, 8), now, zone)) + assertEquals(RelativeDay.OTHER, relativeDay(march(11, 8), now, zone)) + } + + @Test + fun yesterdayAcrossTheYearBoundary() { + val newYear = at(2026, 1, 1, 9, 0) + val newYearsEve = at(2025, 12, 31, 22, 0) + assertEquals(RelativeDay.YESTERDAY, relativeDay(newYearsEve, newYear, zone)) + } + + @Test + fun sameYearOnlyWithinTheCalendarYear() { + val newYearsEve = at(2025, 12, 31, 22, 0) + assertTrue(isSameYear(march(1, 8), now, zone)) + assertFalse(isSameYear(newYearsEve, now, zone)) + } + + @Test + fun theDayIsTheOneOfTheZoneNotOfUtc() { + val lateEvening = at(2026, 3, 10, 23, 30) // 21:30 UTC, still the 10th in Kyiv + val earlyMorning = at(2026, 3, 11, 0, 30) // 22:30 UTC on the 10th, already the 11th in Kyiv + assertEquals(RelativeDay.TODAY, relativeDay(lateEvening, now, zone)) + assertEquals(RelativeDay.OTHER, relativeDay(earlyMorning, now, zone)) + } + + @Test + fun yesterdayAcrossADaylightSavingChange() { + val afterTheChange = at(2026, 3, 30, 9, 0) + assertEquals(RelativeDay.YESTERDAY, relativeDay(at(2026, 3, 29, 1, 0), afterTheChange, zone)) + } + + @Test + fun theDateStampIsTheZonesDateInIsoForm() { + assertEquals("2026-03-10", todayStamp(now, zone)) + assertEquals("2026-03-09", todayStamp(at(2026, 3, 10, 0, 30), TimeZone.UTC)) + } +} diff --git a/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.desktop.kt b/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.desktop.kt new file mode 100644 index 00000000..c6cea083 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.desktop.kt @@ -0,0 +1,4 @@ +package com.pasich.encly.presentation.designsystem + +internal actual fun platformUsesCommandKey(): Boolean = + System.getProperty("os.name").orEmpty().startsWith("Mac", ignoreCase = true) diff --git a/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.desktop.kt b/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.desktop.kt new file mode 100644 index 00000000..317bcfe5 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.desktop.kt @@ -0,0 +1,131 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.HorizontalScrollbar +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.v2.ScrollbarAdapter +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.roundToInt +import androidx.compose.foundation.ScrollbarAdapter as ClassicScrollbarAdapter + +private const val THUMB_ALPHA = 0.6f +private const val THUMB_HOVER_ALPHA = 0.9f +private const val HOVER_FADE_MS = 250 + +/** Thumb colours from the theme: `onSurfaceVariant` at rest and brighter under the pointer (3:1 or better). */ +@Composable +private fun enclyScrollbarStyle(): ScrollbarStyle { + val thumb = MaterialTheme.colorScheme.onSurfaceVariant + return ScrollbarStyle( + minimalHeight = 24.dp, + thickness = 8.dp, + shape = RoundedCornerShape(4.dp), + hoverDurationMillis = HOVER_FADE_MS, + unhoverColor = thumb.copy(alpha = THUMB_ALPHA), + hoverColor = thumb.copy(alpha = THUMB_HOVER_ALPHA), + ) +} + +@Suppress("DEPRECATION") // The ready-made adapters of lists and columns are still the classic ones. +@Composable +private fun BoxScope.Scrollbar(adapter: ClassicScrollbarAdapter, visible: Boolean, modifier: Modifier = Modifier) { + if (!visible) return + VerticalScrollbar( + adapter = adapter, + modifier = modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + style = enclyScrollbarStyle(), + ) +} + +@Composable +private fun BoxScope.Scrollbar(adapter: ScrollbarAdapter, visible: Boolean, modifier: Modifier = Modifier) { + if (!visible) return + VerticalScrollbar( + adapter = adapter, + modifier = modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + style = enclyScrollbarStyle(), + ) +} + +@Composable +actual fun BoxScope.EnclyHorizontalScrollbar(state: ScrollState, modifier: Modifier) { + if (state.maxValue <= 0) return + HorizontalScrollbar( + adapter = rememberScrollbarAdapter(state), + modifier = modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + style = enclyScrollbarStyle(), + ) +} + +@Composable +actual fun BoxScope.EnclyScrollbar(state: LazyListState, modifier: Modifier) { + Scrollbar(rememberScrollbarAdapter(state), state.canScrollForward || state.canScrollBackward, modifier) +} + +@Composable +actual fun BoxScope.EnclyScrollbar(state: ScrollState, modifier: Modifier) { + Scrollbar(rememberScrollbarAdapter(state), state.maxValue > 0, modifier) +} + +@Composable +actual fun BoxScope.EnclyScrollbar(state: LazyStaggeredGridState, modifier: Modifier) { + val adapter = remember(state) { StaggeredGridScrollbarAdapter(state) } + Scrollbar(adapter, state.canScrollForward || state.canScrollBackward, modifier) +} + +/** + * A staggered grid does not know its length, so this estimates it: the rows are taken to be as tall as + * the cells in view on average, and the grid as many rows long as its items fill at the lanes it has. + */ +internal class StaggeredGridScrollbarAdapter(private val state: LazyStaggeredGridState) : ScrollbarAdapter { + private val info get() = state.layoutInfo + private val visible get() = info.visibleItemsInfo + + private val lanes get() = max(1, (visible.maxOfOrNull { it.lane } ?: 0) + 1) + + /** The height of one row of cells with the gap below it, from the cells in view. */ + private val rowHeight: Double + get() { + if (visible.isEmpty()) return 1.0 + val average = visible.sumOf { it.size.height }.toDouble() / visible.size + return max(1.0, average + info.mainAxisItemSpacing) + } + + override val scrollOffset: Double + get() = state.firstVisibleItemIndex.toDouble() / lanes * rowHeight + state.firstVisibleItemScrollOffset + + override val contentSize: Double + get() = max( + viewportSize, + info.totalItemsCount.toDouble() / lanes * rowHeight + info.beforeContentPadding + info.afterContentPadding, + ) + + override val viewportSize: Double + get() = info.viewportSize.height.toDouble() + + override suspend fun scrollTo(scrollOffset: Double) { + val row = (scrollOffset / rowHeight).toInt() + val within = (scrollOffset - row * rowHeight).roundToInt() + state.scrollToItem(index = row * lanes, scrollOffset = within) + } +} diff --git a/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.desktop.kt b/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.desktop.kt new file mode 100644 index 00000000..d3ade940 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.desktop.kt @@ -0,0 +1,6 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun rememberAnimationsOff(): Boolean = false diff --git a/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.desktop.kt b/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.desktop.kt new file mode 100644 index 00000000..da9dfa0c --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.desktop.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Dp + +@Composable +internal actual fun rememberImeVisible(): Boolean = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + +@Composable +internal actual fun windowWidth(): Dp = with(LocalDensity.current) { + LocalWindowInfo.current.containerSize.width.toDp() +} diff --git a/shared/src/desktopMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.desktop.kt b/shared/src/desktopMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.desktop.kt new file mode 100644 index 00000000..64920961 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.desktop.kt @@ -0,0 +1,4 @@ +package com.pasich.encly.ui.theme + +/** The desktop is driven by a mouse and keyboard. */ +internal actual fun isPointerFirstDevice(): Boolean = true diff --git a/shared/src/desktopMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.desktop.kt b/shared/src/desktopMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.desktop.kt new file mode 100644 index 00000000..5900c6f4 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.desktop.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +@Composable +internal actual fun wallpaperColorScheme(dark: Boolean): ColorScheme? = null diff --git a/shared/src/desktopMain/kotlin/com/pasich/encly/utils/LocalizedDate.desktop.kt b/shared/src/desktopMain/kotlin/com/pasich/encly/utils/LocalizedDate.desktop.kt new file mode 100644 index 00000000..5025b5c5 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/pasich/encly/utils/LocalizedDate.desktop.kt @@ -0,0 +1,19 @@ +package com.pasich.encly.utils + +import androidx.compose.runtime.Composable +import kotlinx.datetime.TimeZone +import java.util.Locale +import kotlin.time.Instant + +@Composable +internal actual fun currentLanguageTag(): String = Locale.getDefault().toLanguageTag() + +internal actual fun formatLocalized( + instant: Instant, + style: LocalizedDate, + languageTag: String, + zone: TimeZone, +): String = // The JDK has no skeleton lookup: day and month in the order of the pattern "d MMM[ y]". + formatLocalizedJvm(instant, style, languageTag, zone) { _, skeleton -> + if (skeleton == "dMMM") "d MMM" else "d MMM y" + } diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt new file mode 100644 index 00000000..738e984a --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt @@ -0,0 +1,210 @@ +package com.pasich.encly + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * `:shared` is the app without Android: its common sources (the domain, the data logic, the UI) may import + * nothing of Android or the JVM and name none of the Android-bound Compose locals, and the module may not + * depend on `:app`. What the UI needs from a platform is an interface in `com.pasich.encly.platform` that the + * host implements. The platform source sets are an inventory: each file in [JVM_FILES], [ANDROID_FILES] and + * [DESKTOP_FILES] is named with the reason it is not common code, and nothing else may be there. + */ +class SharedBoundaryTest { + // Tests run with the module directory as the working directory. + private val moduleRoot = listOf(File("."), File("shared")).first { File(it, "build.gradle.kts").isFile } + private val sourceRoot = File(moduleRoot, "src/commonMain/kotlin") + + private val sources = sourceRoot.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + + private fun File.relative() = relativeTo(sourceRoot).invariantSeparatorsPath + + /** Source lines without comments (line, block and KDoc) and string literals' contents. */ + private fun File.codeLines(): List> { + // A block comment becomes as many line breaks as it had, so line numbers stay right. + val text = BLOCK_COMMENT.replace(readText()) { "\n".repeat(it.value.count { c -> c == '\n' }) } + return text.lines().mapIndexed { index, raw -> + (index + 1) to raw.replace(STRING, "\"\"").substringBefore("//") + } + } + + private fun forbidden(import: String): Boolean = FORBIDDEN_PREFIXES.any(import::startsWith) + + private fun platformFiles(sourceSet: String): Set { + val dir = File(moduleRoot, "src/$sourceSet/kotlin") + return dir.walkTopDown().filter { it.isFile && it.extension == "kt" } + .map { it.relativeTo(dir).invariantSeparatorsPath }.toSet() + } + + @Test + fun theSourcesWereFound() { + assertTrue(sources.size > 200, "no sources under $sourceRoot") + } + + @Test + fun noImportReachesOntoAndroidOrTheJvm() { + val offenders = sources.flatMap { file -> + file.codeLines().mapNotNull { (number, line) -> + val import = IMPORT.matchEntire(line.trim())?.groupValues?.get(1) ?: return@mapNotNull null + "${file.relative()}:$number import $import".takeIf { forbidden(import) } + } + } + assertEquals(emptyList(), offenders, "put the platform call behind an interface the host implements") + } + + @Test + fun noCodeNamesThePlatformWithoutAnImport() { + val offenders = sources.flatMap { file -> + file.codeLines().filter { (_, line) -> !line.trim().startsWith("import ") } + .filter { (_, line) -> PLATFORM_USE.containsMatchIn(line) } + .map { (number, line) -> "${file.relative()}:$number ${line.trim()}" } + } + assertEquals(emptyList(), offenders, "the common sources may not use the JVM or Android directly") + } + + @Test + fun theWindowAndContextLocalsAreNotUsed() { + val offenders = sources.flatMap { file -> + file.codeLines().filter { (_, line) -> ANDROID_LOCALS.containsMatchIn(line) } + .map { (number, line) -> "${file.relative()}:$number ${line.trim()}" } + } + assertEquals(emptyList(), offenders, "use the host services of com.pasich.encly.platform instead") + } + + @Test + fun theJvmAndPlatformSourceSetsHoldOnlyTheListedFiles() { + assertEquals(JVM_FILES.keys, platformFiles("jvmAndAndroidMain")) + assertEquals(ANDROID_FILES.keys, platformFiles("androidMain")) + assertEquals(DESKTOP_FILES.keys, platformFiles("desktopMain")) + (JVM_FILES + ANDROID_FILES + DESKTOP_FILES).forEach { (name, reason) -> + assertTrue(reason.length > 10, "$name: write why it is platform code") + } + } + + @Test + fun theModuleDoesNotDependOnTheApp() { + val build = File(moduleRoot, "build.gradle.kts").readText() + val projects = Regex("""project\(\s*"(:[\w:]+)"""").findAll(build).map { it.groupValues[1] }.toList() + assertTrue(projects.isNotEmpty()) + assertFalse(":app" in projects, "the app depends on :shared, not the other way round") + } + + @Test + fun theCheckSeesWhatItShouldReject() { + assertTrue(forbidden("android.content.Context")) + assertTrue(forbidden("java.io.File")) + assertTrue(forbidden("javax.crypto.Cipher")) + assertTrue(forbidden("androidx.fragment.app.FragmentActivity")) + assertTrue(forbidden("androidx.activity.compose.BackHandler")) + assertTrue(forbidden("androidx.core.net.toUri")) + assertTrue(forbidden("androidx.compose.ui.platform.LocalContext")) + assertTrue(forbidden("com.pasich.encly.R")) + assertTrue(forbidden("com.pasich.encly.BuildConfig")) + assertTrue(forbidden("com.pasich.encly.core.di.ApplicationContext")) + assertFalse(forbidden("androidx.compose.ui.backhandler.BackHandler")) + assertFalse(forbidden("androidx.lifecycle.ViewModel")) + assertFalse(forbidden("androidx.navigation.NavHostController")) + assertFalse(forbidden("kotlinx.io.files.Path")) + assertTrue(PLATFORM_USE.containsMatchIn("val x = java.util.UUID.randomUUID()")) + assertTrue(PLATFORM_USE.containsMatchIn("val t = System.currentTimeMillis()")) + assertTrue(PLATFORM_USE.containsMatchIn("} catch (_: SecurityException) {")) + assertFalse(PLATFORM_USE.containsMatchIn("val t = Clock.System.now()")) + assertTrue(PLATFORM_USE.containsMatchIn("e.javaClass.simpleName")) + assertTrue(PLATFORM_USE.containsMatchIn("} catch (_: OutOfMemoryError) {")) + assertTrue(ANDROID_LOCALS.containsMatchIn("val context = LocalContext.current")) + assertTrue(ANDROID_LOCALS.containsMatchIn("val activity = LocalActivity.current")) + assertFalse(ANDROID_LOCALS.containsMatchIn("val window = LocalAppWindow.current")) + } + + private companion object { + val IMPORT = Regex("""import ([\w.]+)(?: as \w+)?""") + val STRING = Regex(""""(?:[^"\\]|\\.)*"""") + val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""") + + val FORBIDDEN_PREFIXES = listOf( + "android.", + "java.", + "javax.", + "dagger.", + "androidx.activity.", + "androidx.core.", + "androidx.appcompat.", + "androidx.fragment.", + "androidx.biometric.", + "androidx.compose.ui.platform.LocalContext", + "androidx.compose.ui.platform.LocalView", + "androidx.compose.ui.platform.LocalConfiguration", + "androidx.compose.ui.platform.LocalResources", + "androidx.compose.ui.res.", + "com.pasich.encly.R", + "com.pasich.encly.BuildConfig", + "com.pasich.encly.core.di.ApplicationContext", + ) + + // Direct uses of the platform that need no import: FQNs, System, javaClass, the JDK classes that + // need no import (Character) or whose import the check would also catch (Normalizer). + val PLATFORM_USE = Regex( + listOf( + """\b(?:java|javax|android)\.\w""", + """(?() + + private fun ComposeUiTest.show( + enabled: Boolean = true, + claimPress: Boolean = true, + child: Modifier = Modifier, + clickable: Boolean = true, + ) = setContent { + MaterialTheme { + EnclyContextMenu( + entries = { + built++ + listOf( + MenuEntry("Open", { ran += "open" }, EnclyIcons.Edit), + MenuEntry("Remove", { ran += "remove" }, destructive = true), + MenuEntry("Locked", { ran += "locked" }, enabled = false), + ) + }, + enabled = enabled, + claimPress = claimPress, + ) { + Box( + Modifier + .testTag("card") + .size(CARD) + .then(child) + .then(if (clickable) Modifier.clickable { clicks++ } else Modifier.focusable()), + ) + } + } + } + + @Test + fun aRightClickOpensTheMenuWithoutClickingTheCard() = runComposeUiTest { + show() + + onNodeWithTag("card").performMouseInput { rightClick() } + waitForIdle() + + onNodeWithText("Open").assertExists() + onNodeWithText("Remove").assertExists() + onNodeWithText("Locked").assertIsNotEnabled() + assertEquals(0, clicks) + assertEquals(1, built) + } + + @Test + fun pickingAnEntryRunsItAndClosesTheMenu() = runComposeUiTest { + show() + onNodeWithTag("card").performMouseInput { rightClick() } + waitForIdle() + + onNodeWithText("Remove").performClick() + waitForIdle() + + assertEquals(listOf("remove"), ran) + onNodeWithText("Remove").assertDoesNotExist() + } + + @Test + fun aDisabledEntryDoesNothing() = runComposeUiTest { + show() + onNodeWithTag("card").performMouseInput { rightClick() } + waitForIdle() + + onNodeWithText("Locked").performClick() + waitForIdle() + + assertTrue(ran.isEmpty()) + } + + @Test + fun aLeftClickJustClicksTheCard() = runComposeUiTest { + show() + + onNodeWithTag("card").performMouseInput { click() } + waitForIdle() + + assertEquals(1, clicks) + onNodeWithText("Open").assertDoesNotExist() + assertEquals(0, built) + } + + @Test + fun escapeClosesTheMenu() = runComposeUiTest { + show() + onNodeWithTag("card").performMouseInput { rightClick() } + waitForIdle() + + onNodeWithText("Open").requestFocus() + onNodeWithText("Open").performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + onNodeWithText("Open").assertDoesNotExist() + assertTrue(ran.isEmpty()) + } + + @Test + fun aDisabledMenuLeavesTheContentAlone() = runComposeUiTest { + show(enabled = false) + + onNodeWithTag("card").performMouseInput { rightClick() } + waitForIdle() + + onNodeWithText("Open").assertDoesNotExist() + assertEquals(0, built) + } + + @Test + fun theMenuKeyAndShiftF10OpenItForTheFocusedContent() = runComposeUiTest { + show() + onNodeWithTag("card").requestFocus() + onNodeWithTag("card").performKeyInput { pressKey(Key.Menu) } + waitForIdle() + onNodeWithText("Open").assertExists() + onNodeWithText("Open").performClick() + waitForIdle() + assertEquals(listOf("open"), ran) + + onNodeWithTag("card").requestFocus() + onNodeWithTag("card").performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.F10) } } + waitForIdle() + onNodeWithText("Remove").assertExists() + } + + @Test + fun otherKeysOpenNothing() = runComposeUiTest { + show() + onNodeWithTag("card").requestFocus() + + onNodeWithTag("card").performKeyInput { pressKey(Key.F10) } + onNodeWithTag("card").performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.F9) } } + waitForIdle() + + onNodeWithText("Open").assertDoesNotExist() + } + + @Test + fun withoutClaimingAChildThatUsedTheRightClickKeepsIt() = runComposeUiTest { + show(claimPress = false, child = Modifier.pointerInput(Unit) { consumeEverything() }) + + onNodeWithTag("card").performMouseInput { rightClick() } + waitForIdle() + + onNodeWithText("Open").assertDoesNotExist() + } + + @Test + fun withoutClaimingARightClickNobodyUsedStillOpensIt() = runComposeUiTest { + show(claimPress = false, clickable = false) + + onNodeWithTag("card").performMouseInput { rightClick() } + waitForIdle() + + onNodeWithText("Open").assertExists() + } + + private suspend fun androidx.compose.ui.input.pointer.PointerInputScope.consumeEverything() { + awaitPointerEventScope { + while (true) awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + } + } + + private companion object { + val CARD = 100.dp + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcutsMapTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcutsMapTest.kt new file mode 100644 index 00000000..5048ad1d --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcutsMapTest.kt @@ -0,0 +1,116 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +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.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The map is one table: its keys, its labels and the window's copy of it all say the same. */ +@OptIn(ExperimentalTestApi::class) +class EnclyShortcutsMapTest { + @Test + fun theLetterCommandsHaveTheirKeysAndNoOtherCommandDoes() { + val shortcuts = EnclyShortcuts(commandKeyIsMeta = false) + + assertEquals(Key.N, shortcuts.keyOf(AppCommand.NewNote)) + assertEquals(Key.F, shortcuts.keyOf(AppCommand.FindInNote)) + assertEquals(Key.L, shortcuts.keyOf(AppCommand.LockApp)) + assertEquals(Key.W, shortcuts.keyOf(AppCommand.CloseTab)) + listOf(AppCommand.NextTab, AppCommand.PreviousTab, AppCommand.DeleteNote, AppCommand.Dismiss).forEach { + assertNull(shortcuts.keyOf(it), "$it") + } + } + + @Test + fun theLabelOfALetterCommandNamesItsKey() { + val letters = mapOf( + AppCommand.NewNote to Key.N, + AppCommand.FindInNote to Key.F, + AppCommand.LockApp to Key.L, + AppCommand.CloseTab to Key.W, + ) + for (meta in listOf(true, false)) { + val shortcuts = EnclyShortcuts(commandKeyIsMeta = meta) + letters.forEach { (command, key) -> + assertEquals(key, shortcuts.keyOf(command)) + assertTrue(shortcuts.label(command).endsWith(command.letter()), "${shortcuts.label(command)}") + } + } + } + + private fun AppCommand.letter() = when (this) { + AppCommand.NewNote -> "N" + AppCommand.FindInNote -> "F" + AppCommand.LockApp -> "L" + else -> "W" + } + + @Test + fun theMapKnowsWhichCommandKeyItWasMadeFor() { + assertTrue(EnclyShortcuts(commandKeyIsMeta = true).usesMetaKey) + assertFalse(EnclyShortcuts(commandKeyIsMeta = false).usesMetaKey) + } + + @Test + fun theHostGivesItsMapToEverythingUnderIt() = runComposeUiTest { + setContent { + EnclyShortcutHost(onCommand = { false }, shortcuts = EnclyShortcuts(commandKeyIsMeta = true)) { + Text(LocalEnclyShortcuts.current.label(AppCommand.NewNote)) + } + } + + onNodeWithText("⌘N").assertExists() + } + + private fun keyDown(event: KeyEvent): Key? = event.key.takeIf { event.type == KeyEventType.KeyDown } + + @Test + fun aCommandTheHostDeclinesReachesWhoeverIsAboveIt() = runComposeUiTest { + val seenAbove = mutableListOf() + val asked = mutableListOf() + setContent { + Box(Modifier.onKeyEvent { event: KeyEvent -> keyDown(event)?.let(seenAbove::add).let { false } }) { + EnclyShortcutHost( + onCommand = { command -> + asked += command + command != AppCommand.NewNote + }, + shortcuts = EnclyShortcuts(commandKeyIsMeta = false), + ) { + Box(Modifier.size(10.dp)) + } + } + } + + onRoot().performKeyInput { + keyDown(Key.CtrlLeft) + pressKey(Key.N) + pressKey(Key.L) + keyUp(Key.CtrlLeft) + } + waitForIdle() + + assertEquals(listOf(AppCommand.NewNote, AppCommand.LockApp), asked) + // The declined chord went on up; the handled one was used up. + assertTrue(Key.N in seenAbove) + assertFalse(Key.L in seenAbove) + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcutsTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcutsTest.kt new file mode 100644 index 00000000..4b159220 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/EnclyShortcutsTest.kt @@ -0,0 +1,211 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.KeyInjectionScope +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The app's shortcut map: the platform's command key, the chords it owns and the ones it leaves to the fields. */ +@OptIn(ExperimentalTestApi::class) +class EnclyShortcutsTest { + private val commands = mutableListOf() + private var handled = true + + private fun ComposeUiTest.show(meta: Boolean) = setContent { + EnclyShortcutHost( + onCommand = { command -> + commands += command + handled + }, + shortcuts = EnclyShortcuts(commandKeyIsMeta = meta), + ) { + Box(Modifier.size(10.dp)) + } + } + + private fun ComposeUiTest.press(modifiers: List = emptyList(), key: Key) { + onRoot().performKeyInput { chord(modifiers, key) } + waitForIdle() + } + + private fun KeyInjectionScope.chord(modifiers: List, key: Key) { + if (modifiers.isEmpty()) { + pressKey(key) + } else { + keyDown(modifiers.first()) + chord(modifiers.drop(1), key) + keyUp(modifiers.first()) + } + } + + @Test + fun controlIsTheCommandKeyOutsideMacOs() = runComposeUiTest { + show(meta = false) + + press(listOf(Key.CtrlLeft), key = Key.N) + press(listOf(Key.CtrlLeft), key = Key.F) + press(listOf(Key.CtrlLeft), key = Key.L) + press(listOf(Key.CtrlLeft), key = Key.W) + press(listOf(Key.CtrlLeft), key = Key.PageDown) + press(listOf(Key.CtrlLeft), key = Key.PageUp) + press(listOf(Key.CtrlLeft, Key.ShiftLeft), key = Key.Backspace) + + assertEquals( + listOf( + AppCommand.NewNote, + AppCommand.FindInNote, + AppCommand.LockApp, + AppCommand.CloseTab, + AppCommand.NextTab, + AppCommand.PreviousTab, + AppCommand.DeleteNote, + ), + commands, + ) + } + + @Test + fun commandIsTheCommandKeyOnAMac() = runComposeUiTest { + show(meta = true) + + press(listOf(Key.MetaLeft), key = Key.N) + press(listOf(Key.MetaLeft), key = Key.W) + press(listOf(Key.MetaLeft, Key.ShiftLeft), key = Key.RightBracket) + press(listOf(Key.MetaLeft, Key.ShiftLeft), key = Key.LeftBracket) + press(listOf(Key.MetaLeft, Key.ShiftLeft), key = Key.Backspace) + + assertEquals( + listOf( + AppCommand.NewNote, + AppCommand.CloseTab, + AppCommand.NextTab, + AppCommand.PreviousTab, + AppCommand.DeleteNote, + ), + commands, + ) + } + + @Test + fun controlTabSwitchesTabsOnEveryPlatform() = runComposeUiTest { + show(meta = true) + + press(listOf(Key.CtrlLeft), key = Key.Tab) + press(listOf(Key.CtrlLeft, Key.ShiftLeft), key = Key.Tab) + + assertEquals(listOf(AppCommand.NextTab, AppCommand.PreviousTab), commands) + } + + @Test + fun theWrongCommandKeyIsNotOurs() = runComposeUiTest { + show(meta = true) + press(listOf(Key.CtrlLeft), key = Key.N) + press(listOf(Key.CtrlLeft, Key.MetaLeft), key = Key.N) + + assertTrue(commands.isEmpty()) + } + + @Test + fun theOtherCommandKeyIsNotOursOffTheMac() = runComposeUiTest { + show(meta = false) + + press(listOf(Key.MetaLeft), key = Key.N) + press(listOf(Key.CtrlLeft, Key.MetaLeft), key = Key.N) + + assertTrue(commands.isEmpty()) + } + + @Test + fun chordsWithAltOrAnExtraShiftAreLeftToTheField() = runComposeUiTest { + show(meta = false) + + press(listOf(Key.CtrlLeft, Key.AltLeft), key = Key.N) + press(listOf(Key.CtrlLeft, Key.ShiftLeft), key = Key.N) + press(listOf(Key.CtrlLeft, Key.ShiftLeft), key = Key.PageDown) + press(listOf(Key.CtrlLeft), key = Key.Backspace) + press(listOf(Key.CtrlLeft), key = Key.X) + press(listOf(Key.CtrlLeft, Key.AltLeft), key = Key.Tab) + press(key = Key.N) + + assertTrue(commands.isEmpty()) + } + + @Test + fun escapeIsADismissOnlyWhenNothingElseUsedIt() = runComposeUiTest { + show(meta = false) + press(key = Key.Escape) + assertEquals(listOf(AppCommand.Dismiss), commands) + } + + @Test + fun escapeWithAModifierIsNotADismiss() = runComposeUiTest { + show(meta = false) + + press(listOf(Key.ShiftLeft), key = Key.Escape) + + assertTrue(commands.isEmpty()) + } + + @Test + fun aCommandNobodyHandledIsNotConsumed() = runComposeUiTest { + handled = false + show(meta = false) + + press(listOf(Key.CtrlLeft), key = Key.N) + + assertEquals(listOf(AppCommand.NewNote), commands) + } + + @Test + fun keyUpIsNotACommand() = runComposeUiTest { + show(meta = false) + + onRoot().performKeyInput { + keyDown(Key.CtrlLeft) + keyDown(Key.N) + keyUp(Key.N) + keyUp(Key.CtrlLeft) + } + waitForIdle() + + assertEquals(listOf(AppCommand.NewNote), commands) + } + + @Test + fun theLabelsAreWhatTheUserReads() { + val mac = EnclyShortcuts(commandKeyIsMeta = true) + val other = EnclyShortcuts(commandKeyIsMeta = false) + + assertEquals("⌘N", mac.label(AppCommand.NewNote)) + assertEquals("Ctrl+N", other.label(AppCommand.NewNote)) + assertEquals("⌘F", mac.label(AppCommand.FindInNote)) + assertEquals("⌘L", mac.label(AppCommand.LockApp)) + assertEquals("⌘W", mac.label(AppCommand.CloseTab)) + assertEquals("⌃Tab", mac.label(AppCommand.NextTab)) + assertEquals("Ctrl+Tab", other.label(AppCommand.NextTab)) + assertEquals("⌃⇧Tab", mac.label(AppCommand.PreviousTab)) + assertEquals("Ctrl+Shift+Tab", other.label(AppCommand.PreviousTab)) + assertEquals("⌘⇧⌫", mac.label(AppCommand.DeleteNote)) + assertEquals("Ctrl+Shift+Backspace", other.label(AppCommand.DeleteNote)) + assertEquals("Esc", mac.label(AppCommand.Dismiss)) + } + + @Test + fun theDefaultCommandKeyIsTheHostsOwn() { + val isMac = System.getProperty("os.name").startsWith("Mac", ignoreCase = true) + + assertEquals(isMac, platformUsesCommandKey()) + assertEquals(if (isMac) "⌘N" else "Ctrl+N", EnclyShortcuts().label(AppCommand.NewNote)) + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/MouseInput.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/MouseInput.kt new file mode 100644 index 00000000..9cc0f962 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/MouseInput.kt @@ -0,0 +1,13 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.MouseButton +import androidx.compose.ui.test.MouseInjectionScope + +/** A press and release of the middle mouse button on the node. */ +@OptIn(ExperimentalTestApi::class) +fun MouseInjectionScope.middleClick() { + moveTo(center) + press(MouseButton.Tertiary) + release(MouseButton.Tertiary) +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/NoteCardCurrentTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/NoteCardCurrentTest.kt new file mode 100644 index 00000000..7272227b --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/NoteCardCurrentTest.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The card of the note that is open beside the list, and the click that opens a note in a new tab. */ +@OptIn(ExperimentalTestApi::class) +class NoteCardCurrentTest { + private var clicks = 0 + private var commandClicks = 0 + + private fun androidx.compose.ui.test.ComposeUiTest.show(current: Boolean, withCommandClick: Boolean = true) = + setContent { + CompositionLocalProvider(LocalEnclyShortcuts provides EnclyShortcuts(commandKeyIsMeta = false)) { + MaterialTheme { + EnclyNoteCard( + title = "Trip", + excerpt = "Pack the bags", + onClick = { clicks++ }, + current = current, + onCommandClick = if (withCommandClick) ({ commandClicks++ }) else null, + ) + } + } + } + + @Test + fun theCardOfTheShownNoteIsMarkedSelectedForAssistiveTechnology() = runComposeUiTest { + show(current = true) + + onNode(hasClickAction()).assertIsSelected() + } + + @Test + fun anotherCardIsNot() = runComposeUiTest { + show(current = false) + + onNode(hasClickAction()).assertIsNotSelected() + } + + @Test + fun anOrdinaryClickOpensTheNote() = runComposeUiTest { + show(current = false) + + onNode(hasClickAction()).performMouseInput { click() } + + assertEquals(1, clicks) + assertEquals(0, commandClicks) + } + + @Test + fun aClickWithTheCommandKeyOpensItInANewTabInstead() = runComposeUiTest { + show(current = false) + + onNode(hasClickAction()).performKeyInput { keyDown(Key.CtrlLeft) } + onNode(hasClickAction()).performMouseInput { click() } + onNode(hasClickAction()).performKeyInput { keyUp(Key.CtrlLeft) } + + assertEquals(0, clicks) + assertEquals(1, commandClicks) + } + + @Test + fun withoutANewTabHandlerTheCommandClickIsAnOrdinaryClick() = runComposeUiTest { + show(current = false, withCommandClick = false) + + onNode(hasClickAction()).performKeyInput { keyDown(Key.CtrlLeft) } + onNode(hasClickAction()).performMouseInput { click() } + onNode(hasClickAction()).performKeyInput { keyUp(Key.CtrlLeft) } + + assertEquals(1, clicks) + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/PointerClicksTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/PointerClicksTest.kt new file mode 100644 index 00000000..b313e5e9 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/PointerClicksTest.kt @@ -0,0 +1,124 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.MouseButton +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.rightClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The middle click that closes a tab and the command-key click that opens a note in a new tab. */ +@OptIn(ExperimentalTestApi::class) +class PointerClicksTest { + private var clicks = 0 + private var middle = 0 + private var command = 0 + + private fun ComposeUiTest.show(meta: Boolean = false) = setContent { + CompositionLocalProvider(LocalEnclyShortcuts provides EnclyShortcuts(commandKeyIsMeta = meta)) { + Box( + Modifier + .testTag("target") + .size(40.dp) + .enclyMiddleClick { middle++ } + .enclyCommandClick { command++ } + .clickable { clicks++ }, + ) + } + } + + @Test + fun aMiddleClickCallsItAndLeavesTheClickAlone() = runComposeUiTest { + show() + + onNodeWithTag("target").performMouseInput { middleClick() } + + assertEquals(1, middle) + assertEquals(0, clicks) + assertEquals(0, command) + } + + @Test + fun aMiddleClickReleasedOutsideDoesNothing() = runComposeUiTest { + show() + + onNodeWithTag("target").performMouseInput { + moveTo(center) + press(MouseButton.Tertiary) + moveTo(androidx.compose.ui.geometry.Offset(-100f, -100f)) + release(MouseButton.Tertiary) + } + + assertEquals(0, middle) + } + + @Test + fun anOrdinaryClickIsJustAClick() = runComposeUiTest { + show() + + onNodeWithTag("target").performMouseInput { click() } + + assertEquals(1, clicks) + assertEquals(0, middle) + assertEquals(0, command) + } + + @Test + fun aRightClickIsNeitherOfThem() = runComposeUiTest { + show() + + onNodeWithTag("target").performMouseInput { rightClick() } + + assertEquals(0, middle) + assertEquals(0, command) + } + + @Test + fun aClickWithControlHeldIsTheCommandClickOffTheMac() = runComposeUiTest { + show(meta = false) + + onNodeWithTag("target").performKeyInput { keyDown(Key.CtrlLeft) } + onNodeWithTag("target").performMouseInput { click() } + onNodeWithTag("target").performKeyInput { keyUp(Key.CtrlLeft) } + + assertEquals(1, command) + assertEquals(0, clicks) + } + + @Test + fun aClickWithCommandHeldIsTheCommandClickOnAMac() = runComposeUiTest { + show(meta = true) + + onNodeWithTag("target").performKeyInput { keyDown(Key.MetaLeft) } + onNodeWithTag("target").performMouseInput { click() } + onNodeWithTag("target").performKeyInput { keyUp(Key.MetaLeft) } + + assertEquals(1, command) + assertEquals(0, clicks) + } + + @Test + fun theWrongCommandKeyLeavesTheClickAlone() = runComposeUiTest { + show(meta = true) + + onNodeWithTag("target").performKeyInput { keyDown(Key.CtrlLeft) } + onNodeWithTag("target").performMouseInput { click() } + onNodeWithTag("target").performKeyInput { keyUp(Key.CtrlLeft) } + + assertEquals(0, command) + assertEquals(1, clicks) + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/PointerInteractionTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/PointerInteractionTest.kt new file mode 100644 index 00000000..ef5892f6 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/PointerInteractionTest.kt @@ -0,0 +1,103 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** Keyboard focus shows a ring in the content colour, and a mouse click or a disabled target does not. */ +@OptIn(ExperimentalTestApi::class) +class PointerInteractionTest { + private val ring = Color.Red + + @Composable + private fun Target(enabled: Boolean, round: Boolean) { + CompositionLocalProvider(LocalContentColor provides ring) { + val base = Modifier + .testTag("target") + .size(40.dp) + .background(Color.White) + Box( + (if (round) base.enclyInteractiveRound(enabled) else base.enclyInteractive(RectangleShape, enabled)) + .clickable(enabled = enabled) {}, + ) + } + } + + private fun ComposeUiTest.show(enabled: Boolean = true, round: Boolean = false) = + setContent { Target(enabled, round) } + + private fun ComposeUiTest.pixel(x: Int, y: Int) = onNodeWithTag("target").captureToImage().toPixelMap()[x, y] + + @Test + fun aKeyboardFocusShowsTheRingInTheContentColour() = runComposeUiTest { + show() + assertEquals(Color.White, pixel(RING_X, RING_Y)) + + onNodeWithTag("target").performKeyInput { pressKey(Key.Tab) } + waitForIdle() + + assertEquals(ring, pixel(RING_X, RING_Y)) + } + + @Test + fun aRoundTargetRingsItsCircle() = runComposeUiTest { + show(round = true) + + onNodeWithTag("target").performKeyInput { pressKey(Key.Tab) } + waitForIdle() + + assertEquals(ring, pixel(RING_X, RING_Y)) + // The corner is outside the circle. + assertNotEquals(ring, pixel(RING_Y, RING_Y)) + } + + @Test + fun aMouseClickShowsNoRing() = runComposeUiTest { + show() + + onNodeWithTag("target").performMouseInput { click() } + waitForIdle() + + // The ripple's own hover layer tints the pixel; the ring would be the full content colour. + assertNotEquals(ring, pixel(RING_X, RING_Y)) + } + + @Test + fun aDisabledTargetHasNoRingEvenOnTab() = runComposeUiTest { + show(enabled = false) + + onNodeWithTag("target").performKeyInput { pressKey(Key.Tab) } + waitForIdle() + + assertNotEquals(ring, pixel(RING_X, RING_Y)) + } + + private companion object { + // The ring is 2 dp wide, 1 dp inside a 40 dp box; the test density is 1. + const val RING_X = 20 + const val RING_Y = 2 + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/ScrollColumnTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/ScrollColumnTest.kt new file mode 100644 index 00000000..9be03cb7 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/ScrollColumnTest.kt @@ -0,0 +1,134 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The scrolling column the pages use, and the scrollbar along the bottom of a row that scrolls sideways. */ +@OptIn(ExperimentalTestApi::class) +class ScrollColumnTest { + private val side = 200.dp + + @Composable + private fun Frame(content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit) { + MaterialTheme { + Box(Modifier.testTag("frame").size(side).background(Color.White), content = content) + } + } + + private fun ComposeUiTest.pixelsOnTheEnd(): Boolean { + val image = onNodeWithTag("frame").captureToImage().toPixelMap() + return (image.width - EDGE until image.width).any { x -> + (0 until image.height).any { y -> image[x, y] != Color.White } + } + } + + private fun ComposeUiTest.pixelsOnTheBottom(): Boolean { + val image = onNodeWithTag("frame").captureToImage().toPixelMap() + return (image.height - EDGE until image.height).any { y -> + (0 until image.width).any { x -> image[x, y] != Color.White } + } + } + + @Test + fun aLongPageShowsItsScrollbarAndScrolls() = runComposeUiTest { + setContent { + Frame { + EnclyScrollColumn(contentPadding = PaddingValues(horizontal = 8.dp)) { + repeat(ROWS) { Box(Modifier.fillMaxWidth().height(40.dp)) } + } + } + } + waitForIdle() + + assertTrue(pixelsOnTheEnd()) + } + + @Test + fun aShortPageShowsNone() = runComposeUiTest { + setContent { + Frame { + EnclyScrollColumn { repeat(2) { Box(Modifier.fillMaxWidth().height(40.dp)) } } + } + } + waitForIdle() + + assertFalse(pixelsOnTheEnd()) + } + + @Test + fun aRowWiderThanItsBoxShowsItsScrollbarAtTheBottom() = runComposeUiTest { + setContent { + Frame { + val state = rememberScrollState() + Row(Modifier.fillMaxHeight().horizontalScroll(state)) { + repeat(ROWS) { Box(Modifier.width(40.dp).fillMaxHeight()) } + } + EnclyHorizontalScrollbar(state) + } + } + waitForIdle() + + assertTrue(pixelsOnTheBottom()) + } + + @Test + fun aRowThatFitsShowsNone() = runComposeUiTest { + setContent { + Frame { + val state = rememberScrollState() + Row(Modifier.fillMaxHeight().horizontalScroll(state)) { + repeat(2) { Box(Modifier.width(40.dp).fillMaxHeight()) } + } + EnclyHorizontalScrollbar(state) + } + } + waitForIdle() + + assertFalse(pixelsOnTheBottom()) + } + + @Test + fun theContentPaddingIsInsideTheScrollingArea() = runComposeUiTest { + setContent { + Frame { + EnclyScrollColumn(contentPadding = PaddingValues(top = 30.dp)) { + Box(Modifier.testTag("first").fillMaxWidth().height(10.dp).background(Color.Black)) + } + } + } + waitForIdle() + + val top = onNodeWithTag("first").fetchSemanticsNode().boundsInRoot.top + assertEquals(true, top > 0f) + } + + private companion object { + const val ROWS = 100 + const val EDGE = 12 + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/ScrollbarTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/ScrollbarTest.kt new file mode 100644 index 00000000..ef6a0469 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/ScrollbarTest.kt @@ -0,0 +1,173 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** A desktop list shows a scrollbar while its content does not fit, in the theme's colours; a short one shows none. */ +@OptIn(ExperimentalTestApi::class) +class ScrollbarTest { + private val side = 200.dp + + @Composable + private fun Frame(content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit) { + MaterialTheme { + Box(Modifier.testTag("frame").size(side).background(Color.White), content = content) + } + } + + /** Whether anything but the white background is drawn in the last 12 px at the end edge. */ + private fun ComposeUiTest.scrollbarShown(): Boolean { + val image = onNodeWithTag("frame").captureToImage().toPixelMap() + return (image.width - EDGE until image.width).any { x -> + (0 until image.height).any { y -> image[x, y] != Color.White } + } + } + + @Test + fun aLongListShowsItsScrollbar() = runComposeUiTest { + setContent { + Frame { + val state = rememberLazyListState() + LazyColumn(state = state) { items(ROWS) { Box(Modifier.fillMaxWidth().height(40.dp)) } } + EnclyScrollbar(state) + } + } + waitForIdle() + + assertTrue(scrollbarShown()) + } + + @Test + fun aShortListShowsNone() = runComposeUiTest { + setContent { + Frame { + val state = rememberLazyListState() + LazyColumn(state = state) { items(2) { Box(Modifier.fillMaxWidth().height(40.dp)) } } + EnclyScrollbar(state) + } + } + waitForIdle() + + assertFalse(scrollbarShown()) + } + + @Test + fun aLongColumnShowsItsScrollbar() = runComposeUiTest { + setContent { + Frame { + val state = rememberScrollState() + Column(Modifier.verticalScroll(state)) { repeat(ROWS) { Box(Modifier.fillMaxWidth().height(40.dp)) } } + EnclyScrollbar(state) + } + } + waitForIdle() + + assertTrue(scrollbarShown()) + } + + @Test + fun aShortColumnShowsNone() = runComposeUiTest { + setContent { + Frame { + val state = rememberScrollState() + Column(Modifier.verticalScroll(state)) { repeat(2) { Box(Modifier.fillMaxWidth().height(40.dp)) } } + EnclyScrollbar(state) + } + } + waitForIdle() + + assertFalse(scrollbarShown()) + } + + @Test + fun aLongGridShowsItsScrollbar() = runComposeUiTest { + setContent { + Frame { + val state = rememberLazyStaggeredGridState() + LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Fixed(2), state = state) { + items(ROWS) { Box(Modifier.fillMaxWidth().height(40.dp)) } + } + EnclyScrollbar(state) + } + } + waitForIdle() + + assertTrue(scrollbarShown()) + } + + @Test + fun aShortGridShowsNone() = runComposeUiTest { + setContent { + Frame { + val state = rememberLazyStaggeredGridState() + LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Fixed(2), state = state) { + items(2) { Box(Modifier.fillMaxWidth().height(40.dp)) } + } + EnclyScrollbar(state) + } + } + waitForIdle() + + assertFalse(scrollbarShown()) + } + + @Test + fun theGridAdapterEstimatesFromTheCellsInViewAndScrollsTo() = runComposeUiTest { + lateinit var adapter: StaggeredGridScrollbarAdapter + lateinit var state: androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState + setContent { + Frame { + state = rememberLazyStaggeredGridState() + adapter = StaggeredGridScrollbarAdapter(state) + LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Fixed(2), state = state) { + items(ROWS) { Box(Modifier.fillMaxWidth().height(40.dp)) } + } + } + } + waitForIdle() + + // 200 dp in view, 40 dp cells at 2 lanes: 100 cells are 50 rows of 40 dp. + assertEquals(200.0, adapter.viewportSize) + assertEquals(0.0, adapter.scrollOffset) + assertEquals(ROWS / 2 * 40.0, adapter.contentSize) + + runBlocking { adapter.scrollTo(500.0) } + waitForIdle() + + assertEquals(500.0, adapter.scrollOffset) + assertEquals(24, state.firstVisibleItemIndex) + } + + private companion object { + const val ROWS = 100 + const val EDGE = 12 + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/TooltipHoverTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/TooltipHoverTest.kt new file mode 100644 index 00000000..aa82d7ec --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/designsystem/TooltipHoverTest.kt @@ -0,0 +1,67 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.click +import androidx.compose.ui.test.moveTo +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** On a desktop an icon button names itself when the pointer rests on it, and a click still acts. */ +@OptIn(ExperimentalTestApi::class) +class TooltipHoverTest { + private var clicks = 0 + + private fun androidx.compose.ui.test.ComposeUiTest.show() = setContent { + MaterialTheme { + IconWithTooltip(EnclyIcons.Undo, "Undo", onClick = { clicks++ }, modifier = Modifier.testTag("button")) + } + } + + @Test + fun restingThePointerOnTheButtonShowsItsLabel() = runComposeUiTest { + mainClock.autoAdvance = false + show() + onNodeWithText("Undo").assertDoesNotExist() + + onNodeWithTag("button").performMouseInput { moveTo(center) } + mainClock.advanceTimeBy(TOOLTIP_WAIT_MS) + waitForIdle() + + onNodeWithText("Undo").assertExists() + } + + @Test + fun leavingTheButtonHidesItAgain() = runComposeUiTest { + mainClock.autoAdvance = false + show() + onNodeWithTag("button").performMouseInput { moveTo(center) } + mainClock.advanceTimeBy(TOOLTIP_WAIT_MS) + waitForIdle() + + onNodeWithTag("button").performMouseInput { exit() } + mainClock.advanceTimeBy(TOOLTIP_WAIT_MS) + waitForIdle() + + onNodeWithText("Undo").assertDoesNotExist() + } + + @Test + fun aClickStillRunsTheAction() = runComposeUiTest { + show() + + onNodeWithTag("button").performMouseInput { click() } + + assertEquals(1, clicks) + } + + private companion object { + const val TOOLTIP_WAIT_MS = 3_000L + } +} diff --git a/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/pane/NoteTabStripTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/pane/NoteTabStripTest.kt new file mode 100644 index 00000000..7e57f301 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/presentation/pane/NoteTabStripTest.kt @@ -0,0 +1,128 @@ +package com.pasich.encly.presentation.pane + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import com.pasich.encly.presentation.designsystem.EnclyShortcuts +import com.pasich.encly.presentation.designsystem.LocalEnclyShortcuts +import com.pasich.encly.presentation.designsystem.middleClick +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The strip of open notes with a mouse: select, middle-click to close, the hint on the close button. */ +@OptIn(ExperimentalTestApi::class) +class NoteTabStripTest { + private val selected = mutableListOf() + private val closed = mutableListOf() + private val tabs = listOf( + NoteTab(key = 1, noteId = 11, title = "Trip"), + NoteTab(key = 2, noteId = 12, title = "Groceries"), + NoteTab(key = 3, noteId = 13, title = ""), + ) + + private fun ComposeUiTest.show(width: Int = 800, activeKey: Long = 1) = setContent { + CompositionLocalProvider(LocalEnclyShortcuts provides EnclyShortcuts(commandKeyIsMeta = false)) { + MaterialTheme { + Box(Modifier.width(width.dp)) { + NoteTabStrip(tabs, activeKey, onSelect = { selected += it }, onClose = { closed += it }) + } + } + } + } + + private fun ComposeUiTest.closeButton(name: String) = + onNodeWithContentDescription("Close $name", useUnmergedTree = true) + + @Test + fun clickingATabSelectsIt() = runComposeUiTest { + show() + + onNodeWithText("Groceries").performMouseInput { click() } + + assertEquals(listOf(2L), selected) + } + + @Test + fun aMiddleClickClosesATabWithoutSelectingIt() = runComposeUiTest { + show() + + onNodeWithText("Groceries").performMouseInput { middleClick() } + + assertEquals(listOf(2L), closed) + assertTrue(selected.isEmpty()) + } + + @Test + fun theCloseButtonClosesItsOwnTab() = runComposeUiTest { + show() + + closeButton("Groceries").performMouseInput { click() } + + assertEquals(listOf(2L), closed) + assertTrue(selected.isEmpty()) + } + + @Test + fun theShownTabIsMarked() = runComposeUiTest { + show(activeKey = 2) + + onAllNodesWithTag(NOTE_TAB_TAG)[1].assertIsSelected() + } + + @Test + fun hoveringTheShownTabsCloseButtonNamesTheKeyThatClosesIt() = runComposeUiTest { + mainClock.autoAdvance = false + show() + + closeButton("Trip").performMouseInput { moveTo(center) } + mainClock.advanceTimeBy(TOOLTIP_WAIT_MS) + waitForIdle() + + onNodeWithText("Close Trip (Ctrl+W)").assertExists() + } + + @Test + fun aTabThatIsNotShownHasNoKeyInItsHint() = runComposeUiTest { + mainClock.autoAdvance = false + show() + + closeButton("Groceries").performMouseInput { moveTo(center) } + mainClock.advanceTimeBy(TOOLTIP_WAIT_MS) + waitForIdle() + + onNodeWithText("Close Groceries").assertExists() + } + + @Test + fun aBlankTabIsCalledUntitled() = runComposeUiTest { + show() + + assertEquals(3, onAllNodesWithTag(NOTE_TAB_TAG).fetchSemanticsNodes().size) + closeButton("Untitled").assertExists() + } + + @Test + fun theTabsStayReachableWhenTheyDoNotFit() = runComposeUiTest { + show(width = 160) + + assertEquals(3, onAllNodesWithTag(NOTE_TAB_TAG).fetchSemanticsNodes().size) + onNodeWithText("Trip").assertExists() + } + + private companion object { + const val TOOLTIP_WAIT_MS = 3_000L + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt b/shared/src/iosMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt new file mode 100644 index 00000000..4a457bf2 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.core.di + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +internal actual val platformIoDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/data/export/IosUnicode.kt b/shared/src/iosMain/kotlin/com/pasich/encly/data/export/IosUnicode.kt new file mode 100644 index 00000000..bd52368a --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/data/export/IosUnicode.kt @@ -0,0 +1,110 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.data.export + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSCharacterSet +import platform.Foundation.NSString +import platform.Foundation.precomposedStringWithCanonicalMapping + +/* + * The iOS side of the file-name rules in MarkdownFileNames.kt. Normalization is Foundation's. The Unicode category + * of a character below U+10000 comes from Kotlin's own `CharCategory`; above it Kotlin has none, so Foundation's + * `illegalCharacterSet` (the unassigned code points) and the ranges of the private-use planes, the tag characters + * and the musical-symbol format characters stand in for it. + */ + +private const val TAB = 0x09 +private const val LINE_FEED = 0x0A +private const val CARRIAGE_RETURN = 0x0D +private const val SUPPLEMENTARY_START = 0x10000 +private const val SURROGATE_BITS = 10 +private const val BYTES_1 = 0x80 +private const val BYTES_2 = 0x800 +private const val BYTES_3 = 0x10000 +private val FORBIDDEN = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|') + +/** [text] in Unicode normalization form C. */ +internal actual fun normalizeNfc(text: String): String = (text as NSString).precomposedStringWithCanonicalMapping + +/** + * [text] without the control, format, separator, private-use, unassigned and lone-surrogate + * characters and without the characters file systems reject; every kind of space becomes one + * plain space. (Runs of spaces, dots at the ends and reserved names are the caller's.) + */ +internal actual fun keepFileNameCharacters(text: String): String { + val kept = StringBuilder(text.length) + var index = 0 + while (index < text.length) { + val char = text[index] + val paired = char.isHighSurrogate() && index + 1 < text.length && text[index + 1].isLowSurrogate() + if (paired) { + val cp = SUPPLEMENTARY_START + ((char - Char.MIN_HIGH_SURROGATE) shl SURROGATE_BITS) + + (text[index + 1] - Char.MIN_LOW_SURROGATE) + if (isPlainSupplementary(cp)) kept.append(char).append(text[index + 1]) + index += 2 + continue + } + index++ + when { + char.isISOControl() && char.code != TAB && char.code != LINE_FEED && char.code != CARRIAGE_RETURN -> Unit + char.isWhitespace() -> kept.append(' ') + char in FORBIDDEN -> Unit + isPlain(char) -> kept.append(char) + } + } + return kept.toString() +} + +/** Not a control, format, separator, private-use, unassigned or lone surrogate character. */ +private fun isPlain(char: Char): Boolean = when (char.category) { + CharCategory.CONTROL, CharCategory.FORMAT, CharCategory.LINE_SEPARATOR, CharCategory.PARAGRAPH_SEPARATOR, + CharCategory.PRIVATE_USE, CharCategory.UNASSIGNED, CharCategory.SURROGATE, + -> false + + else -> true +} + +@Suppress("MagicNumber") // The code point ranges are the data of this function. +private fun isPlainSupplementary(cp: Int): Boolean = when { + // Private-use planes 15 and 16. + cp in 0xF0000..0x10FFFF -> false + + // Language tag characters (format). + cp in 0xE0000..0xE007F -> false + + // Musical symbol format characters. + cp in 0x1D173..0x1D17A -> false + + else -> !NSCharacterSet.illegalCharacterSet().longCharacterIsMember(cp.toUInt()) +} + +/** The longest prefix of [text] that is at most [bytes] bytes of UTF-8, ending on a character. */ +@Suppress("MagicNumber") // The sizes of a UTF-8 sequence. +internal actual fun utf8Prefix(text: String, bytes: Int): String { + var used = 0 + var end = 0 + while (end < text.length) { + val char = text[end] + val paired = char.isHighSurrogate() && end + 1 < text.length && text[end + 1].isLowSurrogate() + val units = if (paired) 2 else 1 + val size = when { + paired -> 4 + + // A lone surrogate is written as "?", as on the JVM. + char.isSurrogate() -> 1 + + char.code < BYTES_1 -> 1 + + char.code < BYTES_2 -> 2 + + char.code < BYTES_3 -> 3 + + else -> 4 + } + if (used + size > bytes) break + used += size + end += units + } + return text.substring(0, end) +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/data/importer/OutOfMemory.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/data/importer/OutOfMemory.ios.kt new file mode 100644 index 00000000..efa77ee6 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/data/importer/OutOfMemory.ios.kt @@ -0,0 +1,3 @@ +package com.pasich.encly.data.importer + +internal actual fun Throwable.isOutOfMemory(): Boolean = this is OutOfMemoryError diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/data/zip/IosZip.kt b/shared/src/iosMain/kotlin/com/pasich/encly/data/zip/IosZip.kt new file mode 100644 index 00000000..c5d96e3f --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/data/zip/IosZip.kt @@ -0,0 +1,553 @@ +@file:OptIn(ExperimentalForeignApi::class) +@file:Suppress("MagicNumber", "TooManyFunctions") // The offsets and flags of the ZIP format; one seam, many parts. + +package com.pasich.encly.data.zip + +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.nativeHeap +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.sizeOf +import kotlinx.cinterop.usePinned +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.Sink +import kotlinx.io.files.Path +import kotlinx.io.writeIntLe +import kotlinx.io.writeShortLe +import platform.posix.O_RDONLY +import platform.posix.SEEK_END +import platform.posix.close +import platform.posix.lseek +import platform.posix.memcpy +import platform.posix.memset +import platform.posix.open +import platform.posix.pread +import platform.zlib.ZLIB_VERSION +import platform.zlib.Z_DEFAULT_COMPRESSION +import platform.zlib.Z_DEFAULT_STRATEGY +import platform.zlib.Z_DEFLATED +import platform.zlib.Z_FINISH +import platform.zlib.Z_NEED_DICT +import platform.zlib.Z_NO_FLUSH +import platform.zlib.Z_OK +import platform.zlib.Z_STREAM_END +import platform.zlib.deflate +import platform.zlib.deflateBound +import platform.zlib.deflateEnd +import platform.zlib.deflateInit2_ +import platform.zlib.inflate +import platform.zlib.inflateEnd +import platform.zlib.inflateInit2_ +import platform.zlib.z_stream +import kotlin.time.Instant + +/* + * The iOS side of ZipPlatform.kt. zlib comes with the system (`platform.zlib`) and does the deflating and + * inflating; the container format (local headers, central directory, ZIP64 sizes of single entries) is read and + * written here, because iOS has no ZIP API. Not supported, and refused with an IOException instead of read wrongly: + * encrypted entries, compression methods other than stored and deflate, archives that need the ZIP64 end record + * (more than 65,534 entries or a directory past 4 GiB), and several-disk archives. + */ + +private const val RAW_WINDOW_BITS = 15 +private const val CHUNK = 8 * 1024 +private const val BAD_DATA = "bad zip data" + +private const val SIG_LOCAL = 0x04034b50 +private const val SIG_CENTRAL = 0x02014b50 +private const val SIG_END = 0x06054b50 +private const val END_RECORD_SIZE = 22 +private const val MAX_COMMENT = 0xFFFF +private const val CENTRAL_HEADER_SIZE = 46 +private const val LOCAL_HEADER_SIZE = 30 +private const val MAX_CENTRAL_BYTES = 256L * 1024 * 1024 +private const val METHOD_STORED = 0 +private const val METHOD_DEFLATED = 8 +private const val FLAG_ENCRYPTED = 0x0001 +private const val FLAG_UTF8 = 0x0800 +private const val MASK_32 = 0xFFFFFFFFL +private const val MAX_32 = 0xFFFFFFFFL +private const val MAX_16 = 0xFFFF +private const val ZIP64_EXTRA = 0x0001 +private const val VERSION_NEEDED = 20 +private const val DOS_EPOCH_YEAR = 1980 +private const val DOS_MAX_YEAR = 2107 + +// -- CRC-32 ------------------------------------------------------------------------------------------------------ + +private val CRC_TABLE = IntArray(256) { n -> + var c = n + repeat(8) { c = if (c and 1 != 0) (c ushr 1) xor 0xEDB88320.toInt() else c ushr 1 } + c +} + +internal actual fun newCrc32(): Crc32 = object : Crc32 { + private var crc = -1 + + override val value: Long get() = crc.inv().toLong() and MASK_32 + + override fun update(bytes: ByteArray, offset: Int, length: Int) { + var c = crc + for (i in offset until offset + length) c = CRC_TABLE[(c xor bytes[i].toInt()) and 0xFF] xor (c ushr 8) + crc = c + } +} + +// -- Inflate ----------------------------------------------------------------------------------------------------- + +internal actual fun newRawInflater(): RawInflater = ZlibInflater() + +private class ZlibInflater : RawInflater { + private val stream = nativeHeap.alloc() + private var input: CPointer? = null + private var done = false + private var dictionaryNeeded = false + private var ended = false + + init { + memset(stream.ptr, 0, sizeOf().convert()) + val status = inflateInit2_(stream.ptr, -RAW_WINDOW_BITS, ZLIB_VERSION, sizeOf().toInt()) + if (status != Z_OK) { + nativeHeap.free(stream.rawPtr) + ended = true + throw IOException(BAD_DATA) + } + } + + override val remaining: Int get() = if (ended) 0 else stream.avail_in.toInt() + override val bytesRead: Long get() = if (ended) 0 else stream.total_in.toLong() + override val bytesWritten: Long get() = if (ended) 0 else stream.total_out.toLong() + + override fun needsInput(): Boolean = !ended && !done && stream.avail_in == 0u + + override fun needsDictionary(): Boolean = dictionaryNeeded + + override fun finished(): Boolean = done + + override fun setInput(bytes: ByteArray, offset: Int, length: Int) { + check(!ended) { "inflater ended" } + input?.let { nativeHeap.free(it.rawValue) } + input = null + if (length > 0) { + val copy = nativeHeap.allocArray(length) + bytes.usePinned { memcpy(copy, it.addressOf(offset), length.convert()) } + input = copy + } + stream.next_in = input?.reinterpret() + stream.avail_in = length.convert() + } + + override fun inflate(bytes: ByteArray, offset: Int, length: Int): Int { + check(!ended) { "inflater ended" } + if (length == 0 || done) return 0 + val produced = bytes.usePinned { pinned -> + stream.next_out = pinned.addressOf(offset).reinterpret() + stream.avail_out = length.convert() + when (inflate(stream.ptr, Z_NO_FLUSH)) { + Z_STREAM_END -> done = true + + Z_NEED_DICT -> dictionaryNeeded = true + + // Z_BUF_ERROR: no progress was possible (more input is needed); the caller sees 0. + Z_OK, platform.zlib.Z_BUF_ERROR -> Unit + + else -> throw IOException(BAD_DATA) + } + length - stream.avail_out.toInt() + } + return produced + } + + override fun end() { + if (ended) return + ended = true + inflateEnd(stream.ptr) + input?.let { nativeHeap.free(it.rawValue) } + input = null + nativeHeap.free(stream.rawPtr) + } +} + +// -- File names -------------------------------------------------------------------------------------------------- + +private const val CP437_HIGH = + "ÇüéâäàåçêëèïîìÄÅ" + + "ÉæÆôöòûùÿÖÜ¢£¥₧ƒ" + + "áíóúñѪº¿⌐¬½¼¡«»" + + "░▒▓│┤╡╢╖╕╣║╗╝╜╛┐" + + "└┴┬├─┼╞╟╚╔╩╦╠═╬╧" + + "╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀" + + "αßΓπΣσµτΦΘΩδ∞φε∩" + + "≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ " + +internal actual fun decodeCp437(bytes: ByteArray): String = buildString(bytes.size) { + for (byte in bytes) { + val code = byte.toInt() and 0xFF + append(if (code < 0x80) code.toChar() else CP437_HIGH[code - 0x80]) + } +} + +// -- Reading ----------------------------------------------------------------------------------------------------- + +/** A file read at absolute offsets (`pread`), closed once. */ +private class RandomAccessFile(path: String) : AutoCloseable { + private val fd: Int = open(path, O_RDONLY) + private var closed = false + val size: Long + + init { + if (fd < 0) throw IOException("cannot open the archive") + size = lseek(fd, 0, SEEK_END) + if (size < 0) { + close(fd) + throw IOException("cannot read the archive") + } + } + + /** Fills [target] from [offset]; throws if the file ends first. */ + fun readFully(offset: Long, target: ByteArray, start: Int = 0, count: Int = target.size - start) { + var done = 0 + while (done < count) { + val read = readAtMost(offset + done, target, start + done, count - done) + if (read <= 0) throw IOException("the archive is cut short") + done += read + } + } + + /** Up to [count] bytes from [offset] into [target]; 0 at the end of the file. */ + fun readAtMost(offset: Long, target: ByteArray, start: Int, count: Int): Int { + check(!closed) { "archive closed" } + if (count == 0) return 0 + val read = target.usePinned { pread(fd, it.addressOf(start), count.convert(), offset) }.toInt() + if (read < 0) throw IOException("cannot read the archive") + return read + } + + override fun close() { + if (!closed) { + closed = true + close(fd) + } + } +} + +private fun ByteArray.u16(at: Int): Int = (this[at].toInt() and 0xFF) or ((this[at + 1].toInt() and 0xFF) shl 8) + +private fun ByteArray.u32(at: Int): Long = (u16(at).toLong()) or (u16(at + 2).toLong() shl 16) + +private fun ByteArray.u64(at: Int): Long = u32(at) or (u32(at + 4) shl 32) + +private class CentralEntry( + val name: String, + val method: Int, + val flags: Int, + val compressedSize: Long, + val uncompressedSize: Long, + val localOffset: Long, +) + +internal actual fun openZipFile(path: Path): ZipEntrySource { + val file = RandomAccessFile(path.toString()) + var opened = false + try { + return IosZipSource(file, readCentralDirectory(file)).also { opened = true } + } finally { + if (!opened) file.close() + } +} + +@Suppress("ThrowsCount") // Each check of the end record fails in its own way. +private fun readCentralDirectory(file: RandomAccessFile): Map { + val tailSize = minOf(file.size, (END_RECORD_SIZE + MAX_COMMENT).toLong()).toInt() + if (tailSize < END_RECORD_SIZE) throw IOException("not a zip file") + val tail = ByteArray(tailSize) + file.readFully(file.size - tailSize, tail) + var end = tailSize - END_RECORD_SIZE + while (end >= 0 && tail.u32(end) != SIG_END.toLong()) end-- + if (end < 0) throw IOException("not a zip file") + val disk = tail.u16(end + 4) + val entries = tail.u16(end + 10) + val size = tail.u32(end + 12) + val offset = tail.u32(end + 16) + if (disk != 0 || tail.u16(end + 6) != 0) throw IOException("zip archives of several disks are not supported") + if (entries == MAX_16 || size == MAX_32 || offset == MAX_32) throw IOException("zip64 archives are not supported") + if (size > MAX_CENTRAL_BYTES || offset + size > file.size) throw IOException("bad zip data") + val directory = ByteArray(size.toInt()) + file.readFully(offset, directory) + val result = LinkedHashMap(entries) + var at = 0 + repeat(entries) { + if (at + CENTRAL_HEADER_SIZE > directory.size || directory.u32(at) != SIG_CENTRAL.toLong()) { + throw IOException(BAD_DATA) + } + val entry = parseCentralEntry(directory, at) + result.putIfAbsentFirst(entry) + at += CENTRAL_HEADER_SIZE + directory.u16(at + 28) + directory.u16(at + 30) + directory.u16(at + 32) + } + return result +} + +private fun MutableMap.putIfAbsentFirst(entry: CentralEntry) { + if (!containsKey(entry.name)) this[entry.name] = entry +} + +@Suppress("CyclomaticComplexMethod") // The fields of one central directory record, then its ZIP64 extra. +private fun parseCentralEntry(directory: ByteArray, at: Int): CentralEntry { + val flags = directory.u16(at + 8) + val method = directory.u16(at + 10) + var compressed = directory.u32(at + 20) + var uncompressed = directory.u32(at + 24) + val nameLength = directory.u16(at + 28) + val extraLength = directory.u16(at + 30) + var localOffset = directory.u32(at + 42) + val nameStart = at + CENTRAL_HEADER_SIZE + val extraStart = nameStart + nameLength + if (extraStart + extraLength > directory.size) throw IOException(BAD_DATA) + val nameBytes = directory.copyOfRange(nameStart, extraStart) + val name = if (flags and FLAG_UTF8 != 0) nameBytes.decodeToString() else decodeCp437(nameBytes) + // The ZIP64 extra holds, in this order, only the values the record set to 0xFFFFFFFF. + var extra = extraStart + while (extra + 4 <= extraStart + extraLength) { + val id = directory.u16(extra) + val length = directory.u16(extra + 2) + if (id == ZIP64_EXTRA) { + var field = extra + 4 + if (uncompressed == MAX_32) uncompressed = directory.u64(field).also { field += 8 } + if (compressed == MAX_32) compressed = directory.u64(field).also { field += 8 } + if (localOffset == MAX_32) localOffset = directory.u64(field) + } + extra += 4 + length + } + return CentralEntry(name, method, flags, compressed, uncompressed, localOffset) +} + +private class IosZipSource(private val file: RandomAccessFile, private val entries: Map) : + ZipEntrySource { + override val entryCount: Int get() = entries.size + + override fun find(name: String): ZipEntryHandle? = + (entries[name] ?: entries["$name/"])?.let { entry -> IosZipEntry(file, entry) } + + override fun close() = file.close() +} + +private class IosZipEntry(private val file: RandomAccessFile, private val entry: CentralEntry) : ZipEntryHandle { + override val name: String get() = entry.name + override val isDirectory: Boolean get() = entry.name.endsWith("/") + override val declaredSize: Long get() = entry.uncompressedSize + + override fun open(): RawSource { + if (entry.flags and FLAG_ENCRYPTED != 0) throw IOException("encrypted zip entries are not supported") + if (entry.method != METHOD_STORED && entry.method != METHOD_DEFLATED) { + throw IOException("unsupported zip compression method") + } + val header = ByteArray(LOCAL_HEADER_SIZE) + file.readFully(entry.localOffset, header) + if (header.u32(0) != SIG_LOCAL.toLong()) throw IOException(BAD_DATA) + val dataStart = entry.localOffset + LOCAL_HEADER_SIZE + header.u16(26) + header.u16(28) + if (dataStart + entry.compressedSize > file.size) throw IOException(BAD_DATA) + return EntrySource(file, dataStart, entry.compressedSize, entry.method == METHOD_DEFLATED) + } +} + +/** The bytes of one entry: copied as they are (stored) or inflated, a piece per read. */ +private class EntrySource( + private val file: RandomAccessFile, + private var position: Long, + private var compressedLeft: Long, + deflated: Boolean, +) : RawSource { + private val inflater: RawInflater? = if (deflated) newRawInflater() else null + private val compressed = ByteArray(CHUNK) + private val plain = ByteArray(CHUNK) + private var closed = false + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + check(!closed) { "source closed" } + require(byteCount >= 0) { "byteCount < 0: $byteCount" } + if (byteCount == 0L) return 0 + val max = minOf(byteCount, CHUNK.toLong()).toInt() + return if (inflater == null) readStored(sink, max) else readInflated(inflater, sink, max) + } + + private fun readStored(sink: Buffer, max: Int): Long { + if (compressedLeft == 0L) return -1 + val count = minOf(max.toLong(), compressedLeft).toInt() + val read = file.readAtMost(position, plain, 0, count) + if (read <= 0) throw IOException(BAD_DATA) + position += read + compressedLeft -= read + sink.write(plain, 0, read) + return read.toLong() + } + + @Suppress("ReturnCount", "ThrowsCount") // The states of a streaming inflate. + private fun readInflated(inflater: RawInflater, sink: Buffer, max: Int): Long { + while (true) { + if (inflater.finished()) return -1 + if (inflater.needsInput()) { + if (compressedLeft == 0L) throw IOException(BAD_DATA) + val read = file.readAtMost(position, compressed, 0, minOf(CHUNK.toLong(), compressedLeft).toInt()) + if (read <= 0) throw IOException(BAD_DATA) + position += read + compressedLeft -= read + inflater.setInput(compressed, 0, read) + } + val produced = inflater.inflate(plain, 0, max) + if (inflater.needsDictionary()) throw IOException(BAD_DATA) + if (produced > 0) { + sink.write(plain, 0, produced) + return produced.toLong() + } + if (inflater.finished()) return -1 + // No output and no progress to hope for: all input is used up but the stream did not end. + if (!inflater.needsInput() && inflater.remaining == 0 && compressedLeft == 0L) throw IOException(BAD_DATA) + } + } + + override fun close() { + if (closed) return + closed = true + inflater?.end() + } +} + +// -- Writing ----------------------------------------------------------------------------------------------------- + +internal actual fun zipWriter(out: Sink, now: () -> Long): ZipEntrySink = IosZipWriter(out, now) + +private class IosZipWriter(private val out: Sink, private val now: () -> Long) : ZipEntrySink { + @Suppress("LongParameterList") // One local header, kept for the central directory. + private class Written( + val name: ByteArray, + val method: Int, + val dosTime: Int, + val dosDate: Int, + val crc: Long, + val compressedSize: Int, + val size: Int, + val offset: Long, + ) + + private val entries = ArrayList() + private var position = 0L + + override fun put(name: String, time: Long?, bytes: ByteArray) { + val nameBytes = name.encodeToByteArray() + val crc = newCrc32().also { it.update(bytes, 0, bytes.size) }.value + val deflated = if (bytes.isEmpty()) bytes else deflateRaw(bytes) + val method = if (bytes.isEmpty()) METHOD_STORED else METHOD_DEFLATED + val (dosDate, dosTime) = dosDateTime(time ?: now()) + val entry = Written(nameBytes, method, dosTime, dosDate, crc, deflated.size, bytes.size, position) + out.writeIntLe(SIG_LOCAL) + out.writeShortLe(VERSION_NEEDED.toShort()) + out.writeShortLe(FLAG_UTF8.toShort()) + out.writeShortLe(method.toShort()) + out.writeShortLe(dosTime.toShort()) + out.writeShortLe(dosDate.toShort()) + out.writeIntLe(crc.toInt()) + out.writeIntLe(deflated.size) + out.writeIntLe(bytes.size) + out.writeShortLe(nameBytes.size.toShort()) + out.writeShortLe(0) + out.write(nameBytes) + out.write(deflated) + position += LOCAL_HEADER_SIZE + nameBytes.size + deflated.size + if (position > MAX_32 || entries.size >= MAX_16 - 1) throw IOException("the archive is too large for a zip") + entries += entry + } + + override fun finish() { + val directoryStart = position + for (entry in entries) { + out.writeIntLe(SIG_CENTRAL) + out.writeShortLe(VERSION_NEEDED.toShort()) + out.writeShortLe(VERSION_NEEDED.toShort()) + out.writeShortLe(FLAG_UTF8.toShort()) + out.writeShortLe(entry.method.toShort()) + out.writeShortLe(entry.dosTime.toShort()) + out.writeShortLe(entry.dosDate.toShort()) + out.writeIntLe(entry.crc.toInt()) + out.writeIntLe(entry.compressedSize) + out.writeIntLe(entry.size) + out.writeShortLe(entry.name.size.toShort()) + out.writeShortLe(0) + out.writeShortLe(0) + out.writeShortLe(0) + out.writeShortLe(0) + out.writeIntLe(0) + out.writeIntLe(entry.offset.toInt()) + out.write(entry.name) + position += CENTRAL_HEADER_SIZE + entry.name.size + } + val directorySize = position - directoryStart + out.writeIntLe(SIG_END) + out.writeShortLe(0) + out.writeShortLe(0) + out.writeShortLe(entries.size.toShort()) + out.writeShortLe(entries.size.toShort()) + out.writeIntLe(directorySize.toInt()) + out.writeIntLe(directoryStart.toInt()) + out.writeShortLe(0) + out.flush() + } +} + +/** [bytes] as a raw deflate stream (no zlib header), in memory. */ +private fun deflateRaw(bytes: ByteArray): ByteArray = memScoped { + val stream = alloc() + memset(stream.ptr, 0, sizeOf().convert()) + val init = deflateInit2_( + stream.ptr, + Z_DEFAULT_COMPRESSION, + Z_DEFLATED, + -RAW_WINDOW_BITS, + 8, + Z_DEFAULT_STRATEGY, + ZLIB_VERSION, + sizeOf().toInt(), + ) + if (init != Z_OK) throw IOException("cannot compress") + try { + val bound = deflateBound(stream.ptr, bytes.size.convert()).toInt() + val output = ByteArray(bound) + bytes.usePinned { input -> + output.usePinned { target -> + stream.next_in = input.addressOf(0).reinterpret() + stream.avail_in = bytes.size.convert() + stream.next_out = target.addressOf(0).reinterpret() + stream.avail_out = bound.convert() + if (deflate(stream.ptr, Z_FINISH) != Z_STREAM_END) throw IOException("cannot compress") + } + } + output.copyOf(stream.total_out.toInt()) + } finally { + deflateEnd(stream.ptr) + } +} + +/** The DOS date and time of [epochMillis] in the local zone, as ZIP stores them; years outside 1980..2107 clamp. */ +@Suppress("ReturnCount") // The two ends of the DOS range, then the date. +private fun dosDateTime(epochMillis: Long): Pair { + val local = Instant.fromEpochMilliseconds(epochMillis).toLocalDateTime(TimeZone.currentSystemDefault()) + if (local.year < DOS_EPOCH_YEAR) return ((1 shl 5) or 1) to 0 + if (local.year > DOS_MAX_YEAR) { + return (((DOS_MAX_YEAR - DOS_EPOCH_YEAR) shl 9) or (12 shl 5) or 31) to + ((23 shl 11) or (59 shl 5) or 29) + } + val date = ((local.year - DOS_EPOCH_YEAR) shl 9) or ((local.month.ordinal + 1) shl 5) or local.day + val time = (local.hour shl 11) or (local.minute shl 5) or (local.second / 2) + return date to time +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.ios.kt new file mode 100644 index 00000000..4a6d4d4d --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.ios.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.domain.enums + +import platform.Foundation.NSString +import platform.Foundation.localizedCaseInsensitiveCompare + +internal actual fun titleCollator(): Comparator = + Comparator { a, b -> (a as NSString).localizedCaseInsensitiveCompare(b).toInt() } diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/HostViewController.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/HostViewController.kt new file mode 100644 index 00000000..d3f23532 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/HostViewController.kt @@ -0,0 +1,43 @@ +@file:OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) + +package com.pasich.encly.ios + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ExportObjCClass +import platform.UIKit.UIStatusBarStyle +import platform.UIKit.UIStatusBarStyleDarkContent +import platform.UIKit.UIStatusBarStyleLightContent +import platform.UIKit.UIViewAutoresizingFlexibleHeight +import platform.UIKit.UIViewAutoresizingFlexibleWidth +import platform.UIKit.UIViewController +import platform.UIKit.addChildViewController + +/** + * The root of the app: a plain view controller that holds the Compose view controller full-screen and decides the + * style of the status bar for it, which Compose does not (the page behind the bar is light or dark by Encly's + * theme, not by the system's). + */ +@ExportObjCClass +internal class HostViewController(private val content: UIViewController) : + UIViewController(nibName = null, bundle = null) { + private var darkContent = false + + override fun viewDidLoad() { + super.viewDidLoad() + addChildViewController(content) + content.view.setFrame(view.bounds) + content.view.autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight + view.addSubview(content.view) + } + + override fun preferredStatusBarStyle(): UIStatusBarStyle = + if (darkContent) UIStatusBarStyleDarkContent else UIStatusBarStyleLightContent + + /** Dark icons for a light page when [dark] is true. */ + fun setDarkStatusBarContent(dark: Boolean) { + if (darkContent == dark) return + darkContent = dark + setNeedsStatusBarAppearanceUpdate() + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt new file mode 100644 index 00000000..11260b62 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt @@ -0,0 +1,116 @@ +package com.pasich.encly.ios + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.pasich.encly.core.di.platformIoDispatcher +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.ios.di.IosAppGraph +import com.pasich.encly.ios.platform.SensitiveClipboardBoundary +import com.pasich.encly.ios.platform.ToastHost +import com.pasich.encly.ios.security.IosBiometricHost +import com.pasich.encly.platform.AppWindow +import com.pasich.encly.platform.LocalAppInfo +import com.pasich.encly.platform.LocalAppLocales +import com.pasich.encly.platform.LocalAppWindow +import com.pasich.encly.platform.LocalBiometricHost +import com.pasich.encly.platform.LocalDocumentPickers +import com.pasich.encly.platform.LocalExternalLinks +import com.pasich.encly.platform.LocalKeyboardPrivacyOffered +import com.pasich.encly.platform.LocalSensitiveClipboard +import com.pasich.encly.platform.LocalToaster +import com.pasich.encly.platform.ScreenBiometricHost +import com.pasich.encly.presentation.navigation.EnclyApp +import com.pasich.encly.ui.theme.AppTheme +import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import kotlinx.coroutines.withContext +import kotlinx.io.IOException + +/** + * The whole app inside the Compose view controller: what `MainActivity` does on Android and `DesktopApp` on the + * desktop. It resolves the vault's start state off the main thread, gives the shared UI its platform services, and + * draws [EnclyApp] and the in-window messages. + */ +@Composable +internal fun IosApp(graph: IosAppGraph, appWindow: AppWindow, modifier: Modifier = Modifier) { + var startStatus by remember(graph) { mutableStateOf(null) } + LaunchedEffect(graph) { + val status = withContext(platformIoDispatcher) { + // Never throws: unreadable vault state routes to the damaged-vault screen. + val resolved = graph.securityManager.resolveInitialStatus() + try { + graph.settingsRepository.loadThemeSettings(existingInstall = !graph.securityManager.isOnboardingShow()) + } catch (_: IOException) { + // An unreadable settings file: the theme starts from its defaults. + } + resolved + } + // A committed vault that is not open starts locked, before the first frame. + if (status == InitialStatus.AUTH) graph.sessionLockManager.requireUnlock() + // The system biometric prompt is built outside any coroutine: give it finished texts first. + graph.biometricTexts.refresh() + startStatus = status + } + + IosHostServices(graph, appWindow) { + // iOS cannot switch the language of a running app, but a new key makes every screen read its strings again. + val revision by graph.locales.revision.collectAsState() + Box(modifier.fillMaxSize()) { + key(revision) { + EnclyApp( + startStatus = startStatus, + securityManager = graph.securityManager, + sessionLockManager = graph.sessionLockManager, + ) + } + AppTheme { + ToastHost(graph.toaster, Modifier.align(Alignment.BottomCenter).padding(EnclyTheme.spacing.m)) + } + } + } +} + +/** Hands the shared UI what it needs from iOS, once, around its whole content. */ +@Composable +internal fun IosHostServices(graph: IosAppGraph, appWindow: AppWindow, content: @Composable () -> Unit) { + val viewModelStoreOwner = remember { + object : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + } + DisposableEffect(viewModelStoreOwner) { onDispose { viewModelStoreOwner.viewModelStore.clear() } } + val biometricHost = remember(graph) { ScreenBiometricHost(IosBiometricHost, graph.lifecycle) } + CompositionLocalProvider( + LocalViewModelStoreOwner provides viewModelStoreOwner, + LocalLifecycleOwner provides graph.lifecycle, + LocalMetroViewModelFactory provides graph.metroViewModelFactory, + LocalAppWindow provides appWindow, + LocalExternalLinks provides graph.externalLinks, + LocalToaster provides graph.toaster, + LocalSensitiveClipboard provides graph.clipboard, + LocalBiometricHost provides biometricHost, + LocalAppLocales provides graph.locales, + LocalAppInfo provides graph.appInfo, + LocalKeyboardPrivacyOffered provides false, + LocalDocumentPickers provides graph.pickers, + ) { + SensitiveClipboardBoundary(graph.clipboardPolicy, content) + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/MainViewController.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/MainViewController.kt new file mode 100644 index 00000000..737f66c1 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/MainViewController.kt @@ -0,0 +1,92 @@ +package com.pasich.encly.ios + +import androidx.compose.ui.window.ComposeUIViewController +import com.pasich.encly.core.keystore.BiometricKeychain +import com.pasich.encly.core.keystore.IosPinFactor +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.ios.di.DataFiles +import com.pasich.encly.ios.di.IosAppGraph +import com.pasich.encly.ios.di.IosEnvironment +import com.pasich.encly.ios.platform.IosAppWindow +import com.pasich.encly.ios.security.IosKeyboardPrivacy +import com.pasich.encly.ios.storage.IosDataDirectory +import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import platform.UIKit.UIViewController + +/** + * The one entry point of the iOS app: the root view controller that the Xcode project's SwiftUI shell shows. + * It hosts [IosApp] and creates the process-wide graph on first use (on the main thread). + */ +// Named as the Swift call site reads it, `MainViewControllerKt.MainViewController()`: the Compose template convention. +@Suppress("FunctionNaming", "ktlint:standard:function-naming") +public fun MainViewController(): UIViewController { + val graph = IosHost.graph + var host: HostViewController? = null + val window = IosAppWindow( + enforceShield = graph.shield::enforce, + view = { host?.view }, + statusBar = { darkContent -> host?.setDarkStatusBarContent(darkContent) }, + ) + val content = ComposeUIViewController { IosApp(graph, window) } + return HostViewController(content).also { host = it } +} + +/** The long-lived parts of the process: the data directory, the graph and the lock sources. */ +internal object IosHost { + val graph: IosAppGraph by lazy { create() } + + private fun create(): IosAppGraph { + val dataDirectory = IosDataDirectory.prepare() + val pinFactor = IosPinFactor.create() + dropOrphanedKeychainItems(dataDirectory, pinFactor) + val environment = IosEnvironment(dataDirectory, pinFactor) + val graph = createGraphFactory().create(environment) + start(graph) + return graph + } + + /** + * Keychain items outlive an uninstall (the app's data does not): with no vault file there is no vault they + * could belong to, so the PIN device secrets and the biometric key of an earlier install are removed. Android + * loses its Keystore keys with the app; this gives a reinstall the same clean start. + */ + private fun dropOrphanedKeychainItems(dataDirectory: String, pinFactor: PinHardwareFactor) { + if (SystemFileSystem.exists(Path("$dataDirectory/${DataFiles.VAULT_STATE}"))) return + PinKeySlot.entries.forEach(pinFactor::delete) + BiometricKeychain().delete() + } + + private fun start(graph: IosAppGraph) { + graph.locales.syncAppleLanguages() + // Every text input asks the keyboard not to learn from what is typed; before any field can be focused. + IosKeyboardPrivacy.install() + // The cover goes up before anything else can be drawn or snapshotted. + graph.shield.install() + // SessionLockManager follows the application: it is told when the app is left and when it is back. + graph.lifecycle.lifecycle.addObserver(graph.sessionLockManager) + // iOS suspends the app soon after it is stopped: a short background task keeps the auto-lock timer and the + // device-lock signal working, and closes the vault when iOS takes the task back. + graph.lifecycle.lifecycle.addObserver(graph.backgroundLockGuard) + // The device locking locks the vault, whether Encly is in front or not. + graph.deviceLockWatcher.onLocked { graph.sessionLockManager.lockNow() } + // Pick grants and imported copies do not outlive the unlocked session they were made in. + graph.pickedCopies.purge() + graph.applicationScope.launch { + graph.sessionLockManager.locked.collect { locked -> + if (locked) { + graph.scopes.releaseAll() + graph.pickedCopies.purge() + withContext(Dispatchers.Main) { graph.backgroundLockGuard.end() } + } + } + } + // A My Notes hand-off (plaintext) that a killed process left in the cache; off the main thread. + graph.applicationScope.launch { graph.handoffStagingSweeper.sweep() } + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosAppGraph.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosAppGraph.kt new file mode 100644 index 00000000..b493bed2 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosAppGraph.kt @@ -0,0 +1,104 @@ +package com.pasich.encly.ios.di + +import com.pasich.encly.core.di.ApplicationScope +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.security.AutoLock +import com.pasich.encly.core.security.AutoLockPolicy +import com.pasich.encly.core.security.HandoffStagingSweeper +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.data.backup.RoomVaultDataStore +import com.pasich.encly.data.backup.VaultDataStore +import com.pasich.encly.data.database.DatabaseProvider +import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.domain.repository.SettingsRepository +import com.pasich.encly.ios.platform.IosAppLocaleController +import com.pasich.encly.ios.platform.IosClipboardPolicy +import com.pasich.encly.ios.platform.IosDocumentPickers +import com.pasich.encly.ios.platform.IosToaster +import com.pasich.encly.ios.platform.PickedCopies +import com.pasich.encly.ios.platform.SecurityScopes +import com.pasich.encly.ios.security.BackgroundLockGuard +import com.pasich.encly.ios.security.IosAppLifecycle +import com.pasich.encly.ios.security.IosBiometricTexts +import com.pasich.encly.ios.security.IosDeviceLockWatcher +import com.pasich.encly.ios.security.PrivacyShield +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.ExternalLinks +import com.pasich.encly.platform.PlatformDocuments +import com.pasich.encly.platform.SensitiveClipboard +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ViewModelGraph +import kotlinx.coroutines.CoroutineScope + +/** + * The one dependency graph of the iOS process: the counterpart of the Android `AppGraph` and the desktop graph. + * The bindings of the shared code (`@ContributesTo(AppScope)` and `@ContributesIntoMap` in `:shared` and the + * `:core` modules) come in by themselves; [IosPlatformModule], [IosServicesModule] and [IosBindingsModule] add + * what iOS provides. + * + * Like the desktop, iOS creates every object itself, so the entry points are plain accessors: the host reads what + * it needs from here once. + */ +@DependencyGraph(AppScope::class) +internal interface IosAppGraph : ViewModelGraph { + val securityManager: SecurityManager + val sessionLockManager: SessionLockManager + val settingsRepository: SettingsRepository + val autoLockPolicy: AutoLockPolicy + val handoffStagingSweeper: HandoffStagingSweeper + val flags: KeyValueStore + + @get:ApplicationScope + val applicationScope: CoroutineScope + + val environment: IosEnvironment + val lifecycle: IosAppLifecycle + val deviceLockWatcher: IosDeviceLockWatcher + val shield: PrivacyShield + val backgroundLockGuard: BackgroundLockGuard + val biometricTexts: IosBiometricTexts + val toaster: IosToaster + val clipboardPolicy: IosClipboardPolicy + val locales: IosAppLocaleController + val externalLinks: ExternalLinks + val clipboard: SensitiveClipboard + val appInfo: AppInfo + val documents: PlatformDocuments + val pickers: IosDocumentPickers + val scopes: SecurityScopes + val pickedCopies: PickedCopies + + @DependencyGraph.Factory + fun interface Factory { + fun create(@Provides environment: IosEnvironment): IosAppGraph + } +} + +/** The interface bindings `:app` declares in `DatabaseModule` and `SecurityBindingsModule`, for the iOS graph. */ +@ContributesTo(AppScope::class) +@BindingContainer +internal interface IosBindingsModule { + // SecureDatabaseManager is itself a singleton, so this binding is one instance too. + @Binds + fun bindDatabaseProvider(secureDatabaseManager: SecureDatabaseManager): DatabaseProvider + + @Binds + fun bindVaultDataStore(store: RoomVaultDataStore): VaultDataStore + + @Binds + fun bindVaultLockEvents(manager: SessionLockManager): VaultLockEvents + + @Binds + fun bindAutoLockPolicy(autoLock: AutoLock): AutoLockPolicy +} + +/** What the host gives the graph: the places and the Keychain-backed PIN factor. */ +internal class IosEnvironment(val dataDirectory: String, val pinFactor: PinHardwareFactor) diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosModules.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosModules.kt new file mode 100644 index 00000000..0286bb23 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosModules.kt @@ -0,0 +1,228 @@ +package com.pasich.encly.ios.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.pasich.encly.core.common.AppStrings +import com.pasich.encly.core.common.ComposeAppStrings +import com.pasich.encly.core.keystore.BiometricKeychain +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.core.security.BiometricManager +import com.pasich.encly.core.security.DeviceLockWatcher +import com.pasich.encly.core.security.HandoffStagingSweeper +import com.pasich.encly.core.security.IosVaultFileIo +import com.pasich.encly.core.security.LockoutClock +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.core.security.VaultStore +import com.pasich.encly.data.database.DatabasePlatform +import com.pasich.encly.data.database.IosDatabasePlatform +import com.pasich.encly.data.handoff.HandoffStaging +import com.pasich.encly.ios.platform.IosAppInfo +import com.pasich.encly.ios.platform.IosAppLocaleController +import com.pasich.encly.ios.platform.IosCacheDirectory +import com.pasich.encly.ios.platform.IosClipboardPolicy +import com.pasich.encly.ios.platform.IosDeviceCapabilities +import com.pasich.encly.ios.platform.IosDocumentPickers +import com.pasich.encly.ios.platform.IosDocuments +import com.pasich.encly.ios.platform.IosExternalLinks +import com.pasich.encly.ios.platform.IosSensitiveClipboard +import com.pasich.encly.ios.platform.IosToaster +import com.pasich.encly.ios.platform.PickedCopies +import com.pasich.encly.ios.platform.SecurityScopes +import com.pasich.encly.ios.security.BackgroundLockGuard +import com.pasich.encly.ios.security.IosAppLifecycle +import com.pasich.encly.ios.security.IosBiometricManager +import com.pasich.encly.ios.security.IosBiometricTexts +import com.pasich.encly.ios.security.IosDeviceLockWatcher +import com.pasich.encly.ios.security.IosLockoutClock +import com.pasich.encly.ios.security.IosMainThreadTimer +import com.pasich.encly.ios.security.PrivacyShield +import com.pasich.encly.ios.storage.IosKeyValueStore +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.AppLocaleController +import com.pasich.encly.platform.CacheDirectory +import com.pasich.encly.platform.ExternalLinks +import com.pasich.encly.platform.PlatformDocuments +import com.pasich.encly.platform.SensitiveClipboard +import com.pasich.encly.platform.Toaster +import com.pasich.encly.utils.DeviceCapabilities +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.io.files.Path +import okio.Path.Companion.toPath + +/** File names inside the data directory. The first three are the ones the Android app uses, by the same names. */ +internal object DataFiles { + const val VAULT_STATE = "vault_state_v3.bin" + const val FLAGS = "encly_flags_v3.json" + const val SETTINGS = "my_notes_settings.preferences_pb" + const val CACHE_DIR = "cache" + const val PICKED_DIR = "picked" + const val EXPORT_DIR = "export" +} + +/** + * Every binding iOS makes for what `:app` binds on Android, in one place. The [IosEnvironment] is the only input of + * the graph. + * + * | Android (`:app`) | iOS | + * |-------------------------------------------|-------------------------------------------------------| + * | KeystorePinFactor | `environment.pinFactor` (`IosPinFactor`, Enclave) | + * | KeystoreBiometricManager | [IosBiometricManager] (Keychain + biometryCurrentSet) | + * | HandlerMainThreadTimer | [IosMainThreadTimer] | + * | SystemLockoutClock | [IosLockoutClock] (continuous clock, boot session) | + * | SystemDeviceLockWatcher | [IosDeviceLockWatcher] (protected data) | + * | VaultStore in `no_backup` | [VaultStore] in the data dir, excluded from backups | + * | SharedPreferences flags | [IosKeyValueStore] | + * | AndroidDatabasePlatform | `IosDatabasePlatform` in the data directory | + * | preferencesDataStore | `PreferenceDataStoreFactory` file in the data dir | + * | AndroidDocuments, AndroidToaster, ... | the `Ios*` services of `ios.platform` | + */ +@ContributesTo(AppScope::class) +@BindingContainer +@Suppress("TooManyFunctions") // One binding per platform service. +internal object IosPlatformModule { + @Provides + @SingleIn(AppScope::class) + fun provideVaultStore(environment: IosEnvironment): VaultStore = + VaultStore(IosVaultFileIo("${environment.dataDirectory}/${DataFiles.VAULT_STATE}")) + + @Provides + @SingleIn(AppScope::class) + fun provideFlags(environment: IosEnvironment): KeyValueStore = + IosKeyValueStore("${environment.dataDirectory}/${DataFiles.FLAGS}") + + @Provides + @SingleIn(AppScope::class) + fun provideDatabasePlatform(environment: IosEnvironment): DatabasePlatform = + IosDatabasePlatform(Path(environment.dataDirectory)) + + @Provides + @SingleIn(AppScope::class) + fun provideSettingsStore(environment: IosEnvironment): DataStore = + PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { "${environment.dataDirectory}/${DataFiles.SETTINGS}".toPath() }, + ) + + @Provides + fun providePinHardwareFactor(environment: IosEnvironment): PinHardwareFactor = environment.pinFactor + + @Provides + @SingleIn(AppScope::class) + fun provideBiometricTexts(): IosBiometricTexts = IosBiometricTexts() + + @Provides + @SingleIn(AppScope::class) + fun provideBiometricManager(store: VaultStore, texts: IosBiometricTexts): BiometricManager = + IosBiometricManager(store, BiometricKeychain(), texts) + + @Provides + @SingleIn(AppScope::class) + fun provideMainThreadTimer(): MainThreadTimer = IosMainThreadTimer() + + @Provides + @SingleIn(AppScope::class) + fun provideLockoutClock(): LockoutClock = IosLockoutClock() + + @Provides + @SingleIn(AppScope::class) + fun provideIosDeviceLockWatcher(): IosDeviceLockWatcher = IosDeviceLockWatcher() + + @Provides + fun provideDeviceLockWatcher(watcher: IosDeviceLockWatcher): DeviceLockWatcher = watcher + + @Provides + @SingleIn(AppScope::class) + fun provideLifecycle(watcher: IosDeviceLockWatcher): IosAppLifecycle = + IosAppLifecycle(onForeground = watcher::appReturned) + + @Provides + @SingleIn(AppScope::class) + fun provideShield(): PrivacyShield = PrivacyShield() + + @Provides + @SingleIn(AppScope::class) + fun provideBackgroundLockGuard(security: SecurityManager, locks: SessionLockManager): BackgroundLockGuard = + BackgroundLockGuard(isVaultOpen = security::isDatabaseUnlocked, lockNow = locks::lockNow) + + @Provides + @SingleIn(AppScope::class) + fun provideAppStrings(): AppStrings = ComposeAppStrings() + + @Provides + @SingleIn(AppScope::class) + fun provideHandoffStagingSweeper(cache: CacheDirectory): HandoffStagingSweeper = + HandoffStagingSweeper { HandoffStaging.sweep(Path(cache.path, HandoffStaging.DIR_NAME)) } +} + +/** The platform services of `:shared` (`com.pasich.encly.platform`) and the host's own helpers. */ +@ContributesTo(AppScope::class) +@BindingContainer +@Suppress("TooManyFunctions") // One binding per platform service. +internal object IosServicesModule { + @Provides + @SingleIn(AppScope::class) + fun provideSecurityScopes(): SecurityScopes = SecurityScopes() + + @Provides + @SingleIn(AppScope::class) + fun providePickedCopies(cache: CacheDirectory): PickedCopies = PickedCopies(Path(cache.path, DataFiles.PICKED_DIR)) + + @Provides + @SingleIn(AppScope::class) + fun provideDocuments(): PlatformDocuments = IosDocuments() + + @Provides + @SingleIn(AppScope::class) + fun providePickers(scopes: SecurityScopes, copies: PickedCopies, cache: CacheDirectory): IosDocumentPickers = + IosDocumentPickers(scopes, copies, Path(cache.path, DataFiles.EXPORT_DIR)) + + @Provides + @SingleIn(AppScope::class) + fun provideExternalLinks(): ExternalLinks = IosExternalLinks() + + @Provides + @SingleIn(AppScope::class) + fun provideToaster(): IosToaster = IosToaster() + + @Provides + fun provideToasterInterface(toaster: IosToaster): Toaster = toaster + + @Provides + @SingleIn(AppScope::class) + fun provideClipboardPolicy(timer: MainThreadTimer): IosClipboardPolicy = IosClipboardPolicy(timer) + + @Provides + @SingleIn(AppScope::class) + fun provideClipboard(policy: IosClipboardPolicy): SensitiveClipboard = IosSensitiveClipboard(policy) + + @Provides + @SingleIn(AppScope::class) + fun provideLocales(flags: KeyValueStore): IosAppLocaleController = IosAppLocaleController(flags) + + @Provides + fun provideLocaleController(controller: IosAppLocaleController): AppLocaleController = controller + + @Provides + @SingleIn(AppScope::class) + fun provideAppInfo(): AppInfo = IosAppInfo() + + @Provides + @SingleIn(AppScope::class) + fun provideCacheDirectory(environment: IosEnvironment): CacheDirectory = + IosCacheDirectory(Path(environment.dataDirectory, DataFiles.CACHE_DIR)) + + @Provides + fun provideDeviceCapabilities(): DeviceCapabilities = IosDeviceCapabilities +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosClipboard.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosClipboard.kt new file mode 100644 index 00000000..6f034231 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosClipboard.kt @@ -0,0 +1,126 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.ios.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.NativeClipboard +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.platform.SensitiveClipboard +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDate +import platform.Foundation.dateWithTimeIntervalSinceNow +import platform.UIKit.UIPasteboard +import platform.UIKit.UIPasteboardOptionExpirationDate +import platform.UIKit.UIPasteboardOptionLocalOnly + +/** + * Encly's clipboard policy on iOS, the one Android applies: what the app copies out is marked sensitive, and is + * cleared after [CLEAR_AFTER_SECONDS] unless something else was copied since. + * - "Sensitive": the item is `localOnly`, so it does not travel to the user's other devices through Universal + * Clipboard, and has an `expirationDate`, so the system removes it itself when the time is up, even if the app + * no longer runs. (iOS shows no copy preview and has no suggestion flag to set; the paste prompt of iOS 16+ + * only asks other apps to be allowed to read it.) + * - "Unless something else was copied since": expiry only affects the items it was set on; and a timer in the app + * also empties the pasteboard if its change count is still the one Encly's copy made. + * + * Limit: a clipboard manager or a keyboard the user installed that reads the pasteboard while the clip is there + * may keep a copy, as with the Android and desktop clipboards. + */ +internal class IosClipboardPolicy(private val timer: MainThreadTimer) { + private var armedChange: Long = NONE + private var cancel: (() -> Unit)? = null + + /** Puts [text] on the pasteboard as Encly's sensitive clip. */ + fun copy(text: String) { + val board = UIPasteboard.generalPasteboard + board.setItems( + listOf(mapOf(PLAIN_TEXT to text)), + options = mapOf( + UIPasteboardOptionLocalOnly to true, + UIPasteboardOptionExpirationDate to NSDate.dateWithTimeIntervalSinceNow(CLEAR_AFTER_SECONDS), + ), + ) + arm(board) + } + + /** Empties the pasteboard now, whatever it holds. */ + fun clearNow() { + cancel?.invoke() + cancel = null + armedChange = NONE + UIPasteboard.generalPasteboard.setItems(emptyList(), options = emptyMap()) + } + + private fun arm(board: UIPasteboard) { + cancel?.invoke() + armedChange = board.changeCount + cancel = timer.schedule(CLEAR_AFTER_SECONDS.toLong() * MILLIS, ::wipeIfStillHeld) + } + + /** Empties the pasteboard if it is still the clip Encly made. */ + fun wipeIfStillHeld() { + val armed = armedChange + cancel = null + armedChange = NONE + if (armed == NONE) return + val board = UIPasteboard.generalPasteboard + if (board.changeCount == armed) board.setItems(emptyList(), options = emptyMap()) + } + + companion object { + const val CLEAR_AFTER_SECONDS = 60.0 + private const val MILLIS = 1000L + private const val NONE = -1L + private const val PLAIN_TEXT = "public.utf8-plain-text" + } +} + +/** [SensitiveClipboard] over the pasteboard, by [IosClipboardPolicy]. */ +internal class IosSensitiveClipboard(private val policy: IosClipboardPolicy) : SensitiveClipboard { + // iOS shows nothing when something is copied: the app says so itself. + override val systemConfirmsCopy: Boolean = false + + override fun copy(label: String, text: String): Boolean { + policy.copy(text) + return true + } + + override fun clear() = policy.clearNow() +} + +/** + * A [Clipboard] that applies [IosClipboardPolicy] to everything the Compose text fields copy (the copy and cut of + * the edit menu), so the policy covers the editor as well as the explicit copy buttons. The text goes to the + * pasteboard once, as the local-only, expiring item: the platform clipboard is never asked to write it first, so + * there is no moment at which the general pasteboard holds the plain item that Universal Clipboard could pick up. + * An entry with no text is dropped: nothing in Encly copies anything else. + */ +@OptIn(ExperimentalComposeUiApi::class) +internal class SensitiveComposeClipboard(private val delegate: Clipboard, private val policy: IosClipboardPolicy) : + Clipboard { + override val nativeClipboard: NativeClipboard get() = delegate.nativeClipboard + + override suspend fun getClipEntry(): ClipEntry? = delegate.getClipEntry() + + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + if (clipEntry == null) { + policy.clearNow() + } else if (clipEntry.hasPlainText()) { + clipEntry.getPlainText()?.let(policy::copy) + } + } +} + +/** Routes every Compose copy under [content] through [policy]. */ +@Composable +internal fun SensitiveClipboardBoundary(policy: IosClipboardPolicy, content: @Composable () -> Unit) { + val platform = LocalClipboard.current + val clipboard = remember(platform, policy) { SensitiveComposeClipboard(platform, policy) } + CompositionLocalProvider(LocalClipboard provides clipboard, content = content) +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocumentPickers.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocumentPickers.kt new file mode 100644 index 00000000..8de9e2fc --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocumentPickers.kt @@ -0,0 +1,154 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.ios.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import com.pasich.encly.platform.DocumentPickers +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.PickerLauncher +import com.pasich.encly.platform.PickerUnavailableException +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUUID +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UISceneActivationStateForegroundActive +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowLevelNormal +import platform.UIKit.UIWindowScene +import platform.UniformTypeIdentifiers.UTTypeFolder +import platform.UniformTypeIdentifiers.UTTypeItem +import platform.darwin.NSObject + +/** + * The document dialogs of iOS: `UIDocumentPickerViewController`, the Files app's browser (iCloud Drive, "On My + * iPhone" and any provider the user has). + * - "Open": the system copies the chosen files into the app (`asCopy`), which also makes it download an iCloud file + * that is not on the device yet; the copies are moved into [copies], a folder of the protected cache that is + * emptied when the vault locks, when the app starts and before the next pick. + * - "Save as": there is no create-with-a-name dialog on iOS. An empty file of the suggested name is exported with + * `asCopy`, the user picks the folder, and the file at the chosen place is what the app then writes into. + * - "Choose a folder": the folder is read in place under a security scope that [scopes] ends with the others. + * The MIME types an Android caller offers are not used to filter: iOS has no type for an Encly backup, so the + * dialog offers every file. + */ +internal class IosDocumentPickers( + private val scopes: SecurityScopes, + private val copies: PickedCopies, + private val exportDirectory: Path, +) : DocumentPickers { + @Composable + override fun rememberCreateDocument(mimeType: String, onPick: (DocumentRef?) -> Unit): PickerLauncher { + val callback by rememberUpdatedState(onPick) + return remember { PickerLauncher { name -> exportPlaceholder(name) { callback(it) } } } + } + + @Composable + override fun rememberOpenDocument(onPick: (DocumentRef?) -> Unit): PickerLauncher> { + val callback by rememberUpdatedState(onPick) + return remember { PickerLauncher> { openFiles(multiple = false) { callback(it.firstOrNull()) } } } + } + + @Composable + override fun rememberOpenDocuments(onPick: (List) -> Unit): PickerLauncher> { + val callback by rememberUpdatedState(onPick) + return remember { PickerLauncher> { openFiles(multiple = true) { callback(it) } } } + } + + @Composable + override fun rememberOpenFolder(onPick: (DocumentRef?) -> Unit): PickerLauncher { + val callback by rememberUpdatedState(onPick) + return remember { PickerLauncher { openFolder { callback(it) } } } + } + + private fun openFiles(multiple: Boolean, done: (List) -> Unit) { + releaseEarlierPicks() + val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = true) + picker.allowsMultipleSelection = multiple + present(picker) { urls -> + val folder = copies.newFolder() + done(urls.mapNotNull { copies.adopt(it, folder) }.map(::DocumentRef)) + } + } + + private fun openFolder(done: (DocumentRef?) -> Unit) { + releaseEarlierPicks() + val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false) + picker.allowsMultipleSelection = false + present(picker) { urls -> + val folder = urls.firstOrNull() + if (folder != null) scopes.retain(folder) + done(folder?.path?.let(::DocumentRef)) + } + } + + private fun exportPlaceholder(suggestedName: String, done: (DocumentRef?) -> Unit) { + releaseEarlierPicks() + val folder = Path(exportDirectory, NSUUID().UUIDString) + SystemFileSystem.createDirectories(folder) + val placeholder = Path(folder, safeFileName(suggestedName)) + NSFileManager.defaultManager.createFileAtPath(placeholder.toString(), null, null) + val url = NSURL.fileURLWithPath(placeholder.toString()) + val picker = UIDocumentPickerViewController(forExportingURLs = listOf(url), asCopy = true) + present(picker) { urls -> + val destination = urls.firstOrNull() + if (destination != null) scopes.retain(destination) + runCatching { SystemFileSystem.delete(placeholder, mustExist = false) } + runCatching { SystemFileSystem.delete(folder, mustExist = false) } + done(destination?.path?.let(::DocumentRef)) + } + } + + private fun releaseEarlierPicks() { + scopes.releaseAll() + copies.purge() + } + + private fun present(picker: UIDocumentPickerViewController, onPicked: (List) -> Unit) { + val presenter = topViewController() ?: throw PickerUnavailableException() + val delegate = PickerDelegate(onPicked) + picker.delegate = delegate + // The picker holds its delegate weakly: keep it alive until the dialog is over. + delegates += delegate + presenter.presentViewController(picker, animated = true, completion = null) + } + + private val delegates = mutableListOf() + + private inner class PickerDelegate(private val onPicked: (List) -> Unit) : + NSObject(), + UIDocumentPickerDelegateProtocol { + override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { + delegates -= this + onPicked(didPickDocumentsAtURLs.filterIsInstance()) + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + delegates -= this + onPicked(emptyList()) + } + } + + private fun safeFileName(name: String): String = + name.map { if (it == '/' || it == ':' || it.isISOControl()) '_' else it }.joinToString("").ifBlank { "Encly" } +} + +/** The view controller a sheet can be presented from: the top of the foreground scene's normal window. */ +internal fun topViewController(): UIViewController? { + val scenes = UIApplication.sharedApplication.connectedScenes.filterIsInstance() + val scene = + scenes.firstOrNull { it.activationState == UISceneActivationStateForegroundActive } ?: scenes.firstOrNull() + val window = scene?.windows?.filterIsInstance() + ?.firstOrNull { it.windowLevel == UIWindowLevelNormal && !it.hidden } + var top = window?.rootViewController + while (top?.presentedViewController != null) top = top.presentedViewController + return top +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocuments.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocuments.kt new file mode 100644 index 00000000..58d386f2 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocuments.kt @@ -0,0 +1,168 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.ios.platform + +import com.pasich.encly.data.importer.DocumentTree +import com.pasich.encly.data.importer.TreeNode +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.PlatformDocuments +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.io.IOException +import kotlinx.io.RawSink +import kotlinx.io.RawSource +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.posix.S_IFLNK +import platform.posix.S_IFMT +import platform.posix.lstat +import platform.posix.stat + +/** + * [PlatformDocuments] over the file system. A document is named by its path: the system's document picker either + * copies what the user picks into the app (a file to open), gives the app a destination it may write (a file to + * save) or lets it read a folder in place (the access of a picked folder is held by [SecurityScopes] until released). + * Reading a missing or unreadable file is an [IOException], like a refused grant on Android. Folders are walked + * without following symbolic links, so a link inside a picked folder can never lead the import to a file outside it. + */ +internal class IosDocuments : PlatformDocuments { + override fun openRead(doc: DocumentRef): RawSource = SystemFileSystem.source(Path(doc.value)) + + // Replaces the content, as Android's "wt" mode does; creates the file if the picker did not. + override fun openWrite(doc: DocumentRef): RawSink = SystemFileSystem.sink(Path(doc.value), append = false) + + override fun displayName(doc: DocumentRef): String? = doc.value.substringAfterLast('/').takeIf { it.isNotEmpty() } + + override fun delete(doc: DocumentRef) { + runCatching { SystemFileSystem.delete(Path(doc.value), mustExist = false) } + } + + override fun openTree(root: DocumentRef, maxEntries: Int): DocumentTree { + val base = runCatching { SystemFileSystem.resolve(Path(root.value)) }.getOrNull() + ?: throw IOException("not a folder") + return FolderTree(base, maxEntries) + } + + private class FolderTree(private val root: Path, private val maxEntries: Int) : DocumentTree { + override val rootId: String = root.toString() + + override fun children(parentId: String): List { + val parent = Path(parentId) + if (isSymbolicLink(parentId) || SystemFileSystem.metadataOrNull(parent)?.isDirectory != true) { + return emptyList() + } + return try { + SystemFileSystem.list(parent) + .asSequence() + .filter { !isSymbolicLink(it.toString()) } + .take(maxEntries) + .map { TreeNode(it.toString(), it.name, SystemFileSystem.metadataOrNull(it)?.isDirectory == true) } + .toList() + } catch (_: IOException) { + emptyList() + } + } + + override fun open(id: String): RawSource? { + val file = Path(id) + val inside = runCatching { SystemFileSystem.resolve(file).toString() }.getOrNull() + ?.startsWith(root.toString() + "/") == true + if (!inside || isSymbolicLink(id) || SystemFileSystem.metadataOrNull(file)?.isRegularFile != true) { + return null + } + return try { + SystemFileSystem.source(file) + } catch (_: IOException) { + null + } + } + } +} + +/** True when [path] itself (not what it points to) is a symbolic link. */ +private fun isSymbolicLink(path: String): Boolean = memScoped { + val info = alloc() + lstat(path, info.ptr) == 0 && (info.st_mode.toInt() and S_IFMT) == S_IFLNK +} + +/** + * The security-scoped access that the document picker grants to a folder or a destination file for as long as the + * app holds it. [retain] starts it; [releaseAll] ends it, which the host does when the app leaves the screen, the + * vault locks, or another picker is opened, so a grant never outlives the operation it was given for. + */ +internal class SecurityScopes { + private val lock = SynchronizedObject() + private val held = mutableListOf() + + fun retain(url: NSURL) { + if (url.startAccessingSecurityScopedResource()) synchronized(lock) { held += url } + } + + fun releaseAll() { + val urls = synchronized(lock) { held.toList().also { held.clear() } } + urls.forEach { it.stopAccessingSecurityScopedResource() } + } +} + +/** + * Copies the document picker made into the app (a file to import), kept in a folder of the cache that is deleted + * as soon as the host says so (at start, when the vault locks and when another picker opens): an imported plaintext + * file does not stay on the device because the user forgot it. + */ +internal class PickedCopies(private val directory: Path) { + /** A fresh folder for one pick. */ + fun newFolder(): Path { + val folder = Path(directory, randomName()) + SystemFileSystem.createDirectories(folder) + return folder + } + + /** + * Moves [source] (a file the system copied for the app) into [folder]; the new path. A copy that cannot be moved + * would stay where [purge] never looks, so it is deleted and the pick is refused (null). + */ + @Suppress("ReturnCount") // Guards: a name and a path are needed. + fun adopt(source: NSURL, folder: Path): String? { + val sourcePath = source.path ?: return null + val name = source.lastPathComponent + if (name == null) { + discard(sourcePath) + return null + } + return adopt(sourcePath, name, folder) + } + + internal fun adopt(sourcePath: String, name: String, folder: Path): String? { + val target = Path(folder, name).toString() + if (NSFileManager.defaultManager.moveItemAtPath(sourcePath, target, null)) return target + discard(sourcePath) + return null + } + + private fun discard(path: String) { + NSFileManager.defaultManager.removeItemAtPath(path, null) + } + + fun purge() { + runCatching { + if (SystemFileSystem.exists(directory)) { + SystemFileSystem.list(directory).forEach { deleteTree(it) } + } + } + } + + private fun deleteTree(path: Path) { + if (SystemFileSystem.metadataOrNull(path)?.isDirectory == true && !isSymbolicLink(path.toString())) { + SystemFileSystem.list(path).forEach(::deleteTree) + } + SystemFileSystem.delete(path, mustExist = false) + } + + private fun randomName(): String = platform.Foundation.NSUUID().UUIDString +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosHostServices.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosHostServices.kt new file mode 100644 index 00000000..2c4ead7e --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosHostServices.kt @@ -0,0 +1,194 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.ios.platform + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.platform.AppLocaleController +import com.pasich.encly.platform.AppWindow +import com.pasich.encly.platform.CacheDirectory +import com.pasich.encly.platform.ExternalLinks +import com.pasich.encly.platform.ToastDuration +import com.pasich.encly.platform.Toaster +import com.pasich.encly.utils.DeviceCapabilities +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.io.files.Path +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import platform.Foundation.NSBundle +import platform.Foundation.NSURL +import platform.Foundation.NSUserDefaults +import platform.UIKit.UIApplication +import platform.UIKit.UIColor +import platform.UIKit.UIView +import platform.UIKit.endEditing + +/** + * [ExternalLinks] that hands an address to iOS. Only `https`, `http` and `mailto` addresses leave the app: a `file:` + * or custom-scheme address is refused, so a link in a note can never start another app or read a local file. iOS has + * no chooser: the default app opens it. `openURL` answers later, so a web address counts as opened once it was + * handed over; for a mail address the Mail app (or any `mailto` handler) is asked for first (the scheme is in + * `LSApplicationQueriesSchemes`). + */ +internal class IosExternalLinks : ExternalLinks { + @Suppress("ReturnCount") // Guards: no address, a scheme that may not leave the app. + override fun openUrl(url: String, chooser: Boolean): Boolean { + val target = parse(url) ?: return false + if (target.scheme?.lowercase() !in WEB_SCHEMES) return false + return open(target) + } + + override fun writeMail(address: String): Boolean { + val target = parse("mailto:$address") ?: return false + return open(target) + } + + private fun open(target: NSURL): Boolean { + val application = UIApplication.sharedApplication + if (target.scheme.equals("mailto", ignoreCase = true) && !application.canOpenURL(target)) return false + application.openURL(target, options = emptyMap(), completionHandler = null) + return true + } + + private fun parse(text: String): NSURL? = NSURL.URLWithString(text.trim())?.takeIf { it.scheme != null } + + private companion object { + val WEB_SCHEMES = setOf("https", "http", "mailto") + } +} + +/** + * What the window offers the UI. [view] is the root view of the host, painted behind the content, and [statusBar] + * sets the style of the status bar (dark icons on a light page). The cover that keeps the content out of the app + * switcher and recordings is [com.pasich.encly.ios.security.PrivacyShield]; [enforceSecure] re-asserts it. + */ +internal class IosAppWindow( + private val enforceShield: () -> Unit, + private val view: () -> UIView?, + private val statusBar: (darkContent: Boolean) -> Unit, +) : AppWindow { + override val isChangingConfigurations: Boolean = false + + override fun enforceSecure() = enforceShield() + + override fun setLightStatusBar(light: Boolean) = statusBar(light) + + override fun setBackgroundColor(argb: Int) { + view()?.backgroundColor = Color(argb).toUiColor() + } + + override fun hideKeyboard() { + view()?.endEditing(true) + } + + // A focused text field raises the keyboard by itself on iOS. + override fun showKeyboard() = Unit +} + +internal fun Color.toUiColor(): UIColor = + UIColor(red = red.toDouble(), green = green.toDouble(), blue = blue.toDouble(), alpha = alpha.toDouble()) + +/** + * Messages as snackbars inside the window ([ToastHost]); iOS has no system toast. The string resource is read in + * a coroutine on [dispatcher] (the main thread), in the in-app language. + */ +internal class IosToaster(dispatcher: CoroutineDispatcher = Dispatchers.Main) : Toaster { + val hostState = SnackbarHostState() + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + + override fun show(text: StringResource, duration: ToastDuration, vararg args: Any) { + scope.launch { + val message = getString(text, *args) + hostState.currentSnackbarData?.dismiss() + hostState.showSnackbar( + message = message, + duration = if (duration == ToastDuration.LONG) SnackbarDuration.Long else SnackbarDuration.Short, + ) + } + } +} + +/** Where [IosToaster]'s messages appear: put it at the bottom of the window, above the content. */ +@Composable +internal fun ToastHost(toaster: IosToaster, modifier: Modifier = Modifier) { + SnackbarHost(hostState = toaster.hostState, modifier = modifier) +} + +/** + * The in-app language. It is kept in [flags] and also written as the app's `AppleLanguages`, which is what the + * system and the string resources read when the app starts. iOS cannot switch a running app's language, so a + * change takes effect the next time the app is started (the system's own Settings → Encly → Language does the + * same). [revision] changes on every [apply] for the rest of the host. + */ +internal class IosAppLocaleController(private val flags: KeyValueStore) : AppLocaleController { + private val _revision = MutableStateFlow(0) + + /** Increases whenever the language was applied. */ + val revision: StateFlow = _revision.asStateFlow() + + override fun current(): AppLanguage = AppLanguage.fromTag(flags.getString(LANGUAGE_KEY, null)) + + override fun apply(language: AppLanguage) { + flags.edit { + if (language.tag == null) remove(LANGUAGE_KEY) else putString(LANGUAGE_KEY, language.tag!!) + } + writeAppleLanguages(language) + _revision.value += 1 + } + + /** Puts the stored language where the system reads it (idempotent); done before anything is composed. */ + fun syncAppleLanguages() = writeAppleLanguages(current()) + + private fun writeAppleLanguages(language: AppLanguage) { + val defaults = NSUserDefaults.standardUserDefaults + val tag = language.tag + if (tag == + null + ) { + defaults.removeObjectForKey(APPLE_LANGUAGES) + } else { + defaults.setObject(listOf(tag), APPLE_LANGUAGES) + } + } + + private companion object { + const val LANGUAGE_KEY = "app_language" + const val APPLE_LANGUAGES = "AppleLanguages" + } +} + +/** This build's facts, from the app bundle (`CFBundleShortVersionString` is written from `version.properties`). */ +internal class IosAppInfo : AppInfo { + override val appName: String = "Encly" + override val versionName: String = + NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.0.0" + + // Every iOS build is distributed through the App Store or TestFlight: no donation links (store policy), as in + // the Play build. There is no store page to rate yet. + override val donationsEnabled: Boolean = false + override val storeRatingEnabled: Boolean = false + override val storePageLinks: List = emptyList() +} + +/** The app's cache directory: a folder of the data directory, so it is as private and as protected as the rest. */ +internal class IosCacheDirectory(override val path: Path) : CacheDirectory + +/** iOS has no wallpaper-derived colours Encly can read. */ +internal object IosDeviceCapabilities : DeviceCapabilities { + override fun supportsDynamicColors(): Boolean = false +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/BackgroundLockGuard.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/BackgroundLockGuard.kt new file mode 100644 index 00000000..d685e50a --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/BackgroundLockGuard.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.ios.security + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import platform.UIKit.UIApplication +import platform.UIKit.UIBackgroundTaskIdentifier +import platform.UIKit.UIBackgroundTaskInvalid + +/** + * Keeps the auto-lock honest while the app is in the background. iOS suspends an app a few seconds after it enters + * the background; a suspended process runs no timer and receives no notification, so the lock manager's delay timer + * and the device-lock signal would simply not arrive, and the decrypted vault key would stay in memory until the + * user returns. On Android the process keeps running and the timer closes the vault on time. + * + * The guard asks iOS for a short background task when the app is stopped. While it runs the lock manager's timer + * and the device-lock signal work as on Android. The task is ended as soon as the vault is closed or the app comes + * back. If iOS takes the task away first (about half a minute), the expiration handler closes the vault before the + * app is suspended. This is the one explicit difference from Android: an auto-lock delay longer than the time iOS + * grants locks at the end of that time, never later. That is stricter, never weaker. + */ +internal class BackgroundLockGuard( + private val isVaultOpen: () -> Boolean, + private val lockNow: () -> Unit, + private val tasks: BackgroundTasks = UiKitBackgroundTasks, +) : DefaultLifecycleObserver { + private var task: UIBackgroundTaskIdentifier = UIBackgroundTaskInvalid + + override fun onStop(owner: LifecycleOwner) = begin() + + override fun onStart(owner: LifecycleOwner) = end() + + /** + * Starts the background task unless one runs or there is no open vault to close. When iOS refuses the task (no + * background time is left) the app is about to be suspended with nothing to wait for, so the vault is locked at + * once. + */ + fun begin() { + if (task != UIBackgroundTaskInvalid || !isVaultOpen()) return + val granted = tasks.begin(TASK_NAME, ::onExpired) + if (granted == UIBackgroundTaskInvalid) { + lockNow() + return + } + task = granted + } + + /** + * Out of time: close the vault while the app still runs. A lock may first save the notes that are open (up to a + * second), so the vault can still be open when [lockNow] returns; the task is then not given back here but when + * the lock reports `locked` (MainViewController calls [end] from there). Giving it back first would let iOS + * suspend the app with the vault open. If the process is killed meanwhile, the key goes with it. + */ + private fun onExpired() { + lockNow() + if (!isVaultOpen()) end() + } + + /** Gives the background task back, if there is one. */ + fun end() { + val running = task + if (running == UIBackgroundTaskInvalid) return + task = UIBackgroundTaskInvalid + tasks.end(running) + } + + private companion object { + const val TASK_NAME = "encly.auto-lock" + } +} + +/** The part of `UIApplication` that [BackgroundLockGuard] uses, so that a test can stand in for iOS. */ +internal interface BackgroundTasks { + /** A task named [name], or [UIBackgroundTaskInvalid] when iOS refuses; [onExpire] runs when it runs out. */ + fun begin(name: String, onExpire: () -> Unit): UIBackgroundTaskIdentifier + + fun end(task: UIBackgroundTaskIdentifier) +} + +internal object UiKitBackgroundTasks : BackgroundTasks { + override fun begin(name: String, onExpire: () -> Unit): UIBackgroundTaskIdentifier = + UIApplication.sharedApplication.beginBackgroundTaskWithName(name, onExpire) + + override fun end(task: UIBackgroundTaskIdentifier) = UIApplication.sharedApplication.endBackgroundTask(task) +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosAppLifecycle.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosAppLifecycle.kt new file mode 100644 index 00000000..29298433 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosAppLifecycle.kt @@ -0,0 +1,86 @@ +package com.pasich.encly.ios.security + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationDidBecomeActiveNotification +import platform.UIKit.UIApplicationDidEnterBackgroundNotification +import platform.UIKit.UIApplicationState +import platform.UIKit.UIApplicationWillEnterForegroundNotification +import platform.UIKit.UIApplicationWillResignActiveNotification + +/** + * The foreground state of the app for [com.pasich.encly.core.security.SessionLockManager], which is a lifecycle + * observer: Android feeds it from the process lifecycle, the desktop from the window, iOS from the application + * notifications of UIKit. + * + * | UIKit | Lifecycle | Meaning | + * |--------------------------------|-------------|---------------------------------------------------| + * | didBecomeActive | ON_RESUME | in front and receiving input | + * | willResignActive | ON_PAUSE | the app switcher, Control Center, a call, a sheet | + * | didEnterBackground | ON_STOP | the user left the app (Android's ON_STOP) | + * | willEnterForeground | ON_START | the app comes back, before its first frame | + * + * The lock manager acts on ON_STOP and ON_START only, as on Android: pulling down Control Center or the Face ID + * prompt pauses the app without locking it, leaving it for another app or the Home screen starts the auto-lock + * delay. The same owner is given to the UI as its lifecycle, so the navigation guards see the same stops and + * starts as the lock manager. + * + * [onForeground] runs once the app is in the foreground again, after the lock manager has seen the start. + */ +internal class IosAppLifecycle(private val onForeground: () -> Unit = {}) : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this) + private val observers = mutableListOf() + + override val lifecycle: Lifecycle get() = registry + + init { + registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + // Created while the app is starting: it is "inactive" until the first didBecomeActive. A start that + // happens in the background (a prewarm) stays created and is started by willEnterForeground. + val state = UIApplication.sharedApplication.applicationState + if (state != UIApplicationState.UIApplicationStateBackground) { + registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + if (state == UIApplicationState.UIApplicationStateActive) { + registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + } + observe(UIApplicationDidBecomeActiveNotification) { resume() } + observe(UIApplicationWillResignActiveNotification) { pause() } + observe(UIApplicationDidEnterBackgroundNotification) { stop() } + observe(UIApplicationWillEnterForegroundNotification) { start() } + } + + /** True while the app counts as in front (started). */ + val inForeground: Boolean get() = registry.currentState.isAtLeast(Lifecycle.State.STARTED) + + private fun start() { + if (registry.currentState == Lifecycle.State.CREATED) registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + } + + private fun resume() { + start() + if (registry.currentState == Lifecycle.State.STARTED) { + registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + onForeground() + } + } + + private fun pause() { + if (registry.currentState == Lifecycle.State.RESUMED) registry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + } + + private fun stop() { + pause() + if (registry.currentState == Lifecycle.State.STARTED) registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + + private fun observe(name: String?, action: () -> Unit) { + observers += NSNotificationCenter.defaultCenter.addObserverForName(name, null, NSOperationQueue.mainQueue) { + action() + } + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosBiometricManager.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosBiometricManager.kt new file mode 100644 index 00000000..cf7adcec --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosBiometricManager.kt @@ -0,0 +1,322 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.pasich.encly.ios.security + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.keystore.BiometricKeychain +import com.pasich.encly.core.keystore.BiometricKeychainException +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.biometric_prompt_general_subtitle +import com.pasich.encly.core.resources.biometric_prompt_general_title +import com.pasich.encly.core.resources.biometric_prompt_key_subtitle +import com.pasich.encly.core.resources.biometric_prompt_key_title +import com.pasich.encly.core.resources.biometric_prompt_settings_subtitle +import com.pasich.encly.core.resources.biometric_prompt_settings_title +import com.pasich.encly.core.resources.biometric_prompt_unlock_subtitle +import com.pasich.encly.core.resources.biometric_prompt_unlock_title +import com.pasich.encly.core.resources.biometric_prompt_use_pin +import com.pasich.encly.core.resources.biometric_unavailable +import com.pasich.encly.core.resources.cancel +import com.pasich.encly.core.security.BiometricHost +import com.pasich.encly.core.security.BiometricManager +import com.pasich.encly.core.security.BiometricManager.BiometricType +import com.pasich.encly.core.security.BiometricManager.PromptConfig +import com.pasich.encly.core.security.BiometricStatus +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.core.security.VaultStore +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import platform.Foundation.NSError +import platform.LocalAuthentication.LAContext +import platform.LocalAuthentication.LAErrorAppCancel +import platform.LocalAuthentication.LAErrorBiometryNotEnrolled +import platform.LocalAuthentication.LAErrorSystemCancel +import platform.LocalAuthentication.LAErrorUserCancel +import platform.LocalAuthentication.LAErrorUserFallback +import platform.LocalAuthentication.LAPolicyDeviceOwnerAuthenticationWithBiometrics +import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_global_queue +import platform.darwin.dispatch_get_main_queue +import kotlin.concurrent.Volatile + +/** What a biometric prompt is shown on. On iOS the system draws it over the app, so there is nothing to hold. */ +internal object IosBiometricHost : BiometricHost + +/** The texts of the system biometric prompts, as plain strings in the in-app language. */ +internal class BiometricTexts( + val unavailable: String, + val cancel: String, + val unlock: PromptConfig, + val masterKey: PromptConfig, + val settings: PromptConfig, + val general: PromptConfig, +) + +/** + * Holds the [BiometricTexts] for [IosBiometricManager], which builds the system prompt outside any coroutine and is + * handed finished strings instead of resolving resources itself. The app calls [refresh] before the first screen. + */ +internal class IosBiometricTexts { + @Volatile + private var texts: BiometricTexts? = null + + suspend fun refresh() { + texts = BiometricTexts( + unavailable = getString(Res.string.biometric_unavailable), + cancel = getString(Res.string.cancel), + unlock = PromptConfig( + title = getString(Res.string.biometric_prompt_unlock_title), + subtitle = getString(Res.string.biometric_prompt_unlock_subtitle), + negativeButtonText = getString(Res.string.biometric_prompt_use_pin), + ), + masterKey = PromptConfig( + title = getString(Res.string.biometric_prompt_key_title), + subtitle = getString(Res.string.biometric_prompt_key_subtitle), + ), + settings = PromptConfig( + title = getString(Res.string.biometric_prompt_settings_title), + subtitle = getString(Res.string.biometric_prompt_settings_subtitle), + ), + general = PromptConfig( + title = getString(Res.string.biometric_prompt_general_title), + subtitle = getString(Res.string.biometric_prompt_general_subtitle), + ), + ) + } + + /** The texts of the last [refresh]; read here, once, if a prompt is asked for before any refresh. */ + fun current(): BiometricTexts = texts ?: runBlocking { refresh() }.let { checkNotNull(texts) } +} + +/** + * The biometric unlock slot on iOS, with the semantics of Android's `KeystoreBiometricManager`. + * + * - Android: an AES key in the AndroidKeyStore, auth-per-use. iOS: a random 32-byte wrap key in a Keychain item + * whose access control is `biometryCurrentSet`. + * - Android: a BIOMETRIC_STRONG CryptoObject releases the key. iOS: the Secure Enclave releases the item only after + * a Face ID or Touch ID match for that very read. + * - Android: the key is invalidated by biometric enrollment. iOS: the system drops the item when the biometric set + * changes. + * - Android: no device-credential fallback. iOS: no `devicePasscode` flag, and the fallback button is hidden. + * - Both: the slot `bio.slot` in the [VaultStore] is iv ‖ AES-GCM(key, DEK) (the key is Android's Keystore key or + * the iOS wrap key), and [hasSlot] needs the key and the slot entry. + * + * As on Android, the key alone opens nothing and the slot entry alone opens nothing, so the wipe and duress paths, + * which drop every `bio.` entry of the [VaultStore] in one atomic write, leave no usable DEK behind. A "prompt + * succeeded" answer never opens the vault: [unlock] returns the DEK only from the Keychain read that the biometric + * match authorizes, followed by an AES-GCM open. [enroll] stores a fresh wrap key and then reads it back through the + * biometric prompt before it wraps the DEK, the iOS form of wrapping through an authenticated cipher. The Keychain + * read blocks until the user answers, so it runs on a background queue; every callback is delivered on the main + * thread, as on Android. + */ +@Suppress("TooManyFunctions") // The whole slot lifecycle, as in KeystoreBiometricManager. +internal class IosBiometricManager( + private val store: VaultStore, + private val keychain: BiometricKeychain, + private val texts: IosBiometricTexts, +) : BiometricManager { + override fun isStrongBiometricAvailable(): Boolean = strongBiometricStatus() == BiometricStatus.AVAILABLE + + override fun strongBiometricStatus(): BiometricStatus = memScoped { + val error = alloc>() + // Face ID and Touch ID are both Class 3; this policy is biometrics only, with no passcode fallback. + if (LAContext().canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, error.ptr)) { + BiometricStatus.AVAILABLE + } else if (error.value?.code == LAErrorBiometryNotEnrolled) { + BiometricStatus.NOT_ENROLLED + } else { + BiometricStatus.UNAVAILABLE + } + } + + /** True from [enroll] storing the fresh wrap key until the slot entry is written (or the enrolment failed). */ + @Volatile + private var enrolling = false + + override fun hasSlot(): Boolean { + if (!store.contains(SLOT_KEY)) { + // A Keychain item without its slot entry is left over from a wipe that only dropped the entries, unless + // it is the wrap key of an enrolment that has not written its slot entry yet: that one must stay. + if (!enrolling && keychain.exists()) keychain.delete() + return false + } + return keychain.exists() + } + + override fun enroll(host: BiometricHost, dek: ByteArray, onResult: (Boolean) -> Unit) { + if (host !is IosBiometricHost || !isStrongBiometricAvailable() || dek.size != DEK_LENGTH) { + onResult(false) + return + } + val dekCopy = dek.copyOf() + // Set before the item is replaced, so a hasSlot() in the wait for Face ID cannot take it for a leftover. + enrolling = true + store.edit { remove(SLOT_KEY) } + keychain.delete() + val fresh = Crypto.randomBytes(KEY_LENGTH) + val kept = keychain.store(fresh) + SensitiveDataCleaner.clear(fresh) + if (!kept) { + SensitiveDataCleaner.clear(dekCopy) + disable() + enrolling = false + onResult(false) + return + } + val prompt = promptFor(BiometricType.SETTINGS_TOGGLE, null) + readWrapKey(prompt, cancelTitle = null) { wrapKey, _ -> + val stored = wrapKey != null && wrap(wrapKey, dekCopy) + wrapKey?.let(SensitiveDataCleaner::clear) + SensitiveDataCleaner.clear(dekCopy) + if (!stored) disable() + enrolling = false + onResult(stored) + } + } + + override fun unlock(host: BiometricHost, onResult: (ByteArray?) -> Unit) { + val slot = store.getBytes(SLOT_KEY) + val usable = host is IosBiometricHost && slot != null && slot.size > IV_LENGTH + if (slot == null || !usable || !isStrongBiometricAvailable()) { + slot?.let(SensitiveDataCleaner::clear) + onResult(null) + return + } + val prompt = promptFor(BiometricType.APP_UNLOCK, null) + readWrapKey(prompt, cancelTitle = prompt.negativeButtonText) { wrapKey, error -> + if (wrapKey == null) { + SensitiveDataCleaner.clear(slot) + // A dropped item is what Android's KeyPermanentlyInvalidatedException is: the slot is over. + if (error?.gone == true) disable() + onResult(null) + return@readWrapKey + } + val dek = Crypto.aesGcmOpen( + wrapKey, + slot.copyOfRange(0, IV_LENGTH), + SLOT_AAD, + slot.copyOfRange(IV_LENGTH, slot.size), + ) + SensitiveDataCleaner.clear(wrapKey) + SensitiveDataCleaner.clear(slot) + // A slot that the key does not open is damaged for good. + if (dek == null) disable() + onResult(dek) + } + } + + /** Wraps [dek] under [wrapKey] into the slot entry of the store; false if it was not stored. */ + private fun wrap(wrapKey: ByteArray, dek: ByteArray): Boolean { + val iv = Crypto.randomBytes(IV_LENGTH) + val sealed = Crypto.aesGcmSeal(wrapKey, iv, SLOT_AAD, dek) + val slot = iv + sealed + val stored = store.edit { putBytes(SLOT_KEY, slot) } + SensitiveDataCleaner.clear(sealed) + SensitiveDataCleaner.clear(slot) + return stored + } + + /** + * Reads the wrap key through a biometric prompt on a background queue and hands the result to [done] on the + * main thread: the key, or null with the reason (cancelled, or the item is gone). + */ + private fun readWrapKey( + config: PromptConfig, + cancelTitle: String?, + done: (ByteArray?, BiometricKeychainException?) -> Unit, + ) { + val context = context(config, cancelTitle) + // The read blocks while the system prompt is up: never on the main thread. + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) { + var key: ByteArray? = null + var failure: BiometricKeychainException? = null + try { + key = keychain.read(context) + } catch (e: BiometricKeychainException) { + failure = e + } + onMain { done(key, failure) } + } + } + + override fun authenticate( + host: BiometricHost, + type: BiometricType, + callback: BiometricManager.BiometricCallback, + customConfig: PromptConfig?, + ) { + if (host !is IosBiometricHost || !isStrongBiometricAvailable()) { + callback.onError(ERROR_UNAVAILABLE, texts.current().unavailable) + return + } + val config = promptFor(type, customConfig) + evaluate(context(config, cancelTitle = config.negativeButtonText)) { success, error -> + when { + success -> callback.onSuccess() + + error?.code in CANCELLED -> callback.onCancelled() + + // A failed match ends this prompt on iOS; the caller treats it like any other terminal error. + else -> callback.onError( + error?.code?.toInt() ?: ERROR_UNAVAILABLE, + error?.localizedDescription.orEmpty(), + ) + } + } + } + + override fun disable() { + store.edit { removePrefix(BiometricManager.SLOT_PREFIX) } + keychain.delete() + } + + private fun promptFor(type: BiometricType, custom: PromptConfig?): PromptConfig = custom ?: when (type) { + BiometricType.APP_UNLOCK -> texts.current().unlock + BiometricType.MASTER_KEY_ACCESS -> texts.current().masterKey + BiometricType.SETTINGS_TOGGLE -> texts.current().settings + BiometricType.GENERAL -> texts.current().general + } + + /** A fresh context per prompt: no reuse of an earlier match, and the texts of this prompt. */ + private fun context(config: PromptConfig, cancelTitle: String?): LAContext = LAContext().apply { + localizedReason = config.description ?: config.subtitle.ifBlank { config.title } + localizedCancelTitle = cancelTitle ?: texts.current().cancel + // No "Enter Password" button: BIOMETRIC_STRONG only, the user falls back to Encly's own PIN. + localizedFallbackTitle = "" + touchIDAuthenticationAllowableReuseDuration = 0.0 + } + + private fun evaluate(context: LAContext, done: (Boolean, NSError?) -> Unit) { + context.evaluatePolicy( + LAPolicyDeviceOwnerAuthenticationWithBiometrics, + localizedReason = context.localizedReason, + ) { success, error -> onMain { done(success, error) } } + } + + private fun onMain(action: () -> Unit) { + dispatch_async(dispatch_get_main_queue(), action) + } + + private companion object { + const val SLOT_KEY = BiometricManager.SLOT_PREFIX + "slot" + const val DEK_LENGTH = 32 + const val KEY_LENGTH = 32 + const val IV_LENGTH = 12 + + /** Binds the envelope to this slot, as the other envelopes of the vault are bound to theirs. */ + val SLOT_AAD = "encly/biometric/slot/v2".encodeToByteArray() + + /** The code the callers already treat as "no biometrics" (BiometricPrompt's ERROR_HW_NOT_PRESENT). */ + const val ERROR_UNAVAILABLE = 12 + + val CANCELLED = setOf(LAErrorUserCancel, LAErrorSystemCancel, LAErrorAppCancel, LAErrorUserFallback) + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosClock.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosClock.kt new file mode 100644 index 00000000..cf7baf41 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosClock.kt @@ -0,0 +1,74 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.ios.security + +import com.pasich.encly.core.platform.MainThreadTimer +import com.pasich.encly.core.security.LockoutClock +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import platform.darwin.DISPATCH_TIME_NOW +import platform.darwin.NSEC_PER_MSEC +import platform.darwin.dispatch_after +import platform.darwin.dispatch_get_main_queue +import platform.darwin.dispatch_time +import platform.darwin.sysctlbyname +import platform.posix.CLOCK_MONOTONIC_RAW +import platform.posix.clock_gettime_nsec_np +import platform.posix.size_tVar +import kotlin.concurrent.Volatile + +private const val NANOS_PER_MILLI = 1_000_000UL +private const val BOOT_ID_BUFFER = 64 + +/** + * [MainThreadTimer] on a dispatch timer on the main queue, the UI thread, like the main Looper on Android. A timer + * does not fire while the process is suspended in the background; the lock manager compares the clock when the app + * comes back, so the delay still counts (see [IosLockoutClock]). + */ +internal class IosMainThreadTimer : MainThreadTimer { + override fun schedule(delayMs: Long, action: () -> Unit): () -> Unit { + val state = TimerState() + val time = dispatch_time(DISPATCH_TIME_NOW, delayMs.coerceAtLeast(0L) * NSEC_PER_MSEC.toLong()) + dispatch_after(time, dispatch_get_main_queue()) { + if (!state.cancelled) action() + } + return { state.cancelled = true } + } + + private class TimerState { + @Volatile + var cancelled = false + } +} + +/** + * [LockoutClock] on the continuous clock of the OS: it counts the time the device sleeps (unlike the uptime + * clock), restarts only at boot, and a change of the wall clock does not move it, so the PIN lockout and the + * auto-lock delay cannot be cut short by sleeping or by setting the date. [bootCount] is derived from the + * boot-session UUID the kernel makes at every boot, so the lockout also knows a reboot happened. + */ +internal class IosLockoutClock : LockoutClock { + private val bootId: Int = readBootSession()?.hashCode() ?: -1 + + override fun elapsedRealtime(): Long = + (clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW.convert()) / NANOS_PER_MILLI).toLong() + + override fun bootCount(): Int = bootId + + private fun readBootSession(): String? = memScoped { + val buffer = ByteArray(BOOT_ID_BUFFER) + val size = alloc() + size.value = buffer.size.convert() + val status = buffer.usePinned { + sysctlbyname("kern.bootsessionuuid", it.addressOf(0), size.ptr, null, 0.convert()) + } + if (status != 0) null else buffer.toKString() + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosDeviceLockWatcher.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosDeviceLockWatcher.kt new file mode 100644 index 00000000..7a5ae020 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosDeviceLockWatcher.kt @@ -0,0 +1,62 @@ +package com.pasich.encly.ios.security + +import com.pasich.encly.core.security.DeviceLockWatcher +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationProtectedDataWillBecomeUnavailable +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue + +/** + * [DeviceLockWatcher] on iOS: the device locking is the moment its protected data becomes unavailable, which + * UIKit announces (`protectedDataWillBecomeUnavailable`) and answers (`isProtectedDataAvailable`). This needs a + * device passcode, as the Keychain items of the vault do. + * + * The announcement can arrive just before the app is told it entered the background, i.e. before the lock manager + * starts watching. The watcher therefore listens from the start and remembers a lock until the app is in front + * again ([appReturned]): a watch that begins after the device locked is told at once (on the next turn of the main + * queue), and [isDeviceLocked] stays true for a start that follows a lock even though the device is unlocked by + * then. So a device lock always closes the vault, whatever the order of the events and the auto-lock delay. + */ +internal class IosDeviceLockWatcher : DeviceLockWatcher { + private var lockedSinceForeground = false + private val listeners = mutableListOf<() -> Unit>() + private val observer: Any + + init { + observer = NSNotificationCenter.defaultCenter.addObserverForName( + UIApplicationProtectedDataWillBecomeUnavailable, + null, + NSOperationQueue.mainQueue, + ) { + lockedSinceForeground = true + listeners.toList().forEach { it() } + } + } + + override fun isDeviceLocked(): Boolean = + lockedSinceForeground || !UIApplication.sharedApplication.protectedDataAvailable + + override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit { + listeners += onScreenOff + if (lockedSinceForeground) { + dispatch_async(dispatch_get_main_queue()) { + if (onScreenOff in + listeners + ) { + onScreenOff() + } + } + } + return { listeners -= onScreenOff } + } + + /** The app is in front and has handled its start: a lock from before is history. */ + fun appReturned() { + lockedSinceForeground = false + } + + /** Calls [action] every time the device locks, as long as the app runs (in front or not). */ + fun onLocked(action: () -> Unit): () -> Unit = watchScreenOff(action) +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosKeyboardPrivacy.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosKeyboardPrivacy.kt new file mode 100644 index 00000000..5c987e09 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosKeyboardPrivacy.kt @@ -0,0 +1,63 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.ios.security + +import kotlinx.cinterop.CFunction +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.staticCFunction +import platform.Foundation.NSClassFromString +import platform.objc.class_getInstanceMethod +import platform.objc.class_replaceMethod +import platform.objc.sel_registerName + +/** + * What Encly asks of the keyboard for every text input, the iOS counterpart of Android's + * `IME_FLAG_NO_PERSONALIZED_LEARNING`: no autocorrection, no spell checking, no smart quotes, dashes or + * insert/delete, no inline predictions. The system keyboard learns from what is typed through autocorrection and + * the suggestions bar; with these off it has nothing to learn from, and a third-party keyboard that honours the + * traits gets the same signal. iOS has no single "do not learn" flag, and a keyboard may ignore any of these (see + * SECURITY.md); a note editor cannot use the only stronger signal, secure text entry, which would make the field + * single-line dots. + * + * Compose draws no text field of its own on iOS: every text input is a `CMPTextInputView` (the UITextInput view + * of Compose Multiplatform), whose traits Compose fills in from `KeyboardOptions`, and only partly: it knows + * `autoCorrectEnabled` and nothing of spell checking or the smart punctuation. The editor's own fields also ask + * for autocorrection explicitly. So the getters of those traits are replaced on that one class and always answer + * "no", whatever Compose assigned. If a Compose upgrade renames the class, [install] finds none and returns 0, and + * the test for it fails. + */ +internal object IosKeyboardPrivacy { + private const val COMPOSE_INPUT_VIEW = "CMPTextInputView" + + /** The `UITextInputTraits` getters that learn or rewrite; each type is an NSInteger enum whose `.no` is 1. */ + private val TRAITS = listOf( + "autocorrectionType", + "spellCheckingType", + "smartQuotesType", + "smartDashesType", + "smartInsertDeleteType", + "inlinePredictionType", + ) + private const val NO = 1L + private const val NSINTEGER_GETTER = "q@:" + + /** + * Makes the instances of [className] answer "no" (1) for every one of [getters] they implement; the number of + * getters replaced (0 when the class is not there). Safe to call more than once. [getters] is [TRAITS] unless a + * test points it at a getter of a harmless class. + */ + fun install(className: String = COMPOSE_INPUT_VIEW, getters: List = TRAITS): Int { + val type = NSClassFromString(className) ?: return 0 + val answerNo = staticCFunction { _: COpaquePointer?, _: COpaquePointer? -> NO } + return getters.count { name -> + val selector = sel_registerName(name) + val implemented = class_getInstanceMethod(type, selector) != null + if (implemented) { + class_replaceMethod(type, selector, answerNo.reinterpret Unit>>(), NSINTEGER_GETTER) + } + implemented + } + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/PrivacyShield.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/PrivacyShield.kt new file mode 100644 index 00000000..ab90c073 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/PrivacyShield.kt @@ -0,0 +1,112 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.ios.security + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.QuartzCore.CATransaction +import platform.UIKit.NSTextAlignmentCenter +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationDidBecomeActiveNotification +import platform.UIKit.UIApplicationWillResignActiveNotification +import platform.UIKit.UIColor +import platform.UIKit.UIFont +import platform.UIKit.UIFontWeightSemibold +import platform.UIKit.UILabel +import platform.UIKit.UIScreen +import platform.UIKit.UIScreenCapturedDidChangeNotification +import platform.UIKit.UIView +import platform.UIKit.UIViewAutoresizingFlexibleHeight +import platform.UIKit.UIViewAutoresizingFlexibleWidth +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowLevelAlert +import platform.UIKit.UIWindowScene +import platform.UIKit.secondaryLabelColor +import platform.UIKit.systemBackgroundColor + +/** + * What Android's `FLAG_SECURE` does for the window, as far as iOS lets an app do it. `FLAG_SECURE` keeps the app out + * of screenshots, recordings, casts and the recents snapshot. iOS gives an app no way to refuse a screenshot, but + * the other three can be covered: + * - the app switcher snapshot: the system takes it when the app goes to the background, and the cover is up from + * `willResignActive` on, before that, so the snapshot shows the cover and not a note; + * - screen recording, AirPlay and wired mirroring (`UIScreen.isCaptured`): while the screen is captured the cover + * hides the content from the captured stream too (and from the user's own screen, as black frames do on Android); + * - the cover is a window of its own above every other window, so it also covers a document picker or any other + * sheet that is on screen, which a view inside the app would not. + * + * Not possible on iOS, so not done: preventing a screenshot taken by the user while the content is shown. The + * vault locks whenever the app is left, as the screenshot is the user's own act. + * + * [install] is idempotent; [enforce] re-asserts the cover state (what `AppWindow.enforceSecure` asks for). + */ +internal class PrivacyShield { + private var cover: UIWindow? = null + private var inactive = false + private var installed = false + + /** Starts watching the application and the screen capture state. */ + fun install() { + if (installed) return + installed = true + val center = NSNotificationCenter.defaultCenter + center.addObserverForName(UIApplicationWillResignActiveNotification, null, NSOperationQueue.mainQueue) { + inactive = true + enforce() + } + center.addObserverForName(UIApplicationDidBecomeActiveNotification, null, NSOperationQueue.mainQueue) { + inactive = false + enforce() + } + center.addObserverForName(UIScreenCapturedDidChangeNotification, null, NSOperationQueue.mainQueue) { + enforce() + } + // The app may start with the screen already being captured. + enforce() + } + + /** Shows or hides the cover for the current state. */ + fun enforce() { + if (inactive || screenIsCaptured()) show() else hide() + } + + private fun screenIsCaptured(): Boolean = UIScreen.mainScreen.captured + + private fun show() { + val window = cover ?: newCover().also { cover = it } + window.hidden = false + window.layoutIfNeeded() + // The system snapshot is taken as soon as the notification handlers return: draw now. + CATransaction.flush() + } + + private fun hide() { + cover?.hidden = true + } + + private fun newCover(): UIWindow { + val scene = UIApplication.sharedApplication.connectedScenes.filterIsInstance().firstOrNull() + val window = if (scene != null) UIWindow(windowScene = scene) else UIWindow(frame = UIScreen.mainScreen.bounds) + window.windowLevel = UIWindowLevelAlert + ABOVE_ALERTS + window.rootViewController = UIViewController().apply { + view.backgroundColor = UIColor.systemBackgroundColor + view.addSubview(brandLabel(view)) + } + return window + } + + private fun brandLabel(parent: UIView): UILabel = UILabel(frame = parent.bounds).apply { + text = "Encly" + textAlignment = NSTextAlignmentCenter + font = UIFont.systemFontOfSize(LABEL_SIZE, weight = UIFontWeightSemibold) + textColor = UIColor.secondaryLabelColor + autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight + } + + private companion object { + const val LABEL_SIZE = 28.0 + const val ABOVE_ALERTS = 1000.0 + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosDataDirectory.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosDataDirectory.kt new file mode 100644 index 00000000..b452533d --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosDataDirectory.kt @@ -0,0 +1,59 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.pasich.encly.ios.storage + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.io.IOException +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSFileProtectionComplete +import platform.Foundation.NSFileProtectionKey +import platform.Foundation.NSURL +import platform.Foundation.NSURLIsExcludedFromBackupKey +import platform.Foundation.NSUserDomainMask + +/** + * The app's private data directory: `Application Support/Encly` inside the sandbox. It holds everything Encly + * stores (the vault state, the database, the flags, the settings, the cache). + * + * It follows the rules of the Android app, which excludes all of its data from backups and device transfers: + * - the directory is excluded from iCloud and iTunes/Finder backups (`isExcludedFromBackup`), so nothing of the + * vault is ever restored onto another device. The PIN's device secret is in a this-device-only Keychain item in + * any case, so a restored copy would be unreadable; + * - the directory has the data protection class `NSFileProtectionComplete`, and so do the files created in it: they + * cannot be opened while the device is locked. The vault locks when the screen turns off, before that matters. + */ +internal object IosDataDirectory { + private const val NAME = "Encly" + + /** The directory's path; created, protected and excluded from backups on every call (idempotent). */ + @Suppress("ThrowsCount") // Each step of the setup fails in its own way. + fun prepare(): String { + val files = NSFileManager.defaultManager + val support = (files.URLsForDirectory(NSApplicationSupportDirectory, NSUserDomainMask).firstOrNull() as? NSURL) + ?: throw IOException("no Application Support directory") + val directory = support.URLByAppendingPathComponent(NAME, isDirectory = true) + ?: throw IOException("no data directory") + val path = directory.path ?: throw IOException("no data directory path") + val protection = mapOf(NSFileProtectionKey to NSFileProtectionComplete) + if (!files.createDirectoryAtPath(path, true, protection, null)) throw IOException("cannot create $path") + // A directory that existed before keeps its old class otherwise; the files in it are fixed up as well. + files.setAttributes(protection, ofItemAtPath = path, error = null) + protectExisting(files, path, protection) + if (!directory.setResourceValue(true, forKey = NSURLIsExcludedFromBackupKey, error = null)) { + throw IOException("cannot exclude $path from backups") + } + return path + } + + /** Files from before the directory was protected (or put there by a library) get the class too. */ + private fun protectExisting(files: NSFileManager, path: String, protection: Map) { + val names = files.contentsOfDirectoryAtPath(path, null) ?: return + for (name in names) { + val child = "$path/$name" + files.setAttributes(protection, ofItemAtPath = child, error = null) + // One level is enough: the database and the vault file are direct children. + } + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt new file mode 100644 index 00000000..3002cde3 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt @@ -0,0 +1,134 @@ +package com.pasich.encly.ios.storage + +import com.pasich.encly.core.platform.KeyValueStore +import com.pasich.encly.core.security.IosVaultFileIo +import com.pasich.encly.core.security.VaultFileIo +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.io.IOException +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +/** + * [KeyValueStore] in one small JSON file inside the app's data directory (not `NSUserDefaults`, which lives + * outside it, is backed up, and is out of Encly's control). + * + * Every change rewrites the whole file through [io] (a temp file, `F_FULLFSYNC`, an atomic rename), so a crash + * leaves the old content or the new, never a mix. [edit] returns at once and writes in order on a background + * thread; [commit] writes before it returns. A file that is absent starts as empty. A file that exists but cannot + * be read (an I/O error, for example while protected data is unavailable) fails closed: nothing is changed or + * written, so the file is never replaced by an empty state, and every read tries the file again. Meanwhile reads + * give the defaults. A file that was read but does not parse is damaged for good, and the vault does not depend on + * these flags: it is moved aside (`.damaged`, for a look) and the store starts empty. Only when it cannot be + * moved does it stay closed, as for an unreadable one. + */ +internal class IosKeyValueStore( + private val io: VaultFileIo, + private val moveAside: () -> Boolean, + private val writer: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)), +) : KeyValueStore { + constructor(path: String) : this(IosVaultFileIo(path), { moveToDamaged(path) }) + + private val stateLock = SynchronizedObject() + private val writeLock = SynchronizedObject() + + // Guarded by [stateLock]. Null while the file exists but could not be read. + private var values: MutableMap? = load()?.toMutableMap() + + override fun getString(key: String, default: String?): String? = read { it[key] } ?: default + + override fun getBoolean(key: String, default: Boolean): Boolean = + read { it[key] }?.toBooleanStrictOrNull() ?: default + + override fun getLong(key: String, default: Long): Long = read { it[key] }?.toLongOrNull() ?: default + + override fun contains(key: String): Boolean = read { it.containsKey(key) } ?: false + + override fun edit(block: KeyValueStore.Editor.() -> Unit) { + if (read { MapEditor(it).block() } != null) writer.launch { persist() } + } + + override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean = + read { MapEditor(it).block() } != null && persist() + + /** + * [action] on the values, or null while the file is unreadable (it is read again first, and taken in once it + * can be read). + */ + private fun read(action: (MutableMap) -> T): T? = synchronized(stateLock) { + val current = values ?: load()?.toMutableMap()?.also { values = it } ?: return null + action(current) + } + + /** Waits for the writes queued by [edit] (tests). */ + internal fun flush() = runBlocking { writer.launch { }.join() } + + /** Writes the current values; false if the file could not be replaced (the values stay in memory). */ + private fun persist(): Boolean = synchronized(writeLock) { + val snapshot = synchronized(stateLock) { values?.toMap() } ?: return false + try { + io.replace(Json.encodeToString(SERIALIZER, snapshot).encodeToByteArray(), null) + true + } catch (_: IOException) { + false + } + } + + /** The file's content; empty when there is no file, null when there is one that cannot be read or parsed. */ + private fun load(): Map? { + if (!io.exists()) return emptyMap() + return try { + Json.decodeFromString(SERIALIZER, io.read().decodeToString()) + } catch (_: IOException) { + null + } catch (_: IllegalArgumentException) { + // Damaged JSON (a SerializationException is one too): nothing to wait for, and the flags are not worth + // a stuck store. + if (moveAside()) emptyMap() else null + } + } + + private class MapEditor(private val map: MutableMap) : KeyValueStore.Editor { + override fun putString(key: String, value: String) { + map[key] = value + } + + override fun putBoolean(key: String, value: Boolean) { + map[key] = value.toString() + } + + override fun putLong(key: String, value: Long) { + map[key] = value.toString() + } + + override fun remove(key: String) { + map.remove(key) + } + + override fun clear() { + map.clear() + } + } + + private companion object { + const val ASIDE_SUFFIX = ".damaged" + + fun moveToDamaged(path: String): Boolean = try { + SystemFileSystem.atomicMove(Path(path), Path(path + ASIDE_SUFFIX)) + true + } catch (_: IOException) { + false + } + + val SERIALIZER = MapSerializer(String.serializer(), String.serializer()) + } +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.ios.kt new file mode 100644 index 00000000..83354cbc --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.ios.kt @@ -0,0 +1,4 @@ +package com.pasich.encly.presentation.designsystem + +// A hardware keyboard on an iPhone or iPad uses Command. +internal actual fun platformUsesCommandKey(): Boolean = true diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.ios.kt new file mode 100644 index 00000000..5e0a421a --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.ios.kt @@ -0,0 +1,22 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +// Touch scrolling is the way on iOS: no scrollbar is drawn. + +@Composable +actual fun BoxScope.EnclyScrollbar(state: LazyListState, modifier: Modifier) = Unit + +@Composable +actual fun BoxScope.EnclyScrollbar(state: ScrollState, modifier: Modifier) = Unit + +@Composable +actual fun BoxScope.EnclyScrollbar(state: LazyStaggeredGridState, modifier: Modifier) = Unit + +@Composable +actual fun BoxScope.EnclyHorizontalScrollbar(state: ScrollState, modifier: Modifier) = Unit diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.ios.kt new file mode 100644 index 00000000..98783f06 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.ios.kt @@ -0,0 +1,9 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import platform.UIKit.UIAccessibilityIsReduceMotionEnabled + +/** The "Reduce Motion" accessibility setting, the iOS counterpart of Android's animator scale of 0. */ +@Composable +internal actual fun rememberAnimationsOff(): Boolean = remember { UIAccessibilityIsReduceMotionEnabled() } diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.ios.kt new file mode 100644 index 00000000..da9dfa0c --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.ios.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.presentation.effects + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Dp + +@Composable +internal actual fun rememberImeVisible(): Boolean = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + +@Composable +internal actual fun windowWidth(): Dp = with(LocalDensity.current) { + LocalWindowInfo.current.containerSize.width.toDp() +} diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.ios.kt new file mode 100644 index 00000000..39b0f612 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.ios.kt @@ -0,0 +1,4 @@ +package com.pasich.encly.ui.theme + +/** iPhones and iPads are touch first, an iPad with a keyboard or trackpad included. */ +internal actual fun isPointerFirstDevice(): Boolean = false diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.ios.kt new file mode 100644 index 00000000..c5433d00 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.ios.kt @@ -0,0 +1,8 @@ +package com.pasich.encly.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +/** iOS has no wallpaper-derived colours an app can read. */ +@Composable +internal actual fun wallpaperColorScheme(dark: Boolean): ColorScheme? = null diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/utils/LocalizedDate.ios.kt b/shared/src/iosMain/kotlin/com/pasich/encly/utils/LocalizedDate.ios.kt new file mode 100644 index 00000000..8ccdde9d --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/utils/LocalizedDate.ios.kt @@ -0,0 +1,64 @@ +package com.pasich.encly.utils + +import androidx.compose.runtime.Composable +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.datetime.TimeZone +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSDateFormatterMediumStyle +import platform.Foundation.NSDateFormatterNoStyle +import platform.Foundation.NSDateFormatterShortStyle +import platform.Foundation.NSLocale +import platform.Foundation.NSTimeZone +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.defaultTimeZone +import platform.Foundation.preferredLanguages +import platform.Foundation.timeZoneWithName +import kotlin.time.Instant + +private const val MILLIS_PER_SECOND = 1000.0 + +/** The first of the user's preferred languages, which is the in-app one after it was applied and the app restarted. */ +@Composable +internal actual fun currentLanguageTag(): String = NSLocale.preferredLanguages.firstOrNull() as? String ?: "en" + +internal actual fun formatLocalized( + instant: Instant, + style: LocalizedDate, + languageTag: String, + zone: TimeZone, +): String = synchronized(lock) { + val formatter = formatters.getOrPut(style to languageTag) { newFormatter(style, languageTag) } + // The cached formatter may outlive the zone it was made in. + formatter.timeZone = NSTimeZone.timeZoneWithName(zone.id) ?: NSTimeZone.defaultTimeZone + formatter.stringFromDate(NSDate.dateWithTimeIntervalSince1970(instant.toEpochMilliseconds() / MILLIS_PER_SECOND)) +} + +private val lock = SynchronizedObject() +private val formatters = HashMap, NSDateFormatter>() + +private fun newFormatter(style: LocalizedDate, languageTag: String): NSDateFormatter = NSDateFormatter().apply { + locale = NSLocale(localeIdentifier = languageTag) + when (style) { + LocalizedDate.TIME -> { + dateStyle = NSDateFormatterNoStyle + timeStyle = NSDateFormatterShortStyle + } + + LocalizedDate.DATE_TIME_SHORT -> { + dateStyle = NSDateFormatterShortStyle + timeStyle = NSDateFormatterShortStyle + } + + LocalizedDate.DATE_TIME_MEDIUM -> { + dateStyle = NSDateFormatterMediumStyle + timeStyle = NSDateFormatterShortStyle + } + + // The locale's own order of day and month comes from the template. + LocalizedDate.DAY_MONTH -> setLocalizedDateFormatFromTemplate("dMMM") + + LocalizedDate.DAY_MONTH_YEAR -> setLocalizedDateFormatFromTemplate("dMMMy") + } +} diff --git a/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt b/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt new file mode 100644 index 00000000..9df7b82e --- /dev/null +++ b/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt @@ -0,0 +1,161 @@ +package com.pasich.encly.ios + +import com.pasich.encly.core.security.IosVaultFileIo +import com.pasich.encly.data.export.keepFileNameCharacters +import com.pasich.encly.data.export.normalizeNfc +import com.pasich.encly.data.export.utf8Prefix +import com.pasich.encly.data.zip.decodeCp437 +import com.pasich.encly.data.zip.newCrc32 +import com.pasich.encly.data.zip.openZipFile +import com.pasich.encly.data.zip.zipWriter +import com.pasich.encly.ios.storage.IosKeyValueStore +import com.pasich.encly.utils.LocalizedDate +import com.pasich.encly.utils.formatLocalized +import kotlinx.datetime.TimeZone +import kotlinx.io.Buffer +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readByteArray +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSUUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** The iOS actuals that only run on the Simulator: the ZIP seam over zlib, dates, Unicode and the flags file. */ +class IosActualsTest { + private val scratch = mutableListOf() + + private fun tempPath(suffix: String): Path = + Path(NSTemporaryDirectory().trimEnd('/') + "/encly-" + NSUUID().UUIDString + suffix).also { scratch += it } + + @AfterTest + fun cleanUp() = scratch.forEach { SystemFileSystem.delete(it, mustExist = false) } + + @Test + fun crc32MatchesTheCheckValue() { + val crc = newCrc32() + val bytes = "123456789".encodeToByteArray() + crc.update(bytes, 0, bytes.size) + assertEquals(0xCBF43926L, crc.value) + } + + @Test + fun cp437DecodesTheHighHalf() { + assertEquals("aÇü ", decodeCp437(byteArrayOf(0x61, 0x80.toByte(), 0x81.toByte(), 0xFF.toByte()))) + } + + @Test + fun zipWrittenHereReadsBack() { + val text = "hello hello hello hello".encodeToByteArray() + val big = ByteArray(100_000) { (it % 7).toByte() } + val out = Buffer() + val writer = zipWriter(out) { 1_700_000_000_000L } + writer.put("a.txt", null, text) + writer.put("dir/ü.md", 1_600_000_000_000L, big) + writer.put("empty", null, ByteArray(0)) + writer.finish() + val file = tempPath(".zip") + SystemFileSystem.sink(file).buffered().use { it.write(out, out.size) } + + openZipFile(file).use { zip -> + assertEquals(3, zip.entryCount) + assertContentEquals(text, read(zip, "a.txt")) + assertContentEquals(big, read(zip, "dir/ü.md")) + assertContentEquals(ByteArray(0), read(zip, "empty")) + assertEquals(big.size.toLong(), zip.find("dir/ü.md")?.declaredSize) + assertNull(zip.find("missing")) + } + } + + @Test + fun aFileThatIsNotAZipIsAnIoError() { + val file = tempPath(".zip") + SystemFileSystem.sink(file).buffered().use { it.write("not a zip at all, just some text".encodeToByteArray()) } + val failed = runCatching { openZipFile(file) }.exceptionOrNull() + assertNotNull(failed) + assertTrue(failed is kotlinx.io.IOException) + } + + @Test + fun timeIsFormattedInTheGivenZoneAndLanguage() { + val noon = Instant.fromEpochMilliseconds(12 * 3_600_000L) + val text = formatLocalized(noon, LocalizedDate.TIME, "en-US", TimeZone.UTC) + assertTrue(text.startsWith("12:00"), text) + val day = formatLocalized(noon, LocalizedDate.DAY_MONTH, "en-US", TimeZone.UTC) + assertTrue(day.contains("Jan") && day.contains("1"), day) + } + + @Test + fun fileNameRulesFollowTheJvmOnes() { + assertEquals("é", normalizeNfc("é")) + assertEquals("abc d", keepFileNameCharacters("a/b:c\u0000 d")) + assertEquals("aé", utf8Prefix("aé€", 3)) + assertEquals("😀", utf8Prefix("😀x", 4)) + } + + @Test + fun flagsSurviveARestart() { + val file = tempPath(".json").toString() + val store = IosKeyValueStore(file) + assertTrue( + store.commit { + putString("a", "1") + putBoolean("b", true) + putLong("c", 7L) + }, + ) + val reopened = IosKeyValueStore(file) + assertEquals("1", reopened.getString("a", null)) + assertTrue(reopened.getBoolean("b", false)) + assertEquals(7L, reopened.getLong("c", 0L)) + reopened.commit { remove("a") } + assertFalse(IosKeyValueStore(file).contains("a")) + } + + @Test + fun aDamagedFlagsFileIsMovedAsideAndTheStoreStartsEmpty() { + val file = tempPath(".json").toString() + scratch += Path("$file.damaged") + val damaged = "{ not json".encodeToByteArray() + IosVaultFileIo(file).replace(damaged, null) + + val store = IosKeyValueStore(file) + + assertFalse(store.contains("a")) + assertContentEquals(damaged, IosVaultFileIo("$file.damaged").read()) + assertFalse(IosVaultFileIo(file).exists()) + assertTrue(store.commit { putString("a", "1") }) + assertEquals("1", IosKeyValueStore(file).getString("a", null)) + assertContentEquals(damaged, IosVaultFileIo("$file.damaged").read()) + } + + @Test + fun aDamagedFlagsFileThatCannotBeMovedFailsClosedAndIsNeverOverwritten() { + val file = tempPath(".json").toString() + val damaged = "{ not json".encodeToByteArray() + IosVaultFileIo(file).replace(damaged, null) + val store = IosKeyValueStore(IosVaultFileIo(file), moveAside = { false }) + + assertFalse(store.contains("a")) + assertFalse(store.commit { putString("a", "1") }) + store.edit { putString("a", "1") } + store.flush() + + assertContentEquals(damaged, IosVaultFileIo(file).read()) + IosVaultFileIo(file).replace("""{"a":"1"}""".encodeToByteArray(), null) + assertEquals("1", store.getString("a", null)) + } + + private fun read(zip: com.pasich.encly.data.zip.ZipEntrySource, name: String): ByteArray { + val entry = assertNotNull(zip.find(name)) + return entry.open().buffered().use { it.readByteArray() } + } +} diff --git a/shared/src/iosTest/kotlin/com/pasich/encly/ios/platform/PickedCopiesTest.kt b/shared/src/iosTest/kotlin/com/pasich/encly/ios/platform/PickedCopiesTest.kt new file mode 100644 index 00000000..619abdfc --- /dev/null +++ b/shared/src/iosTest/kotlin/com/pasich/encly/ios/platform/PickedCopiesTest.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.ios.platform + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.writeString +import platform.Foundation.NSFileManager +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSUUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** What the document picker's copies do when they cannot be moved into the folder the purge empties. */ +class PickedCopiesTest { + private val root = Path(NSTemporaryDirectory().trimEnd('/') + "/encly-picked-" + NSUUID().UUIDString) + private val copies = PickedCopies(Path(root, "copies")) + + @AfterTest + fun cleanUp() { + NSFileManager.defaultManager.removeItemAtPath(root.toString(), null) + } + + private fun write(path: Path) { + SystemFileSystem.createDirectories(path.parent!!) + SystemFileSystem.sink(path).buffered().use { it.writeString("secret") } + } + + @Test + fun aCopyIsMovedIntoThePurgeFolder() { + val source = Path(root, "incoming/a.txt").also(::write) + val folder = copies.newFolder() + + val adopted = assertNotNull(copies.adopt(source.toString(), "a.txt", folder)) + + assertEquals(Path(folder, "a.txt").toString(), adopted) + assertTrue(SystemFileSystem.exists(Path(adopted))) + assertFalse(SystemFileSystem.exists(source)) + copies.purge() + assertFalse(SystemFileSystem.exists(Path(adopted))) + } + + @Test + fun aCopyThatCannotBeMovedIsDeletedAndTheFileRefused() { + val source = Path(root, "incoming/b.txt").also(::write) + val missingFolder = Path(root, "copies/not-created") + + val adopted = copies.adopt(source.toString(), "b.txt", missingFolder) + + assertNull(adopted) + assertFalse(SystemFileSystem.exists(source)) + } +} diff --git a/shared/src/iosTest/kotlin/com/pasich/encly/ios/platform/SensitiveComposeClipboardTest.kt b/shared/src/iosTest/kotlin/com/pasich/encly/ios/platform/SensitiveComposeClipboardTest.kt new file mode 100644 index 00000000..c2eafb99 --- /dev/null +++ b/shared/src/iosTest/kotlin/com/pasich/encly/ios/platform/SensitiveComposeClipboardTest.kt @@ -0,0 +1,58 @@ +package com.pasich.encly.ios.platform + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.NativeClipboard +import com.pasich.encly.core.platform.MainThreadTimer +import kotlinx.coroutines.runBlocking +import platform.UIKit.UIPasteboard +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** What a Compose text field's copy does to the pasteboard. */ +@OptIn(ExperimentalComposeUiApi::class) +class SensitiveComposeClipboardTest { + /** The platform clipboard of Compose; any write to it is a failure of the policy. */ + private class ForbiddenDelegate : Clipboard { + var writes = 0 + override val nativeClipboard: NativeClipboard get() = UIPasteboard.generalPasteboard + + override suspend fun getClipEntry(): ClipEntry? = null + + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + writes++ + } + } + + private val delegate = ForbiddenDelegate() + private val policy = IosClipboardPolicy(MainThreadTimer.None) + private val clipboard = SensitiveComposeClipboard(delegate, policy) + + @AfterTest + fun cleanUp() = policy.clearNow() + + @Test + fun aCopiedTextReachesThePasteboardOnlyThroughThePolicy() { + runBlocking { clipboard.setClipEntry(ClipEntry.withPlainText("note line")) } + + assertEquals(0, delegate.writes) + // A test executable may have no pasteboard server ("Cannot connect to pasteboard server"): then there is + // nothing to read back, and the check above is the one that matters. + UIPasteboard.generalPasteboard.string?.let { assertEquals("note line", it) } + } + + @Test + fun aNullEntryEmptiesThePasteboardAndStillNeverTouchesTheDelegate() { + policy.copy("kept") + + runBlocking { clipboard.setClipEntry(null) } + + assertEquals(0, delegate.writes) + assertNull(UIPasteboard.generalPasteboard.string) + assertTrue(UIPasteboard.generalPasteboard.items.isEmpty()) + } +} diff --git a/shared/src/iosTest/kotlin/com/pasich/encly/ios/security/BackgroundLockGuardTest.kt b/shared/src/iosTest/kotlin/com/pasich/encly/ios/security/BackgroundLockGuardTest.kt new file mode 100644 index 00000000..6d709080 --- /dev/null +++ b/shared/src/iosTest/kotlin/com/pasich/encly/ios/security/BackgroundLockGuardTest.kt @@ -0,0 +1,100 @@ +package com.pasich.encly.ios.security + +import platform.UIKit.UIBackgroundTaskIdentifier +import platform.UIKit.UIBackgroundTaskInvalid +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The guard against a stand-in for iOS' background tasks. */ +class BackgroundLockGuardTest { + private class FakeTasks(var grant: Boolean = true) : BackgroundTasks { + var expire: (() -> Unit)? = null + val begun = mutableListOf() + val ended = mutableListOf() + + override fun begin(name: String, onExpire: () -> Unit): UIBackgroundTaskIdentifier { + begun += name + expire = onExpire + return if (grant) 7uL else UIBackgroundTaskInvalid + } + + override fun end(task: UIBackgroundTaskIdentifier) { + ended += task + } + } + + private val tasks = FakeTasks() + private var open = true + private var locks = 0 + + /** A lock that closes the vault on the spot, like a lock with no editor to wait for. */ + private val synchronousLock: () -> Unit = { + locks++ + open = false + } + + /** A lock that has to wait for an editor save: the vault is still open when it returns. */ + private val waitingLock: () -> Unit = { locks++ } + + private fun guard(lock: () -> Unit) = BackgroundLockGuard({ open }, lock, tasks) + + @Test + fun nothingStartsWithoutAnOpenVault() { + open = false + + guard(synchronousLock).begin() + + assertTrue(tasks.begun.isEmpty()) + } + + @Test + fun aRefusedTaskLocksAtOnce() { + tasks.grant = false + val guard = guard(waitingLock) + + guard.begin() + + assertEquals(1, locks) + assertTrue(tasks.ended.isEmpty()) + } + + @Test + fun expiryLocksAndGivesTheTaskBackOnceTheVaultIsClosed() { + val guard = guard(synchronousLock) + guard.begin() + + tasks.expire!!.invoke() + + assertEquals(1, locks) + assertEquals(listOf(7uL), tasks.ended) + } + + @Test + fun expiryWithTheVaultStillOpenKeepsTheTaskUntilTheLockReportsLocked() { + val guard = guard(waitingLock) + guard.begin() + + tasks.expire!!.invoke() + + assertEquals(1, locks) + assertTrue(tasks.ended.isEmpty()) + + // The lock closes the vault after its wait; the app's `locked` collector then ends the task. + open = false + guard.end() + assertEquals(listOf(7uL), tasks.ended) + guard.end() + assertEquals(1, tasks.ended.size) + } + + @Test + fun onlyOneTaskRunsAtATime() { + val guard = guard(waitingLock) + + guard.begin() + guard.begin() + + assertEquals(1, tasks.begun.size) + } +} diff --git a/shared/src/iosTest/kotlin/com/pasich/encly/ios/security/IosKeyboardPrivacyTest.kt b/shared/src/iosTest/kotlin/com/pasich/encly/ios/security/IosKeyboardPrivacyTest.kt new file mode 100644 index 00000000..6a4690ac --- /dev/null +++ b/shared/src/iosTest/kotlin/com/pasich/encly/ios/security/IosKeyboardPrivacyTest.kt @@ -0,0 +1,41 @@ +package com.pasich.encly.ios.security + +import platform.Foundation.NSProgress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The traits that stop the keyboard from learning. */ +class IosKeyboardPrivacyTest { + @Test + fun aReplacedGetterAnswersNoAndOnlyThatGetterIsTouched() { + // NSProgress.totalUnitCount is an NSInteger getter of a class nothing else here uses: it stands in for the + // trait getters of Compose's text input view, which UIKit's own classes do not implement as real methods. + val replaced = IosKeyboardPrivacy.install("NSProgress", getters = listOf("totalUnitCount", "noSuchGetter")) + + assertEquals(1, replaced) + val progress = NSProgress() + progress.completedUnitCount = 7 + assertEquals(1L, progress.totalUnitCount) + assertEquals(7L, progress.completedUnitCount) + } + + @Test + fun installingTwiceIsHarmless() { + assertEquals(1, IosKeyboardPrivacy.install("NSProgress", getters = listOf("totalUnitCount"))) + assertEquals(1, IosKeyboardPrivacy.install("NSProgress", getters = listOf("totalUnitCount"))) + } + + @Test + fun aClassThatIsNotThereIsLeftAlone() { + assertEquals(0, IosKeyboardPrivacy.install("NoSuchTextInputView")) + } + + @Test + fun composeTextInputViewIsFoundOrTheUpgradeIsNoticed() { + // The class is looked up by name at runtime. If it is linked into the test binary it must carry the traits; + // if not, the lookup says 0 and the gap is real only in the app, which the Xcode build covers. + val replaced = IosKeyboardPrivacy.install() + assertTrue(replaced == 0 || replaced >= 5, "replaced $replaced") + } +} diff --git a/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt new file mode 100644 index 00000000..0ca5c186 --- /dev/null +++ b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt @@ -0,0 +1,6 @@ +package com.pasich.encly.core.di + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal actual val platformIoDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/export/JvmUnicode.kt b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/export/JvmUnicode.kt new file mode 100644 index 00000000..fda2c998 --- /dev/null +++ b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/export/JvmUnicode.kt @@ -0,0 +1,61 @@ +package com.pasich.encly.data.export + +import java.text.Normalizer + +/* + * The JVM side (Android and desktop) of the file-name rules in MarkdownFileNames.kt: Unicode + * normalization and the Unicode category of a code point have no Kotlin common equivalent, so they + * live here, the one file of the export that uses java.text and java.lang.Character. + */ + +private const val TAB = 0x09 +private const val LINE_FEED = 0x0A +private const val CARRIAGE_RETURN = 0x0D +private val FORBIDDEN = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|') + +/** [text] in Unicode normalization form C. */ +internal actual fun normalizeNfc(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFC) + +/** + * [text] without the control, format, separator, private-use, unassigned and lone-surrogate + * characters and without the characters file systems reject; every kind of space becomes one + * plain space. (Runs of spaces, dots at the ends and reserved names are the caller's.) + */ +internal actual fun keepFileNameCharacters(text: String): String { + val kept = StringBuilder(text.length) + var index = 0 + while (index < text.length) { + val cp = text.codePointAt(index) + index += Character.charCount(cp) + when { + Character.isISOControl(cp) && cp != TAB && cp != LINE_FEED && cp != CARRIAGE_RETURN -> Unit + Character.isSpaceChar(cp) || Character.isWhitespace(cp) -> kept.append(' ') + cp.toChar() in FORBIDDEN && cp < Character.MIN_SUPPLEMENTARY_CODE_POINT -> Unit + isPlain(cp) -> kept.appendCodePoint(cp) + } + } + return kept.toString() +} + +/** Not a control, format, separator, private-use, unassigned or lone surrogate character. */ +private fun isPlain(cp: Int): Boolean = when (Character.getType(cp).toByte()) { + Character.CONTROL, Character.FORMAT, Character.LINE_SEPARATOR, Character.PARAGRAPH_SEPARATOR, + Character.PRIVATE_USE, Character.UNASSIGNED, Character.SURROGATE, + -> false + + else -> true +} + +/** The longest prefix of [text] that is at most [bytes] bytes of UTF-8, ending on a character. */ +internal actual fun utf8Prefix(text: String, bytes: Int): String { + var used = 0 + var end = 0 + while (end < text.length) { + val cp = text.codePointAt(end) + val size = String(Character.toChars(cp)).toByteArray().size + if (used + size > bytes) break + used += size + end += Character.charCount(cp) + } + return text.substring(0, end) +} diff --git a/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/importer/OutOfMemory.kt b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/importer/OutOfMemory.kt new file mode 100644 index 00000000..efa77ee6 --- /dev/null +++ b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/importer/OutOfMemory.kt @@ -0,0 +1,3 @@ +package com.pasich.encly.data.importer + +internal actual fun Throwable.isOutOfMemory(): Boolean = this is OutOfMemoryError diff --git a/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/zip/JvmZip.kt b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/zip/JvmZip.kt new file mode 100644 index 00000000..81f7c663 --- /dev/null +++ b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/data/zip/JvmZip.kt @@ -0,0 +1,98 @@ +package com.pasich.encly.data.zip + +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.Sink +import kotlinx.io.asOutputStream +import kotlinx.io.asSource +import kotlinx.io.files.Path +import java.io.File +import java.nio.charset.Charset +import java.util.zip.CRC32 +import java.util.zip.DataFormatException +import java.util.zip.Inflater +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +/* + * The JVM side (Android and desktop) of ZipPlatform.kt: the one file that uses java.util.zip. + */ + +internal actual fun newCrc32(): Crc32 = object : Crc32 { + private val crc = CRC32() + + override val value: Long get() = crc.value + + override fun update(bytes: ByteArray, offset: Int, length: Int) = crc.update(bytes, offset, length) +} + +internal actual fun newRawInflater(): RawInflater = object : RawInflater { + private val inflater = Inflater(true) + + override val remaining: Int get() = inflater.remaining + override val bytesRead: Long get() = inflater.bytesRead + override val bytesWritten: Long get() = inflater.bytesWritten + + override fun needsInput() = inflater.needsInput() + + override fun needsDictionary() = inflater.needsDictionary() + + override fun finished() = inflater.finished() + + override fun setInput(bytes: ByteArray, offset: Int, length: Int) = inflater.setInput(bytes, offset, length) + + override fun inflate(bytes: ByteArray, offset: Int, length: Int): Int = try { + inflater.inflate(bytes, offset, length) + } catch (_: DataFormatException) { + throw IOException("bad zip data") + } + + override fun end() = inflater.end() +} + +private val cp437: Charset = try { + Charset.forName("IBM437") +} catch (_: IllegalArgumentException) { + Charsets.ISO_8859_1 +} + +/** [bytes] as CP437, the ZIP format's default name encoding (ISO-8859-1 where the JVM has no CP437). */ +internal actual fun decodeCp437(bytes: ByteArray): String = String(bytes, cp437) + +/** The ZIP at [path], opened with [ZipFile]; throws an [IOException] if it is not one. */ +internal actual fun openZipFile(path: Path): ZipEntrySource = object : ZipEntrySource { + private val archive = ZipFile(File(path.toString())) + + override val entryCount: Int get() = archive.size() + + override fun find(name: String): ZipEntryHandle? = archive.getEntry(name)?.let { entry -> + object : ZipEntryHandle { + override val name: String get() = entry.name + override val isDirectory: Boolean get() = entry.isDirectory + override val declaredSize: Long get() = entry.size + + override fun open(): RawSource = archive.getInputStream(entry).asSource() + } + } + + override fun close() = archive.close() +} + +/** A [ZipOutputStream] over [out]; an entry without a time gets [now] (epoch millis). */ +internal actual fun zipWriter(out: Sink, now: () -> Long): ZipEntrySink = object : ZipEntrySink { + private val zip = ZipOutputStream(out.asOutputStream()) + + override fun put(name: String, time: Long?, bytes: ByteArray) { + val entry = ZipEntry(name) + entry.time = time ?: now() + zip.putNextEntry(entry) + zip.write(bytes) + zip.closeEntry() + } + + override fun finish() { + zip.finish() + out.flush() + } +} diff --git a/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.kt b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.kt new file mode 100644 index 00000000..76116265 --- /dev/null +++ b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.kt @@ -0,0 +1,10 @@ +package com.pasich.encly.domain.enums + +import java.text.Collator +import java.util.Locale + +// SECONDARY: letters differ by base letter and accent, not by case. +internal actual fun titleCollator(): Comparator { + val collator = Collator.getInstance(Locale.getDefault()).apply { strength = Collator.SECONDARY } + return Comparator { a, b -> collator.compare(a, b) } +} diff --git a/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/utils/LocalizedDateJvm.kt b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/utils/LocalizedDateJvm.kt new file mode 100644 index 00000000..09e72cc6 --- /dev/null +++ b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/utils/LocalizedDateJvm.kt @@ -0,0 +1,54 @@ +package com.pasich.encly.utils + +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaZoneId +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Instant + +/* + * Date formatting on the JVM (Android and desktop), through java.text. The locale's own order for the + * day-and-month styles comes from the platform ([bestPattern]): Android has it in android.text.format. + */ + +/** [instant] in [zone], written as [style] in the language [languageTag]. */ +internal fun formatLocalizedJvm( + instant: Instant, + style: LocalizedDate, + languageTag: String, + zone: TimeZone, + bestPattern: (Locale, String) -> String, +): String { + val format = formats.getOrPut(style to languageTag) { + newFormat(style, dateLocale(languageTag), bestPattern) + } + // A DateFormat is not thread-safe, and the cached one may last longer than the zone it was made in. + return synchronized(format) { + format.timeZone = java.util.TimeZone.getTimeZone(zone.toJavaZoneId()) + format.format(Date(instant.toEpochMilliseconds())) + } +} + +private val formats = ConcurrentHashMap, DateFormat>() + +private fun newFormat(style: LocalizedDate, locale: Locale, bestPattern: (Locale, String) -> String): DateFormat = + when (style) { + LocalizedDate.TIME -> DateFormat.getTimeInstance(DateFormat.SHORT, locale) + LocalizedDate.DATE_TIME_SHORT -> DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, locale) + LocalizedDate.DATE_TIME_MEDIUM -> DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale) + LocalizedDate.DAY_MONTH -> SimpleDateFormat(bestPattern(locale, "dMMM"), locale) + LocalizedDate.DAY_MONTH_YEAR -> SimpleDateFormat(bestPattern(locale, "dMMMy"), locale) + } + +/** + * The locale to format dates in. Arabic keeps the Western digits the rest of the UI shows (counts and + * times come out of the string resources as 0-9), unless the tag asks for other digits itself. + */ +internal fun dateLocale(languageTag: String): Locale { + val locale = Locale.forLanguageTag(languageTag) + if (locale.language != "ar" || locale.getUnicodeLocaleType("nu") != null) return locale + return Locale.Builder().setLocale(locale).setUnicodeLocaleKeyword("nu", "latn").build() +} diff --git a/app/src/test/java/com/pasich/encly/core/ExternalLinksTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/ExternalLinksTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/core/ExternalLinksTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/ExternalLinksTest.kt diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecureDatabaseVaultTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecureDatabaseVaultTest.kt new file mode 100644 index 00000000..b62c662f --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecureDatabaseVaultTest.kt @@ -0,0 +1,62 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.data.database.VaultFile +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SecureDatabaseVaultTest { + private val manager = mock(SecureDatabaseManager::class.java) + private val vault = SecureDatabaseVault(manager) + private val dek = ByteArray(32) { it.toByte() } + + @Test + fun theStateQuestionsAreTheManagersAnswers() { + `when`(manager.isDatabaseUnlocked()).thenReturn(true) + `when`(manager.hasEncryptedDatabase(VaultFile.PRIMARY)).thenReturn(true) + `when`(manager.hasEncryptedDatabase(VaultFile.DECOY)).thenReturn(false) + + assertTrue(vault.isDatabaseUnlocked()) + assertTrue(vault.hasEncryptedDatabase(VaultKind.PRIMARY)) + assertFalse(vault.hasEncryptedDatabase(VaultKind.DECOY)) + } + + @Test + fun aVaultKindNamesItsDatabaseFile() { + `when`(manager.unlockDatabase(dek, true, VaultFile.DECOY)).thenReturn(true) + + assertTrue(vault.unlockDatabase(dek, allowCreate = true, vault = VaultKind.DECOY)) + verify(manager).unlockDatabase(dek, true, VaultFile.DECOY) + } + + @Test + fun unlockingWithoutAKindOpensThePrimaryVault() { + `when`(manager.unlockDatabase(dek, false)).thenReturn(true) + + assertTrue(vault.unlockDatabase(dek, allowCreate = false)) + verify(manager).unlockDatabase(dek, false) + } + + @Test + fun resetAndWipeGoToTheManager() { + vault.reset() + vault.wipe() + + verify(manager).reset() + verify(manager).wipe() + } + + @Test + fun wipingAClosedVaultMapsTheKindAndNoneMeansEveryFile() { + `when`(manager.wipeIfClosed(VaultFile.PRIMARY)).thenReturn(true) + `when`(manager.wipeIfClosed(null)).thenReturn(false) + + assertTrue(vault.wipeIfClosed(VaultKind.PRIMARY)) + assertEquals(false, vault.wipeIfClosed(null)) + } +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupDocumentsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupDocumentsTest.kt new file mode 100644 index 00000000..36525334 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupDocumentsTest.kt @@ -0,0 +1,182 @@ +package com.pasich.encly.data.backup + +import com.pasich.encly.core.backup.BackupCipher +import com.pasich.encly.core.backup.BackupError +import com.pasich.encly.core.backup.BackupFormat +import com.pasich.encly.core.backup.BackupSecret +import com.pasich.encly.core.backup.assertError +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.platform.PlatformDocuments +import com.pasich.encly.testutil.FakeDocuments +import kotlinx.io.asSource +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer + +class BackupDocumentsTest { + + private val backup = BackupCipher.seal(ByteArray(200_000) { it.toByte() }, BackupSecret.RecoveryRoot(ByteArray(32))) + + // --- reading -------------------------------------------------------------------------- + + @Test + fun readsABackupSizedFromItsHeader() { + val input = CountingInputStream(backup) + + assertArrayEquals(backup, BackupDocuments.readCapped(input.asSource(), limit = backup.size)) + } + + @Test + fun rejectsAFileDeclaringMoreThanTheLimitWithoutReadingIt() { + val input = CountingInputStream(backup) + + assertError(BackupError.TOO_LARGE) { BackupDocuments.readCapped(input.asSource(), limit = backup.size - 1) } + assertEquals(BackupFormat.HEADER_LENGTH, input.consumed) + } + + @Test + fun rejectsAnUnsignedLengthAboveIntMax() { + val header = backup.copyOf(BackupFormat.HEADER_LENGTH) + ByteBuffer.wrap(header).putInt(LENGTH_OFFSET, -1) // 0xFFFFFFFF + + assertError(BackupError.TOO_LARGE) { BackupDocuments.readCapped(ByteArrayInputStream(header).asSource()) } + } + + @Test + fun rejectsDataPastTheDeclaredEnd() { + assertError(BackupError.CORRUPTED) { + BackupDocuments.readCapped(ByteArrayInputStream(backup + byteArrayOf(0)).asSource()) + } + } + + @Test + fun aShortFileIsLeftForParseToReportTruncated() { + val short = BackupDocuments.readCapped(ByteArrayInputStream(backup.copyOf(backup.size - 1)).asSource()) + + assertEquals(backup.size - 1, short.size) + assertError(BackupError.TRUNCATED) { BackupFormat.parse(short) } + } + + @Test + fun otherFilesAreRejectedAfterTheirFirstBytes() { + val input = CountingInputStream(ByteArray(1_000_000)) + + assertError(BackupError.NOT_A_BACKUP) { BackupDocuments.readCapped(input.asSource()) } + assertEquals(BackupFormat.HEADER_LENGTH, input.consumed) + } + + // --- writing -------------------------------------------------------------------------- + + private val fake = FakeDocuments() + private val documents = BackupDocuments(fake) + private val doc = DocumentRef("content://test/backup.enclybak") + + @Test + fun writesToANewDocumentAndChecksItBack() { + documents.write(doc, backup) + + assertArrayEquals(backup, fake.files[doc]) + assertEquals(emptyList(), fake.deleted) + } + + @Test + fun neverOverwritesAnExistingBackup() { + val previous = backup.copyOf() + fake.files[doc] = previous + + assertError(BackupError.TARGET_EXISTS) { documents.write(doc, byteArrayOf(1, 2, 3)) } + + assertEquals(0, fake.opensForWrite) + assertArrayEquals(previous, fake.files[doc]) + } + + @Test + fun aWriteThatDoesNotReadBackIsAnErrorAndLeavesNoDocument() { + fake.losesLastByte = true + + assertError(BackupError.IO) { documents.write(doc, backup) } + + assertEquals(listOf(doc), fake.deleted) + } + + @Test + fun aFailedWriteIsAnIoError() { + fake.writeFailure = IOException("No space left") + + assertError(BackupError.IO) { documents.write(doc, backup) } + } + + @Test + fun aProviderThatCannotDeleteDoesNotHideTheWriteError() { + fake.losesLastByte = true + val stubborn = object : PlatformDocuments by fake { + override fun delete(doc: DocumentRef) = throw UnsupportedOperationException("no delete") + } + + assertError(BackupError.IO) { BackupDocuments(stubborn).write(doc, backup) } + } + + @Test + fun providerRuntimeExceptionsBecomeIoErrorsNotCrashes() { + listOf( + IllegalArgumentException("Unknown URI"), + IllegalStateException("provider died"), + UnsupportedOperationException("mode wt"), + NullPointerException(), + SecurityException("grant revoked"), + ).forEach { failure -> + fake.readFailure = failure + + assertError(BackupError.IO) { documents.read(doc) } + assertError(BackupError.IO) { documents.write(doc, backup) } + } + } + + @Test + fun aMissingStreamIsAnIoError() { + fake.noStream = true + + assertError(BackupError.IO) { documents.read(doc) } + } + + @Test + fun readsTheDocumentThroughTheCap() { + fake.files[doc] = backup + + assertArrayEquals(backup, documents.read(doc)) + } + + @Test + fun theDisplayNameIsWhatTheProviderSaysAndNullWhenItFails() { + fake.names[doc] = "backup.enclybak" + assertEquals("backup.enclybak", documents.displayName(doc)) + + fake.readFailure = IllegalStateException("provider died") + assertEquals(null, documents.displayName(doc)) + } + + private class CountingInputStream(bytes: ByteArray) : InputStream() { + private val input = ByteArrayInputStream(bytes) + var consumed = 0 + private set + + override fun read(): Int = input.read().also { if (it >= 0) consumed++ } + + override fun read(b: ByteArray, off: Int, len: Int): Int = input.read(b, off, len).also { + if (it > + 0 + ) { + consumed += it + } + } + } + + private companion object { + const val LENGTH_OFFSET = 57 + } +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt new file mode 100644 index 00000000..ccf12dd1 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt @@ -0,0 +1,221 @@ +package com.pasich.encly.data.backup + +import com.pasich.encly.core.backup.BackupCipher +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.BackupNote +import com.pasich.encly.core.backup.BackupPayload +import com.pasich.encly.core.backup.BackupPayloadCodec +import com.pasich.encly.core.backup.BackupSecret +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.data.model.Note +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.testutil.InMemoryVaultDataStore +import kotlinx.coroutines.test.runTest +import kotlinx.io.asSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import java.io.ByteArrayInputStream + +class BackupManagerTest { + + private lateinit var prefs: InMemoryKeyValueStore + private lateinit var manager: BackupManager + private lateinit var security: SecurityManager + private val preferences = FakePreferences() + + @Before + fun setUp() = runTest { + security = mock(SecurityManager::class.java) + // A fresh copy per call: createBackup wipes the key it was handed. + `when`(security.copyBackupRootKey()).thenAnswer { ByteArray(ROOT_LENGTH) { 5 } } + val store = InMemoryVaultDataStore().apply { + insertNote(Note(title = "Plan", value = "[]", uid = "n-plan")) + } + preferences.notes = { store.notes.map { it.uid } } + prefs = InMemoryKeyValueStore() + manager = BackupManager(security, store, prefs, preferences).apply { clock = { 1L } } + } + + @Test + fun aBackupExactlyAtTheImportLimitIsWrittenAndReadsBack() = runTest { + val size = manager.createBackup().size + manager.maxFileBytes = size + + val file = manager.createBackup() + + assertEquals(size, file.size) + val read = BackupDocuments.readCapped(ByteArrayInputStream(file).asSource(), limit = size) + assertEquals(size, BackupFormat.parse(read).file.size) + } + + @Test + fun aVaultTooLargeToImportIsRefusedInsteadOfExported() = runTest { + val size = manager.createBackup().size + manager.maxFileBytes = size - 1 + + try { + manager.createBackup() + fail("expected TOO_LARGE") + } catch (e: BackupException) { + assertEquals(BackupError.TOO_LARGE, e.error) + } + // Nothing is recorded as exported. + assertNull(manager.lastExportAt()) + } + + @Test + fun theDefaultExportLimitIsTheImportLimit() { + 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, InMemoryKeyValueStore(), NoBackupPreferences).createBackup() + fail("expected a closed-session failure") + } catch (e: BackupException) { + assertEquals(BackupError.IO, e.error) + } + } + + @Test + fun theDecoyVaultRefusesEveryBackupStepAsAStorageError() = runTest { + val security = mock(SecurityManager::class.java) + `when`(security.isDecoySession()).thenReturn(true) + val decoy = BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore(), NoBackupPreferences) + 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) + } + + @Test + fun theBackupCarriesOnlyTheAllowListedPreferences() = runTest { + preferences.values = mapOf("theme_type" to "DARK", "pin.slot" to "secret", "onboarding_shown_v3" to "true") + + val payload = decrypt(manager.createBackup()) + + assertEquals(mapOf("theme_type" to "DARK"), payload.settings) + assertEquals(BackupPayload.SCHEMA_VERSION, payload.schema) + } + + @Test + fun aBackupIsStillMadeWhenThePreferencesCannotBeRead() = runTest { + preferences.failOnSnapshot = true + + val payload = decrypt(manager.createBackup()) + + assertEquals(emptyMap(), payload.settings) + assertEquals(1, payload.notes.size) + } + + @Test + fun replaceRestoresPreferencesAfterTheNotes() = runTest { + val payload = payloadWith(mapOf("theme_type" to "DARK")) + + manager.import(payload, ImportMode.REPLACE) + + assertEquals(listOf(mapOf("theme_type" to "DARK")), preferences.applied) + assertEquals(listOf(listOf("n-new")), preferences.notesWhenApplied) + } + + @Test + fun mergeKeepsThePreferencesOfThisInstall() = runTest { + manager.import(payloadWith(mapOf("theme_type" to "DARK")), ImportMode.MERGE) + + assertEquals(emptyList>(), preferences.applied) + } + + @Test + fun aFailedImportRestoresNoPreferences() = runTest { + val store = InMemoryVaultDataStore().apply { failOnInsert = 1 } + val failing = BackupManager(security, store, InMemoryKeyValueStore(), preferences) + + val result = runCatching { failing.import(payloadWith(mapOf("theme_type" to "DARK")), ImportMode.REPLACE) } + + assertEquals(BackupError.IO, (result.exceptionOrNull() as BackupException).error) + assertEquals(emptyList>(), preferences.applied) + } + + @Test + fun aBackupWithoutPreferencesRestoresAsBefore() = runTest { + val summary = manager.import(payloadWith(emptyMap()), ImportMode.REPLACE) + + assertEquals(1, summary.notesAdded) + assertEquals(emptyList>(), preferences.applied) + } + + @Test + fun preferencesThatCannotBeAppliedNeverFailTheRestore() = runTest { + preferences.failOnApply = true + + val summary = manager.import(payloadWith(mapOf("theme_type" to "DARK")), ImportMode.REPLACE) + + assertEquals(1, summary.notesAdded) + } + + private fun payloadWith(settings: Map) = BackupPayload( + exportedAt = 0, + tags = emptyList(), + notes = listOf( + BackupNote("n-new", "New", "[]", "", 1L, 1L, isTrash = false), + ), + tasks = emptyList(), + settings = settings, + ) + + private fun decrypt(file: ByteArray): BackupPayload = BackupPayloadCodec.decode( + BackupCipher.open(file, BackupSecret.RecoveryRoot(ByteArray(ROOT_LENGTH) { 5 })), + ) + + private class FakePreferences : BackupPreferences { + var values: Map = emptyMap() + var failOnSnapshot = false + var failOnApply = false + val applied = mutableListOf>() + val notesWhenApplied = mutableListOf>() + var notes: () -> List = { emptyList() } + + override suspend fun snapshot(): Map { + if (failOnSnapshot) error("unreadable") + return values + } + + override suspend fun apply(raw: Map) { + applied += raw + notesWhenApplied += notes() + if (failOnApply) error("unwritable") + } + } + + private companion object { + const val ROOT_LENGTH = 32 + } +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/PreferenceKeysClassifiedTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/PreferenceKeysClassifiedTest.kt new file mode 100644 index 00000000..b4d621b1 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/PreferenceKeysClassifiedTest.kt @@ -0,0 +1,153 @@ +package com.pasich.encly.data.backup + +import com.pasich.encly.core.backup.BackupSettings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The guard behind the backup allow-list: every key the app reads or writes in its flag store + * (`KeyValueStore`) or its DataStore must be classified in [BackupSettings], either as a + * preference that is backed up ([BackupSettings.included]) or as one that is not, with a reason + * ([BackupSettings.excluded]). Adding a preference without deciding fails here, so a new + * setting is never silently left out of a backup, and a security flag is never silently put in. + * + * It reads the production sources: a key is the first argument of a `getString`, `getBoolean`, + * `getLong`, `putString`, `putBoolean`, `putLong` or `...PreferencesKey` call in a file that + * uses `KeyValueStore` or a DataStore, resolved through `const val` names (keys are UPPER_CASE + * constants; a literal works too). The vault state + * file (PIN, lockout, recovery slots) is a different store and is not scanned; it is never + * backed up. + */ +class PreferenceKeysClassifiedTest { + + @Test + fun everyPreferenceKeyTheAppUsesIsClassified() { + val unclassified = usedKeys().filterKeys { it !in BackupSettings.included && it !in BackupSettings.excluded } + + assertTrue( + "Classify these preference keys in BackupSettings (included = backed up, excluded = not, with a " + + "reason): ${unclassified.map { (key, where) -> "$key (${where.joinToString()})" }}", + unclassified.isEmpty(), + ) + } + + @Test + fun noClassifiedKeyIsStale() { + val used = usedKeys().keys + val stale = (BackupSettings.included.keys + BackupSettings.excluded.keys).filter { it !in used } + + assertEquals("Keys in BackupSettings that no source uses any more", emptyList(), stale) + } + + @Test + fun theScanSeesTheKeysItIsMeantToGuard() { + val used = usedKeys().keys + + // If the scan silently found nothing, the two tests above would pass for the wrong reason. + assertTrue(used.containsAll(listOf("theme_type", "auto_lock_delay", "onboarding_shown_v3", "app_language"))) + } + + /** Key -> the files that use it. */ + private fun usedKeys(): Map> { + val sources = productionSources() + val consts = constants(sources) + val keys = linkedMapOf>() + sources.filter { (_, text) -> USES_PREFERENCES.containsMatchIn(text) } + .filterNot { (file, _) -> file.name in STORE_IMPLEMENTATIONS } + .forEach { (file, text) -> + CALL.findAll(text).forEach { match -> + val literal = match.groupValues[1] + val reference = match.groupValues[2] + // A lower-case name is a parameter (`key`) or a resource (`Res.string.x`), not a key. + if (literal.isEmpty() && !CONSTANT_NAME.matches(reference.substringAfterLast('.'))) return@forEach + val values = if (literal.isNotEmpty()) setOf(literal) else resolve(reference, consts) + check(values.isNotEmpty()) { + "${file.name}: cannot tell which preference key `${match.value.trim()}` is; " + + "use a named string constant" + } + values.forEach { keys.getOrPut(it) { linkedSetOf() }.add(file.name) } + } + } + return keys + } + + private fun resolve(reference: String, consts: Map>): Set = + consts[reference.substringAfterLast('.')].orEmpty() + + /** `const val NAME = "value"` and `const val NAME = Other.NAME`, followed to the literal. */ + private fun constants(sources: List>): Map> { + val literal = mutableMapOf>() + val alias = mutableMapOf>() + sources.forEach { (_, text) -> + CONST.findAll(text).forEach { match -> + val name = match.groupValues[1] + val string = match.groupValues[2] + val other = match.groupValues[3] + if (string.isNotEmpty()) { + literal.getOrPut(name) { linkedSetOf() }.add(string) + } else { + alias.getOrPut(name) { linkedSetOf() }.add(other.substringAfterLast('.')) + } + } + } + repeat(ALIAS_DEPTH) { + alias.forEach { (name, targets) -> + targets.forEach { target -> + literal[target]?.let { literal.getOrPut(name) { linkedSetOf() }.addAll(it) } + } + } + } + return literal + } + + private fun productionSources(): List> { + val root = generateSequence(File("").absoluteFile) { it.parentFile } + .first { File(it, "settings.gradle.kts").isFile } + return root.walkTopDown() + .onEnter { dir -> !dir.name.startsWith(".") && dir.name !in SKIPPED_DIRECTORIES } + .filter { it.isFile && it.extension == "kt" && isProduction(it, root) } + .map { it to it.readText() } + .toList() + } + + /** `/src//...` where the set is `main` or ends in `Main` (commonMain, desktopMain, ...). */ + private fun isProduction(file: File, root: File): Boolean { + val parts = file.relativeTo(root).invariantSeparatorsPath.split('/') + val src = parts.indexOf("src") + if (src < 0 || src + 1 >= parts.size) return false + val set = parts[src + 1] + return set == "main" || set.endsWith("Main") + } + + private companion object { + val SKIPPED_DIRECTORIES = setOf("build", "node_modules", "notes") + + /** The interface and its three platform implementations take keys as parameters, not as keys. */ + val STORE_IMPLEMENTATIONS = setOf( + "KeyValueStore.kt", + "SharedPreferencesKeyValueStore.kt", + "FileKeyValueStore.kt", + "IosKeyValueStore.kt", + ) + + val USES_PREFERENCES = Regex("""KeyValueStore|PreferencesKey\(""") + + /** A store call's first argument: a string literal (group 1) or a name (group 2). */ + val CALL = Regex( + """\b(?:getString|getBoolean|getLong|putString|putBoolean|putLong|""" + + """(?:boolean|int|string|long|double|float|stringSet)PreferencesKey)""" + + """\(\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_.]*))""", + ) + + val CONSTANT_NAME = Regex("[A-Z][A-Z0-9_]*") + + val CONST = Regex( + """const\s+val\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*String\s*)?=\s*""" + + """(?:"([^"]*)"|([A-Za-z_][A-Za-z0-9_.]*))""", + ) + + const val ALIAS_DEPTH = 3 + } +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/SettingsBackupTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/SettingsBackupTest.kt new file mode 100644 index 00000000..5f3636ef --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/SettingsBackupTest.kt @@ -0,0 +1,205 @@ +package com.pasich.encly.data.backup + +import com.pasich.encly.core.backup.BackupSettings +import com.pasich.encly.core.locale.AppLanguage +import com.pasich.encly.core.security.AutoLock +import com.pasich.encly.core.security.AutoLockDelay +import com.pasich.encly.core.security.KeyboardPrivacy +import com.pasich.encly.data.datasource.local.MAX_FONT_SIZE +import com.pasich.encly.data.datasource.local.MIN_FONT_SIZE +import com.pasich.encly.domain.enums.NoteSortOption +import com.pasich.encly.domain.model.EditorToolbar +import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.NoteLineSpacing +import com.pasich.encly.domain.model.NotePreviews +import com.pasich.encly.domain.model.SwipeAction +import com.pasich.encly.domain.model.SwipeSide +import com.pasich.encly.domain.model.ThemePalette +import com.pasich.encly.domain.model.ThemeType +import com.pasich.encly.domain.model.ToolbarLayout +import com.pasich.encly.domain.model.ToolbarSection +import com.pasich.encly.domain.model.ToolbarTool +import com.pasich.encly.platform.AppLocaleController +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.testutil.TestSettingsRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SettingsBackupTest { + + private val flags = InMemoryKeyValueStore() + private val repository = TestSettingsRepository() + private val locales = FakeLocales() + private val autoLock = AutoLock(flags) + private val keyboard = KeyboardPrivacy(flags) + private val previews = NotePreviews(flags) + private val toolbar = EditorToolbar(flags) + private val backup = SettingsBackup(repository, autoLock, keyboard, previews, locales, toolbar) + + @Test + fun theSnapshotHoldsExactlyTheAllowListedKeys() = runTest { + assertEquals(BackupSettings.included.keys, backup.snapshot().keys) + } + + @Test + fun everySnapshotValueIsOneTheRestoreAccepts() = runTest { + val snapshot = backup.snapshot() + + assertEquals(snapshot, BackupSettings.accepted(snapshot)) + } + + @Test + fun whatIsExportedIsRestoredOnAnotherInstall() = runTest { + repository.setDynamicTheme(true) + repository.setThemeType(ThemeType.DARK) + repository.setThemePalette(ThemePalette.OCEAN) + repository.setFontStyle(FontStyleType.TECH_MINIMAL) + repository.setFontSize(21) + repository.setNoteLineSpacing(NoteLineSpacing.COMFORTABLE) + repository.setGridNoteList(true) + repository.setSortNotes(NoteSortOption.CREATED_ASC) + repository.setShowTasks(false) + repository.setSimpleEdit(true) + repository.setSwipeEnabled(false) + repository.setSwipeAction(SwipeSide.RIGHT, SwipeAction.DELETE) + repository.setSwipeAction(SwipeSide.LEFT, SwipeAction.NONE) + autoLock.setDelay(AutoLockDelay.MINUTES_5) + keyboard.setStrict(true) + previews.setShow(false) + locales.apply(AppLanguage.GERMAN) + val exported = backup.snapshot() + + val other = fresh() + other.backup.apply(exported) + + assertEquals(exported, other.backup.snapshot()) + assertEquals(AppLanguage.GERMAN, other.locales.current()) + assertEquals(AutoLockDelay.MINUTES_5, other.autoLock.delay.value) + assertTrue(other.keyboard.strict.value) + assertFalse(other.previews.show.value) + } + + @Test + fun unknownKeysAndBadValuesAreIgnoredPerKey() = runTest { + val target = fresh() + + target.backup.apply( + mapOf( + BackupSettings.THEME_TYPE to "DARK", + BackupSettings.THEME_PALETTE to "NEON", + BackupSettings.FONT_SIZE to "999", + BackupSettings.AUTO_LOCK_DELAY to "NEVER", + BackupSettings.APP_LANGUAGE to "KLINGON", + "pin.slot" to "x", + "from_the_future" to "1", + ), + ) + + val after = target.backup.snapshot() + assertEquals("DARK", after[BackupSettings.THEME_TYPE]) + assertEquals(ThemePalette.DEFAULT.name, after[BackupSettings.THEME_PALETTE]) + assertEquals("16", after[BackupSettings.FONT_SIZE]) + assertEquals(AutoLockDelay.DEFAULT.name, after[BackupSettings.AUTO_LOCK_DELAY]) + assertEquals(AppLanguage.SYSTEM, target.locales.current()) + } + + @Test + fun securityStateInAFileIsNeverWrittenToTheFlagStore() = runTest { + val target = fresh() + + target.backup.apply( + mapOf( + "onboarding_shown_v3" to "true", + "whats_new_last_seen_version" to "9.9.9", + "backup_last_export_at" to "5", + "auth.biometric_enabled" to "true", + "pin.wipe.slot" to "x", + "lockout.failures" to "0", + ), + ) + + listOf( + "onboarding_shown_v3", + "whats_new_last_seen_version", + "backup_last_export_at", + "auth.biometric_enabled", + "pin.wipe.slot", + "lockout.failures", + ).forEach { assertFalse(it, target.flags.contains(it)) } + } + + @Test + fun aFailingStoreSkipsOnlyItsOwnKey() = runTest { + val failing = object : TestSettingsRepository() { + override suspend fun setThemeType(value: ThemeType) = error("disk full") + } + val target = fresh(failing) + + target.backup.apply( + mapOf(BackupSettings.THEME_TYPE to "DARK", BackupSettings.FONT_SIZE to "20"), + ) + + assertEquals(20, failing.fontSizeFlow.value) + } + + @Test + fun everyBackupNameIsAnEntryOfItsEnum() { + assertEquals(ThemeType.entries.map { it.name }, BackupSettings.THEME_TYPES) + assertEquals(ThemePalette.entries.map { it.name }, BackupSettings.THEME_PALETTES) + assertEquals(FontStyleType.entries.map { it.name }, BackupSettings.FONT_STYLES) + assertEquals(NoteLineSpacing.entries.map { it.name }, BackupSettings.LINE_SPACINGS) + assertEquals(NoteSortOption.entries.map { it.name }, BackupSettings.SORT_OPTIONS) + assertEquals(ToolbarTool.entries.map { it.name }, BackupSettings.TOOLBAR_TOOLS) + assertEquals(ToolbarSection.entries.map { it.name.lowercase() }, BackupSettings.TOOLBAR_SECTIONS) + assertEquals(SwipeAction.entries.map { it.name }, BackupSettings.SWIPE_ACTIONS) + assertEquals(AppLanguage.entries.map { it.name }, BackupSettings.LANGUAGES) + assertEquals(MIN_FONT_SIZE, BackupSettings.MIN_FONT_SIZE) + assertEquals(MAX_FONT_SIZE, BackupSettings.MAX_FONT_SIZE) + } + + @Test + fun theToolbarLayoutIsExportedAndRestored() = runTest { + val custom = ToolbarLayout.DEFAULT.moved(ToolbarTool.TABLE, -20).toggled(ToolbarTool.QUOTE) + toolbar.set(custom) + val snapshot = backup.snapshot() + + val other = fresh() + other.backup.apply(snapshot) + + assertEquals(custom, other.toolbar.layout.value) + } + + @Test + fun aToolbarLayoutThatNamesNoToolIsNotRestored() = runTest { + val other = fresh() + other.backup.apply(mapOf(BackupSettings.EDITOR_TOOLBAR to "main:NOT_A_TOOL")) + other.backup.apply(mapOf(BackupSettings.EDITOR_TOOLBAR to "tools:HEADING")) + + assertEquals(ToolbarLayout.DEFAULT, other.toolbar.layout.value) + } + + private fun fresh(settings: TestSettingsRepository = TestSettingsRepository()) = Target(settings) + + private class Target(settings: TestSettingsRepository) { + val flags = InMemoryKeyValueStore() + val locales = FakeLocales() + val autoLock = AutoLock(flags) + val keyboard = KeyboardPrivacy(flags) + val previews = NotePreviews(flags) + val toolbar = EditorToolbar(flags) + val backup = SettingsBackup(settings, autoLock, keyboard, previews, locales, toolbar) + } + + private class FakeLocales : AppLocaleController { + private var language = AppLanguage.SYSTEM + + override fun current(): AppLanguage = language + + override fun apply(language: AppLanguage) { + this.language = language + } + } +} diff --git a/app/src/test/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownDocumentsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownDocumentsTest.kt new file mode 100644 index 00000000..c74bca11 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownDocumentsTest.kt @@ -0,0 +1,91 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.core.backup.BackupError +import com.pasich.encly.core.backup.assertError +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.testutil.FakeDocuments +import kotlinx.io.IOException +import kotlinx.io.writeString +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * A failed export removes the empty document the save dialog created, and nothing else: a + * document that holds data (an existing file a provider handed back) is never deleted. + */ +class MarkdownDocumentsTest { + private val fake = FakeDocuments() + private val documents = MarkdownDocuments(fake) + private val doc = DocumentRef("content://test/note.md") + + @Test + fun anEmptyDocumentIsDeleted() { + documents.discard(doc) + + assertEquals(listOf(doc), fake.deleted) + } + + @Test + fun aDocumentWithDataIsNeverDeleted() { + fake.files[doc] = "keep me".toByteArray() + + documents.discard(doc) + + assertEquals(emptyList(), fake.deleted) + } + + @Test + fun anUnreadableDocumentIsLeftAlone() { + fake.readFailure = SecurityException("grant revoked") + + documents.discard(doc) + + assertEquals(emptyList(), fake.deleted) + } + + @Test + fun streamsTheContentIntoTheDocument() { + documents.write(doc) { it.writeString("# Title") } + + assertEquals("# Title", fake.files.getValue(doc).decodeToString()) + assertEquals(emptyList(), fake.deleted) + } + + @Test + fun neverOverwritesADocumentThatHoldsData() { + fake.files[doc] = "old".toByteArray() + + assertError(BackupError.TARGET_EXISTS) { documents.write(doc) { it.writeString("new") } } + + assertEquals("old", fake.files.getValue(doc).decodeToString()) + assertEquals(0, fake.opensForWrite) + } + + @Test + fun aFailureWhileWritingDeletesTheIncompleteDocument() { + assertError(BackupError.IO) { documents.write(doc) { throw IOException("No space left") } } + + assertEquals(listOf(doc), fake.deleted) + } + + @Test + fun providerFailuresBecomeIoErrorsWithoutTheirMessage() { + listOf(SecurityException("grant revoked at /secret/path"), IllegalStateException("provider died")).forEach { + fake.writeFailure = it + + val failure = runCatching { documents.write(doc) { sink -> sink.writeString("x") } }.exceptionOrNull() + + assertEquals(BackupError.IO, (failure as com.pasich.encly.core.backup.BackupException).error) + assertEquals(BackupError.IO.name, failure.message) + } + } + + @Test + fun aProviderWithNoStreamIsAnIoError() { + val noWrite = object : com.pasich.encly.platform.PlatformDocuments by fake { + override fun openWrite(doc: DocumentRef) = null + } + + assertError(BackupError.IO) { MarkdownDocuments(noWrite).write(doc) { it.writeString("x") } } + } +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownExportTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownExportTest.kt new file mode 100644 index 00000000..3a850abe --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownExportTest.kt @@ -0,0 +1,326 @@ +package com.pasich.encly.data.export + +import com.pasich.encly.data.backup.VaultSnapshot +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Tag +import com.pasich.encly.data.model.Task +import com.pasich.encly.data.zip.zipWriter +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.io.Buffer +import kotlinx.io.asSink +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import kotlinx.io.readString +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.TimeZone +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +class MarkdownExportTest { + + private val labels = MarkdownLabels( + untitled = "Untitled", + unreadable = "Unreadable", + tasks = "Tasks", + priorities = mapOf(0 to "Low", 1 to "Medium", 2 to "High"), + ) + + private fun body(vararg blocks: Block) = BlockConverter.blocksToJson(blocks.toList()) + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun note(id: Long, title: String, vararg blocks: Block, block: Note.() -> Unit = {}) = + Note(id = id, title = title, value = body(*blocks), date = 1_700_000_000_000, dateCreate = 1_690_000_000_000) + .apply(block) + + /** The zip's entries by name, in order. */ + private fun unzip(bytes: ByteArray): Map { + val entries = LinkedHashMap() + ZipInputStream(ByteArrayInputStream(bytes)).use { zip -> + generateSequence { zip.nextEntry }.forEach { entry -> + assertTrue("duplicate entry ${entry.name}", entry.name !in entries) + entries[entry.name] = String(zip.readBytes(), Charsets.UTF_8) + } + } + return entries + } + + private fun exportAll(snapshot: VaultSnapshot): Map { + val out = Buffer() + MarkdownExport.writeAll(snapshot, labels, out) + return unzip(out.readByteArray()) + } + + @Test + fun theZipHoldsOneFilePerNoteAndATasksFile() { + val files = exportAll( + VaultSnapshot( + notes = listOf(note(1, "Groceries", text("milk")), note(2, "Ideas", text("one"))), + tags = emptyList(), + tasks = emptyList(), + ), + ) + + assertEquals(setOf("tasks.md", "Groceries.md", "Ideas.md"), files.keys) + assertTrue(files.getValue("Groceries.md").endsWith("# Groceries\n\nmilk\n")) + } + + @Test + fun archivedNotesGoToTheArchiveFolderAndTrashedOnesAreLeftOut() { + val files = exportAll( + VaultSnapshot( + notes = listOf( + note(1, "Live", text("a")), + note(2, "Old", text("b")) { isArchived = true }, + note(3, "Gone", text("c")) { isTrash = true }, + note(4, "Old and gone", text("d")) { + isArchived = true + isTrash = true + }, + ), + tags = emptyList(), + tasks = emptyList(), + ), + ) + + assertEquals(setOf("tasks.md", "Live.md", "archive/Old.md"), files.keys) + assertFalse(files.values.any { it.contains("Gone") }) + } + + @Test + fun notesWithTheSameTitleOrAReservedNameGetUniqueFiles() { + val files = exportAll( + VaultSnapshot( + notes = listOf( + note(1, "Plan", text("1")), + note(2, "plan", text("2")), + note(3, "tasks", text("3")), + note(4, "", text("4")), + note(5, "../../etc/passwd", text("5")), + note(6, "Plan", text("6")) { isArchived = true }, + ), + tags = emptyList(), + tasks = emptyList(), + ), + ) + + assertEquals( + setOf( + "tasks.md", + "Plan.md", + "plan (2).md", + "tasks (2).md", + "Untitled.md", + "etcpasswd.md", + "archive/Plan.md", + ), + files.keys, + ) + assertTrue(files.keys.none { it.startsWith("/") || ".." in it }) + } + + @Test + fun theTagsAreInTheFrontMatterInTheirOrderAndHiddenTagsAreIncluded() { + val files = exportAll( + VaultSnapshot( + notes = listOf(note(1, "Secret plan", text("x"))), + tags = listOf( + Tag(id = 7, nameTag = "Private", isVisible = false, position = 1), + Tag(id = 8, nameTag = "Work"), + ), + tasks = emptyList(), + noteTags = listOf(NoteTag(1, 7), NoteTag(1, 8)), + ), + ) + + assertTrue(files.getValue("Secret plan.md").contains("tags: [\"Work\", \"Private\"]\n")) + } + + @Test + fun theTasksFileHasCheckboxesSubtasksAndPriorities() { + val tasks = listOf( + Task(id = 1, title = "Buy milk", description = "Two litres", priority = 2, categoryId = 7, position = 1), + Task(id = 2, title = "Call mom", isCompleted = true, priority = 0, position = 0), + Task(id = 3, title = "Pay rent", priority = 1, position = 2), + ) + val subtasks = listOf( + Subtask(id = 1, taskId = 1, title = "Oat", position = 0), + Subtask(id = 2, taskId = 1, title = "Soy", isCompleted = true, position = 1), + ) + val files = exportAll( + VaultSnapshot( + notes = emptyList(), + tags = listOf(Tag(id = 7, nameTag = "Home")), + tasks = tasks, + subtasks = subtasks, + ), + ) + + assertEquals( + "# Tasks\n\n" + + "- [ ] Buy milk (High, Home)\n" + + " > Two litres\n" + + " - [ ] Oat\n" + + " - [x] Soy\n" + + "- [ ] Pay rent (Medium)\n" + + "- [x] Call mom (Low)\n", + files.getValue("tasks.md"), + ) + } + + @Test + fun anEmptyVaultStillWritesAValidZip() { + val files = exportAll(VaultSnapshot(emptyList(), emptyList(), emptyList())) + + assertEquals(setOf("tasks.md"), files.keys) + assertEquals("# Tasks\n\n", files.getValue("tasks.md")) + } + + @Test + fun aSingleNoteIsOneMarkdownDocument() { + val snapshot = VaultSnapshot( + notes = listOf( + note(1, "One", text("first")), + note(2, "Two", text("second")), + note(3, "Trashed", text("x")) { isTrash = true }, + ), + tags = emptyList(), + tasks = emptyList(), + ) + val out = Buffer() + + assertTrue(MarkdownExport.writeNote(snapshot, 2, labels, out)) + val markdown = out.readString() + + assertTrue(markdown.startsWith("---\ntitle: \"Two\"\n")) + assertTrue(markdown.endsWith("# Two\n\nsecond\n")) + assertFalse(markdown.contains("first")) + assertFalse(MarkdownExport.writeNote(snapshot, 3, labels, Buffer())) + assertFalse(MarkdownExport.writeNote(snapshot, 99, labels, Buffer())) + } + + @Test + fun aManyNoteVaultKeepsEveryFileUnique() { + val notes = (1L..200L).map { note(it, "Same title", text("n$it")) } + val files = exportAll(VaultSnapshot(notes, emptyList(), emptyList())) + + assertEquals(201, files.size) + assertNotNull(files["Same title (200).md"]) + } + + @Test + fun theBlocksOfEveryTypeAreInTheFile() { + val items = MutableStateFlow(listOf(ItemListBlock("x", isCheck = true))) + val markdown = MarkdownNotes.render( + note(1, "All", text("t"), Block.ListBlock(items, BlockType.LIST_CHECK), Block.SeparatorBlock()), + emptyList(), + labels, + ) + + assertTrue(markdown.endsWith("# All\n\nt\n\n- [x] x\n\n---\n")) + } + + // The zip's bytes: the same entries written with plain java.util.zip, at a fixed clock and time zone. + + private val zone = TimeZone.getDefault() + + @Before + fun fixTimeZone() = TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + + @After + fun restoreTimeZone() = TimeZone.setDefault(zone) + + private fun goldenSnapshot(): VaultSnapshot { + val notes = listOf( + note(1, "Groceries", text("milk")), + note(2, "\u041f\u0440\u0438\u0432\u0456\u0442", text("emoji \uD83D lone surrogate")), + note(3, "Old", text("b")) { isArchived = true }, + note(4, "Same", text("1")) { date = EPOCH_1980 }, + note(5, "Same", text("2")) { date = EPOCH_1980 - 1 }, + note(6, "Gone", text("c")) { isTrash = true }, + ) + val tags = listOf(Tag(id = 7, nameTag = "Home")) + val tasks = listOf(Task(id = 1, title = "Buy milk", priority = 1, position = 0)) + return VaultSnapshot(notes, tags, tasks, noteTags = listOf(NoteTag(2, 7))) + } + + private fun goldenZip(snapshot: VaultSnapshot): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + fun put(name: String, time: Long?, text: String) { + zip.putNextEntry(ZipEntry(name).apply { this.time = time ?: CLOCK }) + zip.write(text.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + fun render(id: Long): String { + val note = snapshot.notes.first { it.id == id } + return MarkdownNotes.render(note, snapshot.tagsOf(note.id).map { it.nameTag }, labels) + } + put("tasks.md", null, MarkdownTasks.render(snapshot.tasks, snapshot.subtasks, snapshot.tags, labels)) + put("Groceries.md", 1_700_000_000_000, render(1)) + put("$CYRILLIC.md", 1_700_000_000_000, render(2)) + put("archive/Old.md", 1_700_000_000_000, render(3)) + put("Same.md", EPOCH_1980, render(4)) + // Before 1980 a zip entry has no usable time: it gets the clock's. + put("Same (2).md", null, render(5)) + } + return out.toByteArray() + } + + @Test + fun theZipIsByteForByteWhatPlainJavaUtilZipWrites() { + val snapshot = goldenSnapshot() + val out = Buffer() + + MarkdownExport.writeAll(snapshot, labels, zipWriter(out) { CLOCK }) + + assertArrayEquals(goldenZip(snapshot), out.readByteArray()) + } + + @Test + fun theZipIsCompleteOnceWriteAllReturnsWithoutClosingTheSink() { + val bytes = ByteArrayOutputStream() + val sink = bytes.asSink().buffered() + + MarkdownExport.writeAll(goldenSnapshot(), labels, sink) + + assertEquals( + setOf("tasks.md", "Groceries.md", "$CYRILLIC.md", "archive/Old.md", "Same.md", "Same (2).md"), + unzip(bytes.toByteArray()).keys, + ) + } + + @Test + fun aSingleNoteIsItsUtf8BytesWithALoneSurrogateAsQuestionMark() { + val snapshot = VaultSnapshot(listOf(note(1, "One", text("a \uD83D b"))), emptyList(), emptyList()) + val out = Buffer() + + MarkdownExport.writeNote(snapshot, 1, labels, out) + + assertArrayEquals( + MarkdownNotes.render(snapshot.notes.single(), emptyList(), labels).toByteArray(Charsets.UTF_8), + out.readByteArray(), + ) + } + + private companion object { + const val EPOCH_1980 = 315_532_800_000L + const val CLOCK = 1_800_000_000_000L + const val CYRILLIC = "\u041f\u0440\u0438\u0432\u0456\u0442" + } +} diff --git a/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownNotesTest.kt similarity index 80% rename from app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownNotesTest.kt index 09374416..219418a8 100644 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownNotesTest.kt @@ -2,9 +2,9 @@ package com.pasich.encly.data.export import com.pasich.encly.data.importer.Frontmatter import com.pasich.encly.data.model.Note -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -15,7 +15,17 @@ class MarkdownNotesTest { private val labels = MarkdownLabels("Untitled", "Cannot read", "Tasks", emptyMap()) - private fun render(note: Note, tag: String? = null) = MarkdownNotes.render(note, tag, labels) + private fun render(note: Note, tag: String? = null) = MarkdownNotes.render(note, listOfNotNull(tag), labels) + + @Test + fun timesKeepTheirMillisecondsAndDropNothingElse() { + val markdown = render( + Note(title = "Plan", value = "[]", date = 1_700_000_000_120, dateCreate = 1_700_000_000_500), + ) + + assertTrue(markdown, markdown.contains("created: 2023-11-14T22:13:20.500Z\n")) + assertTrue(markdown, markdown.contains("updated: 2023-11-14T22:13:20.120Z\n")) + } @Test fun theFrontMatterCarriesTheNoteDetails() { @@ -78,6 +88,15 @@ class MarkdownNotesTest { assertEquals("2023-11-14T22:13:20Z", doc.value("updated")) } + @Test + fun everyTagReadsBackWholeEvenWithACommaOrAQuote() { + val tags = listOf("Work", "Home, Inc", "a \"q\"") + val markdown = MarkdownNotes.render(Note(title = "Plan", value = "[]"), tags, labels) + + assertTrue(markdown, markdown.contains("tags: [\"Work\", \"Home, Inc\", \"a \\\"q\\\"\"]\n")) + assertEquals(tags, Frontmatter.split(markdown).list("tags")) + } + @Test fun aNoteWithoutATitleIsCalledUntitledButGetsNoHeading() { val markdown = render(Note(value = "[]")) @@ -130,7 +149,7 @@ class MarkdownNotesTest { val note = Note(title = "Plan", value = "[]") assertEquals( "# Plan\n", - MarkdownNotes.render(note, null, labels).substringAfter("---\n\n"), + MarkdownNotes.render(note, emptyList(), labels).substringAfter("---\n\n"), ) assertEquals("# Plan\n", MarkdownNotes.renderBody(note.title, emptyList())) } diff --git a/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/HandoffFixtures.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/HandoffFixtures.kt index e81e8861..fabdf338 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/HandoffFixtures.kt @@ -1,5 +1,6 @@ package com.pasich.encly.data.handoff +import kotlinx.io.files.Path import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -90,3 +91,7 @@ internal object HandoffFixtures { fun read(dir: File, json: JsonElement): MyNotesHandoff = MyNotesHandoffReader.read(handoffZip(dir, json)) } + +/** The hand-off in the ZIP file [zip], opened with the platform's ZIP support (what the app does after staging it). */ +internal fun MyNotesHandoffReader.read(zip: File, limits: HandoffLimits = HandoffLimits()): MyNotesHandoff = + HandoffStaging.read(Path(zip.path), limits) diff --git a/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/HandoffStagingTest.kt similarity index 76% rename from app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/HandoffStagingTest.kt index 3cb9a6dd..17ebac61 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/HandoffStagingTest.kt @@ -3,6 +3,8 @@ package com.pasich.encly.data.handoff import com.pasich.encly.data.handoff.HandoffFixtures.handoff import com.pasich.encly.data.handoff.HandoffFixtures.handoffZip import com.pasich.encly.data.handoff.HandoffFixtures.note +import kotlinx.io.asSource +import kotlinx.io.files.Path import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -20,10 +22,12 @@ class HandoffStagingTest { private val stagingDir: File get() = File(temp.root, HandoffStaging.DIR_NAME) + private fun staging(limits: HandoffLimits = HandoffLimits()) = HandoffStaging(Path(stagingDir.path), limits) + @Test fun loadsAndLeavesNothingBehind() { val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1")))) - val loaded = HandoffStaging(stagingDir).load { zip.inputStream() } + val loaded = staging().load { zip.inputStream().asSource() } assertEquals(1, loaded.preview.notes) assertFalse(stagingDir.exists()) } @@ -31,11 +35,11 @@ class HandoffStagingTest { @Test fun aFailedReadLeavesNothingBehind() { assertError(HandoffError.INVALID_PAYLOAD) { - HandoffStaging(stagingDir).load { "not a zip".byteInputStream() } + staging().load { "not a zip".byteInputStream().asSource() } } assertFalse(stagingDir.exists()) assertError(HandoffError.TOO_LARGE) { - HandoffStaging(stagingDir, HandoffLimits(maxZipBytes = 8)).load { ByteArray(64).inputStream() } + staging(HandoffLimits(maxZipBytes = 8)).load { ByteArray(64).inputStream().asSource() } } assertFalse(stagingDir.exists()) } @@ -44,21 +48,21 @@ class HandoffStagingTest { fun anUnreadableUriLeavesNothingBehind() { val failing = object : InputStream() { override fun read(): Int = throw IOException("revoked") - } + }.asSource() try { - HandoffStaging(stagingDir).load { failing } + staging().load { failing } } catch (_: IOException) { // Expected: the ViewModel reports it as "failed". } assertFalse(stagingDir.exists()) - assertError(HandoffError.INVALID_PAYLOAD) { HandoffStaging(stagingDir).load { null } } + assertError(HandoffError.INVALID_PAYLOAD) { staging().load { null } } } @Test fun leftoversOfAKilledRunAreSwept() { stagingDir.mkdirs() File(stagingDir, "handoff-123.zip").writeText("plaintext from a run that died") - HandoffStaging.sweep(stagingDir) + HandoffStaging.sweep(Path(stagingDir.path)) assertFalse(stagingDir.exists()) } @@ -68,11 +72,11 @@ class HandoffStagingTest { File(stagingDir.apply { mkdirs() }, "handoff-old.zip").writeText("left by a killed run") var during: List = emptyList() - val loaded = HandoffStaging(stagingDir).load { + val loaded = staging().load { // Another hand-off, or the app's start, sweeps while this one is copying. - HandoffStaging.sweep(stagingDir) + HandoffStaging.sweep(Path(stagingDir.path)) during = stagingDir.list().orEmpty().toList() - zip.inputStream() + zip.inputStream().asSource() } assertEquals(1, loaded.preview.notes) @@ -87,13 +91,13 @@ class HandoffStagingTest { var inner: HandoffImport? = null var filesDuringInner = 0 - val outer = HandoffStaging(stagingDir).load { - inner = HandoffStaging(stagingDir).load { + val outer = staging().load { + inner = staging().load { filesDuringInner = stagingDir.list().orEmpty().size - zip.inputStream() + zip.inputStream().asSource() } // The inner load deleted its own file only, so this one still copies into its file. - zip.inputStream() + zip.inputStream().asSource() } assertEquals(2, filesDuringInner) diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt index 97d2b635..94ef19e4 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt @@ -1,8 +1,8 @@ package com.pasich.encly.data.handoff -import com.pasich.encly.core.serialization.BlockConverter -import com.pasich.encly.domain.model.ItemListBlock -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.ItemListBlock import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt similarity index 93% rename from app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt index 99369bdc..214e4732 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt @@ -1,7 +1,6 @@ package com.pasich.encly.data.handoff import com.pasich.encly.core.backup.BackupPayloadCodec -import com.pasich.encly.core.serialization.BlockConverter import com.pasich.encly.data.backup.BackupImporter import com.pasich.encly.data.backup.ImportMode import com.pasich.encly.data.backup.TagMatch @@ -12,7 +11,8 @@ import com.pasich.encly.data.handoff.HandoffFixtures.read import com.pasich.encly.data.handoff.HandoffFixtures.tag import com.pasich.encly.data.handoff.HandoffFixtures.task import com.pasich.encly.data.model.Tag -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block import com.pasich.encly.testutil.InMemoryVaultDataStore import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject @@ -57,11 +57,11 @@ class MyNotesHandoffImportTest { val payload = result.payload val plan = payload.notes.first { it.uid == "note-a" } assertEquals("Plan", plan.title) - assertEquals("mynotes:tag:1", plan.tagUid) + assertEquals(listOf("mynotes:tag:1"), plan.tagUids) assertEquals(1_700_000_000_000L, plan.dateCreate) val trashed = payload.notes.first { it.uid == "note-b" } assertTrue(trashed.isTrash) - assertNull(trashed.tagUid) + assertEquals(emptyList(), trashed.tagUids) assertTrue(BlockConverter.jsonToBlocks(trashed.value).single() is Block.TextBlock) val milk = payload.tasks.first { it.uid == "mynotes:task:1" } @@ -107,8 +107,8 @@ class MyNotesHandoffImportTest { assertEquals(listOf("work", "Ideas", "Shopping"), store.tags.map { it.nameTag }) assertEquals(2, summary.tagsAdded) val ideas = store.tags.first { it.nameTag == "Ideas" }.id - assertEquals(work, store.notes.first { it.uid == "n1" }.tagId) - assertEquals(ideas, store.notes.first { it.uid == "n2" }.tagId) + assertEquals(setOf(work), store.tagIdsOf(store.notes.first { it.uid == "n1" }.id)) + assertEquals(setOf(ideas), store.tagIdsOf(store.notes.first { it.uid == "n2" }.id)) assertEquals(ideas, store.tasks.first { it.uid == "k1" }.categoryId) assertNull(store.tasks.first { it.uid == "k2" }.categoryId) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt new file mode 100644 index 00000000..1d5c0adf --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt @@ -0,0 +1,263 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.data.handoff.HandoffFixtures.handoff +import com.pasich.encly.data.handoff.HandoffFixtures.handoffZip +import com.pasich.encly.data.handoff.HandoffFixtures.note +import com.pasich.encly.data.handoff.HandoffFixtures.read +import com.pasich.encly.data.handoff.HandoffFixtures.tag +import com.pasich.encly.data.handoff.HandoffFixtures.task +import com.pasich.encly.data.handoff.HandoffFixtures.zip +import com.pasich.encly.data.zip.ZipEntryHandle +import com.pasich.encly.data.zip.ZipEntrySource +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class MyNotesHandoffReaderTest { + + @get:Rule + val temp = TemporaryFolder() + + private val dir: File get() = temp.root + + @Test + fun readsAValidHandoff() { + val parsed = read( + dir, + handoff( + tags = listOf(tag("t1", "Work")), + notes = listOf(note("n1", tag = "Work", isPinned = true, attachments = 2)), + tasks = listOf(task("k1", "Call")), + ), + ) + assertEquals(1, parsed.schema) + assertEquals("Work", parsed.tags.single().name) + assertEquals("Work", parsed.notes.single().tag) + assertEquals(2, parsed.notes.single().attachments) + assertEquals("Call", parsed.tasks.single().description) + } + + @Test + fun unknownKeysAreIgnoredAndOmittedNullsAreNull() { + val noteWithoutNulls = JsonObject(note("n1") - "valueJson" - "tag" + ("color" to JsonPrimitive("red"))) + val json = JsonObject(handoff(notes = listOf(noteWithoutNulls)) + ("future" to JsonPrimitive(true))) + val parsed = read(dir, json) + assertNull(parsed.notes.single().valueJson) + assertNull(parsed.notes.single().tag) + } + + @Test + fun aNewerSchemaAsksForAnUpdate() { + assertError(HandoffError.UNSUPPORTED_SCHEMA) { read(dir, handoff(schema = 2)) } + } + + @Test + fun aNewerSchemaIsReportedEvenIfItsShapeChanged() { + val v2 = buildJsonObject { + put("format", MyNotesHandoff.FORMAT) + put("schema", 2) + put("notes", "a different shape") + } + assertError(HandoffError.UNSUPPORTED_SCHEMA) { read(dir, v2) } + } + + @Test + fun wrongFormatOrSchemaIsInvalid() { + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(format = "encly-backup")) } + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(schema = 0)) } + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, JsonObject(handoff() - "schema")) } + } + + @Test + fun malformedJsonIsInvalid() { + val broken = zip(dir, MyNotesHandoffReader.ENTRY_NAME to "{\"format\": \"mynotes-handoff\", ".toByteArray()) + assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(broken) } + } + + @Test + fun aMissingRequiredFieldOrWrongTypeIsInvalid() { + val untitled = JsonObject(note("n1") - "title") + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(untitled))) } + val stringFlag = JsonObject(note("n1") + ("isTrash" to JsonPrimitive("yes"))) + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(stringFlag))) } + } + + @Test + fun duplicateOrBlankIdsAndUnknownCategoriesAreInvalid() { + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note("n1"), note("n1")))) } + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note(" ")))) } + assertError(HandoffError.INVALID_PAYLOAD) { + read(dir, handoff(tasks = listOf(task("k1", "Call", categoryId = "missing")))) + } + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note("n1", attachments = -1)))) } + } + + @Test + fun notAZipIsInvalid() { + val file = File(dir, "plain.zip").apply { writeText("not a zip at all") } + assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(file) } + } + + @Test + fun theZipMustHoldExactlyHandoffJson() { + val json = handoff().toString().toByteArray() + assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(zip(dir, "other.json" to json)) } + assertError(HandoffError.INVALID_PAYLOAD) { + MyNotesHandoffReader.read(zip(dir, MyNotesHandoffReader.ENTRY_NAME to json, "extra.bin" to byteArrayOf(1))) + } + assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(zip(dir)) } + assertError(HandoffError.INVALID_PAYLOAD) { + MyNotesHandoffReader.read(zip(dir, "../${MyNotesHandoffReader.ENTRY_NAME}" to json)) + } + } + + @Test + fun deeplyNestedUnknownKeysDoNotOverflow() { + val deep = "[".repeat(200_000) + "]".repeat(200_000) + val json = handoff().toString().replaceFirst("{", "{\"future\":$deep,") + val file = zip(dir, MyNotesHandoffReader.ENTRY_NAME to json.toByteArray()) + assertEquals(1, MyNotesHandoffReader.read(file).schema) + } + + @Test + fun aZipBombStopsAtTheUncompressedLimit() { + // A few KB compressed, 8 MB of spaces uncompressed: valid JSON padding, so only the limit stops it. + val padded = handoff().toString().replaceFirst("{", "{" + " ".repeat(8 * 1024 * 1024)) + val bomb = zip(dir, MyNotesHandoffReader.ENTRY_NAME to padded.toByteArray()) + val limits = HandoffLimits(maxJsonBytes = 1024L * 1024) + assertEquals(true, bomb.length() < limits.maxJsonBytes) + assertError(HandoffError.TOO_LARGE) { MyNotesHandoffReader.read(bomb, limits) } + } + + @Test + fun anOversizedZipIsRefusedBeforeReading() { + val file = handoffZip(dir, handoff(notes = listOf(note("n1")))) + assertError(HandoffError.TOO_LARGE) { MyNotesHandoffReader.read(file, HandoffLimits(maxZipBytes = 10)) } + } + + @Test + fun tooManyRecordsOrTooLongTextIsTooLarge() { + val three = handoff(notes = listOf(note("a"), note("b"), note("c"))) + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.read(handoffZip(dir, three), HandoffLimits(maxRecords = 2)) + } + val long = handoff(notes = listOf(note("a", value = "x".repeat(101)))) + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.read(handoffZip(dir, long), HandoffLimits(maxTextChars = 100)) + } + val longName = handoff(tags = listOf(tag("t", "n".repeat(11)))) + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.read(handoffZip(dir, longName), HandoffLimits(maxNameChars = 10)) + } + } + + @Test + fun copyStopsAtTheZipLimit() { + val endless = object : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + sink.write(ByteArray(byteCount.toInt())) + return byteCount + } + + override fun close() = Unit + } + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.copyLimited(endless, Buffer(), HandoffLimits(maxZipBytes = 4096)) + } + val out = Buffer() + MyNotesHandoffReader.copyLimited( + Buffer().apply { + write(ByteArray(4096)) + }, + out, + HandoffLimits(maxZipBytes = 4096), + ) + assertEquals(4096, out.size) + } + + @Test + fun malformedUtf8InsideAStringIsReplacedNotRejected() { + val json = handoff(notes = listOf(note("n1", title = "QB"))).toString().toByteArray() + // The title's "Q" becomes a lone continuation byte, which is not UTF-8. + json[json.indexOf('Q'.code.toByte())] = 0x80.toByte() + val title = MyNotesHandoffReader.read(zip(dir, MyNotesHandoffReader.ENTRY_NAME to json)).notes.single().title + assertEquals(true, title.endsWith("B") && '\uFFFD' in title) + } + + // The checks that need a hostile archive are driven through the platform-free seam. + + private class FakeArchive( + private val entries: List>, + private val data: ByteArray = ByteArray(0), + ) : ZipEntrySource { + override val entryCount: Int get() = entries.size + + override fun find(name: String): ZipEntryHandle? = entries.firstOrNull { it.first == name }?.let { entry -> + object : ZipEntryHandle { + override val name = entry.first + override val isDirectory = entry.second + override val declaredSize = entry.third + + override fun open(): RawSource = Buffer().apply { write(data) } + } + } + + override fun close() = Unit + } + + private fun readFake(archive: FakeArchive, zipBytes: Long = 100, limits: HandoffLimits = HandoffLimits()) = + MyNotesHandoffReader.read(zipBytes, limits) { archive } + + @Test + fun aDirectoryNamedHandoffJsonIsInvalid() { + val archive = FakeArchive(listOf(Triple(MyNotesHandoffReader.ENTRY_NAME, true, 0L))) + assertError(HandoffError.INVALID_PAYLOAD) { readFake(archive) } + } + + @Test + fun aDeclaredSizeOverTheLimitIsTooLargeWithoutReading() { + val archive = FakeArchive(listOf(Triple(MyNotesHandoffReader.ENTRY_NAME, false, 2048L))) + assertError(HandoffError.TOO_LARGE) { readFake(archive, limits = HandoffLimits(maxJsonBytes = 1024)) } + } + + @Test + fun aDeclaredSizeThatLiesStillStopsAtTheStreamLimit() { + val json = handoff().toString().toByteArray() + val archive = FakeArchive(listOf(Triple(MyNotesHandoffReader.ENTRY_NAME, false, 1L)), json) + assertError(HandoffError.TOO_LARGE) { readFake(archive, limits = HandoffLimits(maxJsonBytes = json.size - 1L)) } + assertEquals(1, readFake(archive, limits = HandoffLimits(maxJsonBytes = json.size.toLong())).schema) + } + + @Test + fun twoEntriesOrNoneAreInvalid() { + val two = FakeArchive(listOf(Triple(MyNotesHandoffReader.ENTRY_NAME, false, 1L), Triple("x", false, 1L))) + assertError(HandoffError.INVALID_PAYLOAD) { readFake(two) } + assertError(HandoffError.INVALID_PAYLOAD) { readFake(FakeArchive(emptyList())) } + } + + @Test + fun theZipLengthIsCheckedBeforeTheArchiveIsOpened() { + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.read(11, HandoffLimits(maxZipBytes = 10)) { error("opened") } + } + } +} + +internal fun assertError(expected: HandoffError, block: () -> Unit) { + try { + block() + fail("expected $expected") + } catch (e: HandoffException) { + assertEquals(expected, e.error) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ExpectedNote.kt similarity index 78% rename from app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ExpectedNote.kt index 79de56a7..5c0bc642 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ExpectedNote.kt @@ -1,18 +1,21 @@ 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 com.pasich.encly.editor.codec.BlockConverter import org.junit.Assert.assertEquals internal class ExpectedNote( val title: String, val blocks: List, + /** The note's first tag. */ val tag: String? = null, val created: Long?, val edited: Long?, val trashed: Boolean = false, val losses: ImportLosses = ImportLosses(), + /** How many tags the note has besides [tag]: every one is kept. */ + val moreTags: Int = 0, ) internal fun checkPlan(plan: ImportPlan, expected: List, attachments: Int = 0) { @@ -22,7 +25,8 @@ internal fun checkPlan(plan: ImportPlan, expected: List, attachmen 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)) + assertEquals(want.title, want.tag, note.tagUids.firstOrNull()?.let(tagNames::get)) + assertEquals(want.title, (if (want.tag == null) 0 else 1) + want.moreTags, note.tagUids.size) 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) diff --git a/app/src/test/java/com/pasich/encly/data/importer/FormatDetectorTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/FormatDetectorTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/data/importer/FormatDetectorTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/FormatDetectorTest.kt diff --git a/app/src/test/java/com/pasich/encly/data/importer/FrontmatterTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/FrontmatterTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/data/importer/FrontmatterTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/FrontmatterTest.kt diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportDocumentsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportDocumentsTest.kt new file mode 100644 index 00000000..31e0a6cb --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportDocumentsTest.kt @@ -0,0 +1,84 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.testutil.FakeDocuments +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.readByteArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * The import's file access: the picked files are named by the provider (or "file"), and a file the provider + * refuses reaches the import as one that cannot be read, never as another kind of failure. + */ +class ImportDocumentsTest { + private val fake = FakeDocuments() + private val documents = ImportDocuments(fake) + private val note = DocumentRef("content://test/note") + + private fun RawSource.text(): String = Buffer().also { buffer -> use { it.readAtMostTo(buffer, Long.MAX_VALUE) } } + .readByteArray().decodeToString() + + @Test + fun aGrantedFileIsReadThroughTheStream() { + fake.files[note] = "hello".toByteArray() + + val source = documents.files(listOf(note)).files().single().open() + + assertEquals("hello", checkNotNull(source).text()) + } + + @Test + fun aFileWithoutAMissingStreamIsUnreadable() { + fake.noStream = true + + assertNull(documents.files(listOf(note)).files().single().open()) + } + + @Test + fun aRefusedFileIsAnIoErrorTheReaderCountsAsUnreadable() { + fake.readFailure = IOException("access refused") + + assertThrows(IOException::class.java) { documents.files(listOf(note)).files().single().open() } + } + + @Test + fun theFilesAreNamedByTheProviderOrFile() { + val other = DocumentRef("content://test/other") + fake.names[note] = "Plan.md" + + val names = documents.files(listOf(note, other)).files().map { it.path }.toList() + + assertEquals(listOf("Plan.md", "file"), names) + } + + @Test + fun aNameTheProviderRefusesToGiveIsTheFallback() { + val broken = object : com.pasich.encly.platform.PlatformDocuments by fake { + override fun displayName(doc: DocumentRef): String? = throw IllegalArgumentException("Unknown URI") + } + + assertEquals("file", ImportDocuments(broken).files(listOf(note)).files().single().path) + } + + @Test + fun aFolderIsWalkedThroughItsTree() { + val folder = DocumentRef("content://test/folder") + fake.trees[folder] = object : DocumentTree { + override val rootId = "root" + + override fun children(parentId: String) = listOf(TreeNode("a", "a.md", isDirectory = false)) + + override fun open(id: String): RawSource? = Buffer().apply { write("# A".toByteArray()) } + } + + val file = documents.tree(folder).files().single() + + assertEquals("a.md", file.path) + assertEquals("# A", checkNotNull(file.open()).text()) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportFixtures.kt similarity index 93% rename from app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportFixtures.kt index 0e741e57..ba93cea6 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportFixtures.kt @@ -1,8 +1,9 @@ package com.pasich.encly.data.importer -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType -import java.io.ByteArrayInputStream +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import kotlinx.io.Buffer +import kotlinx.io.asSource import java.io.ByteArrayOutputStream import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -15,7 +16,7 @@ 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) } } + files.asSequence().map { (path, bytes) -> SourceFile(path) { Buffer().apply { write(bytes) } } } } fun textSource(vararg files: Pair): ImportFileSource = @@ -98,7 +99,7 @@ internal object ImportFixtures { TreeNode(it.relativeTo(dir).invariantSeparatorsPath, it.name, it.isDirectory) } - override fun open(id: String) = java.io.File(dir, id).inputStream() + override fun open(id: String) = java.io.File(dir, id).inputStream().asSource() } /** A real fixture folder, walked by the same [TreeWalker] the Storage Access Framework tree uses. */ @@ -132,6 +133,12 @@ internal object ImportFixtures { is Block.LinkBlock -> "link" + is Block.CodeBlock -> "code:${block.text.value}" + + is Block.TableBlock -> "table:" + block.table.value.rows.joinToString("/") { row -> + row.cells.joinToString("|") { it.value } + } + is Block.ListBlock -> { val kind = when (block.blockType) { BlockType.LIST_CHECK -> "check" diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportMapperTest.kt similarity index 92% rename from app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportMapperTest.kt index fa1834df..4760d875 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportMapperTest.kt @@ -5,7 +5,7 @@ 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.editor.model.Block import com.pasich.encly.testutil.InMemoryVaultDataStore import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -92,16 +92,18 @@ class ImportMapperTest { } @Test - fun theFirstTagIsKeptNamesAreMatchedAndCutLikeTheTagEditor() { + fun everyTagIsKeptOnceNamesAreMatchedAndCutLikeTheTagEditor() { val plan = map( - note("a", "x", tags = listOf(" Work Stuff ", "second")), + note("a", "x", tags = listOf(" Work Stuff ", "second", "WORK STUFF")), 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]) + assertEquals(listOf("Work Stuff", "second", "a".repeat(20)), plan.payload.tags.map { it.name }) + val uids = plan.payload.notes.map { it.tagUids } + assertEquals(2, uids[0].size) + assertEquals(uids[0].first(), uids[1].single()) + assertNotEquals(uids[0].first(), uids[2].single()) + assertEquals(listOf("Work Stuff", "second"), plan.rows.first().tags) } @Test @@ -110,7 +112,7 @@ class ImportMapperTest { 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) + assertEquals(setOf(store.tags.single().id), store.tagIdsOf(store.notes.first { it.title == "A" }.id)) } @Test @@ -216,6 +218,6 @@ class ImportMapperTest { note("B", "b", key = "2", tags = listOf("home")), note("C", "c", key = "3", tags = listOf("work")), ) - assertEquals(listOf("Work", "home", "Work"), plan.rows.map { it.tag }) + assertEquals(listOf(listOf("Work"), listOf("home"), listOf("Work")), plan.rows.map { it.tags }) } } diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportReaderTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportReaderTest.kt index 26962aaa..a29f7ac9 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportReaderTest.kt @@ -6,14 +6,13 @@ 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 kotlinx.io.Buffer 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 -import java.io.InputStream class ImportReaderTest { @@ -138,13 +137,12 @@ class ImportReaderTest { sequenceOf( SourceFile("gone.md") { null }, SourceFile("io.md") { throw IOException("x") }, - SourceFile("denied.md") { throw SecurityException("x") }, - SourceFile("ok.md") { ByteArrayInputStream("ok".toByteArray()) }, + SourceFile("ok.md") { Buffer().apply { write("ok".toByteArray()) } }, ) } val result = read(broken) assertEquals(listOf("ok.md"), result.entries.map { it.path }) - assertEquals(3, result.skips.unreadable) + assertEquals(2, result.skips.unreadable) } @Test @@ -306,7 +304,7 @@ class ImportReaderTest { sequenceOf( SourceFile("Vault/pics/Photo.JPG") { throw AssertionError("a photo must not be opened") }, SourceFile("doc.pdf") { throw AssertionError("a PDF must not be opened") }, - SourceFile("note.md") { ByteArrayInputStream("hi".toByteArray()) }, + SourceFile("note.md") { Buffer().apply { write("hi".toByteArray()) } }, ) } val result = read(source) @@ -327,9 +325,9 @@ class ImportReaderTest { override fun children(parentId: String) = kids[parentId].orEmpty() - override fun open(id: String): InputStream { + override fun open(id: String): Buffer { opened += id - return ByteArrayInputStream(id.toByteArray()) + return Buffer().apply { write(id.toByteArray()) } } } val result = ImportReader().read(TreeWalker(tree).source()) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportTimeTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportTimeTest.kt new file mode 100644 index 00000000..1cb23874 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportTimeTest.kt @@ -0,0 +1,201 @@ +package com.pasich.encly.data.importer + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.time.Instant +import java.util.TimeZone + +/** + * What [ImportTime] makes of every shape of date the supported note apps write, in the time zone + * Europe/Kyiv (UTC+3 in summer, UTC+2 in winter). The expectations were taken from the + * implementation that used java.time, so they pin what an import did before kotlinx-datetime. + */ +class ImportTimeTest { + private val savedZone = TimeZone.getDefault() + + @Before + fun useKyiv() = TimeZone.setDefault(TimeZone.getTimeZone("Europe/Kyiv")) + + @After + fun restoreZone() = TimeZone.setDefault(savedZone) + + private fun parsed(raw: String?): String? = ImportTime.parse(raw)?.let { Instant.ofEpochMilli(it).toString() } + + @Test + fun everyKnownShapeParsesAsItAlwaysDid() { + val wrong = CASES.filter { (raw, expected) -> parsed(raw) != expected } + .map { (raw, expected) -> "$raw: expected $expected, got ${parsed(raw)}" } + assertEquals(emptyList(), wrong) + } + + @Test + fun aLocalTimeInTheSpringGapMovesForwardAndInTheAutumnOverlapTakesTheEarlierOffset() { + assertEquals("2026-03-29T01:30:00Z", parsed("2026-03-29 03:30")) + assertEquals("2026-03-29T00:30:00Z", parsed("2026-03-29 02:30")) + assertEquals("2026-10-25T00:30:00Z", parsed("2026-10-25 03:30")) + assertEquals("2026-10-25T02:00:00Z", parsed("2026-10-25 04:00")) + } + + @Test + fun theSameLocalTextFollowsThePhonesZone() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + assertEquals("2026-10-14T22:48:00Z", parsed("14-10-2026 10:48 PM")) + assertEquals("2026-10-14T19:32:53Z", parsed("2026-10-14T19:32:53Z")) + } + + private companion object { + val CASES: List> = listOf( + null to null, + "" to null, + " " to null, + "0" to null, + "-5" to null, + "1700000000" to "2023-11-14T22:13:20Z", + "1700000000000" to "2023-11-14T22:13:20Z", + "1700000000123" to "2023-11-14T22:13:20.123Z", + "99999999999999999999" to null, + "32503680000000" to "3000-01-01T00:00:00Z", + "32503680000001" to null, + "99999999999" to null, + "00000000001" to "1970-01-01T00:00:01Z", + "1" to "1970-01-01T00:00:01Z", + "2026-10-14T19:32:53Z" to "2026-10-14T19:32:53Z", + "2026-10-14T19:32:53.123Z" to "2026-10-14T19:32:53.123Z", + "2026-10-14T19:32:53.123456789Z" to "2026-10-14T19:32:53.123Z", + "2026-10-14T19:32:53.1234567891Z" to null, + "2026-10-14T19:32:53+02:00" to "2026-10-14T17:32:53Z", + "2026-10-14T19:32:53+0200" to null, + "2026-10-14T19:32:53+02" to "2026-10-14T17:32:53Z", + "2026-10-14T19:32:53-05:30" to "2026-10-15T01:02:53Z", + "2026-10-14T19:32:53+02:00:30" to "2026-10-14T17:32:23Z", + "2026-10-14T19:32+02:00" to "2026-10-14T17:32:00Z", + "2026-10-14T19:32Z" to "2026-10-14T19:32:00Z", + "2026-10-14T19:32:53" to "2026-10-14T16:32:53Z", + "2026-10-14T19:32" to "2026-10-14T16:32:00Z", + "2026-10-14T19:32:53.5" to "2026-10-14T16:32:53.500Z", + "2026-10-14" to "2026-10-13T21:00:00Z", + "2026-10-14T19:32:53+02:00[Europe/Paris]" to null, + "2026-10-14t19:32:53z" to "2026-10-14T19:32:53Z", + "2026-10-14T19:32:53z" to "2026-10-14T19:32:53Z", + "2026-10-14 19:32:53Z" to "2026-10-14T19:32:53Z", + "2026-10-14 19:32:53+02:00" to "2026-10-14T17:32:53Z", + "2026-10-14 19:32:53" to "2026-10-14T16:32:53Z", + "2026-10-14 19:32" to "2026-10-14T16:32:00Z", + "2026-10-14 19:32:53.123Z" to "2026-10-14T19:32:53.123Z", + "2026-10-14 19:32:53.123" to "2026-10-14T16:32:53.123Z", + "2026-10-14T24:00:00Z" to "2026-10-15T00:00:00Z", + "2026-10-14T24:00:00" to null, + "2026-06-30T23:59:60Z" to "2026-06-30T23:59:59Z", + "2026-06-30T23:59:60" to null, + "2026-02-30" to null, + "2026-02-30T10:00:00Z" to null, + "2026-13-01" to null, + "20261014T193253Z" to null, + "20261014" to "1970-08-23T12:03:34Z", + "+002026-10-14T19:32:53Z" to "2026-10-14T19:32:53Z", + "+12026-10-14T19:32:53Z" to null, + "0001-01-01" to null, + "0001-01-01T00:00:00Z" to null, + "1970-01-01T00:00:00Z" to null, + "1970-01-01T00:00:01Z" to "1970-01-01T00:00:01Z", + "2999-12-31T23:59:59Z" to "2999-12-31T23:59:59Z", + "3000-01-01T00:00:00Z" to "3000-01-01T00:00:00Z", + "3000-01-01T00:00:01Z" to null, + "2026-10-14T19:32:53 Z" to null, + " 2026-10-14T19:32:53Z " to "2026-10-14T19:32:53Z", + "2026-10-14T19:32:53ZZ" to null, + "2026-10-14T19:32:53+14:00" to "2026-10-14T05:32:53Z", + "2026-10-14T19:32:53+18:00" to "2026-10-14T01:32:53Z", + "2026-10-14T19:32:53+19:00" to null, + "2026-10-14T19:32:53-18:00" to "2026-10-15T13:32:53Z", + "2026-10-14T19:32:53+2:00" to null, + "2026-10-14T19:32:53+02:0" to null, + "2026-10-14T19:32:53UTC" to null, + "2026-10-14T19:32:53GMT" to null, + "2026-10-14T9:32:53Z" to null, + "2026-10-14T19:3:53Z" to null, + "2026-1-14T19:32:53Z" to null, + "2026-1-14" to null, + "2026-10-14T19:32:53,123Z" to null, + "2026-10-14T19:32:53.Z" to "2026-10-14T19:32:53Z", + "2026-10-14T19:32:5Z" to null, + "2026-10-14T19Z" to null, + "2026-10-14T19" to null, + "2026-10-14T" to null, + "2026-10-14 10:48 PM" to "2026-10-14T19:48:00Z", + "2026-10-14, 10:48 am" to "2026-10-14T07:48:00Z", + "2026-10-14 12:30 AM" to "2026-10-13T21:30:00Z", + "2026-10-14 12:30 pm" to "2026-10-14T09:30:00Z", + "2026-10-14 13:00 PM" to "2026-10-14T10:00:00Z", + "2026-10-14 00:30 AM" to "2026-10-13T21:30:00Z", + "2026-10-14 10:48pm" to "2026-10-14T19:48:00Z", + "2026/10/14 22:48" to "2026-10-14T19:48:00Z", + "2026.10.14 22:48:10" to "2026-10-14T19:48:10Z", + "2026-10-14 22:48:10 PM" to "2026-10-14T19:48:10Z", + "2026-10-14 25:00" to null, + "2026-10-14 23:60" to null, + "2026-10-14 23:59:60" to null, + "2026-10-14 23:59:59" to "2026-10-14T20:59:59Z", + "2026-02-30 10:00" to null, + "2026-1-4 5:06" to "2026-01-04T03:06:00Z", + "2026-10-14T10:00 PM" to "2026-10-14T19:00:00Z", + "14-10-2026 10:48 PM" to "2026-10-14T19:48:00Z", + "10/14/2026 22:48" to "2026-10-14T19:48:00Z", + "14.10.2026" to "2026-10-13T21:00:00Z", + "14.10.2026 22:48:10" to "2026-10-14T19:48:10Z", + "14/10/2026" to "2026-10-13T21:00:00Z", + "05-06-2026" to null, + "05-05-2026" to "2026-05-04T21:00:00Z", + "13-12-2026" to "2026-12-12T22:00:00Z", + "12-13-2026" to "2026-12-12T22:00:00Z", + "5-5-2026 7:05 am" to "2026-05-05T04:05:00Z", + "1.1.2026" to "2025-12-31T22:00:00Z", + "31-02-2026" to null, + "02-31-2026" to null, + "31-04-2026" to null, + "29-02-2024" to "2024-02-28T22:00:00Z", + "29-02-2025" to null, + "14-10-2026 25:00" to null, + "14-10-2026 23:59:60" to null, + "14-10-2026,10:48 PM" to "2026-10-14T19:48:00Z", + "14-10-2026T10:48" to "2026-10-14T07:48:00Z", + "14-10-2026 10:48PM" to "2026-10-14T19:48:00Z", + "14-10-2026 10:48 P.M." to null, + "14-10-2026 10:48" to "2026-10-14T07:48:00Z", + "14-10-2026 10:48:10 am" to "2026-10-14T07:48:10Z", + "00-10-2026" to null, + "10-00-2026" to null, + "32-10-2026" to null, + "10-32-2026" to null, + "13-13-2026" to null, + "12-12-2026" to "2026-12-11T22:00:00Z", + "99-99-2026" to null, + "14-10-0000" to null, + "14-10-0001" to null, + "2026-03-29 03:30" to "2026-03-29T01:30:00Z", + "2026-03-29 02:30" to "2026-03-29T00:30:00Z", + "2026-03-29 04:00" to "2026-03-29T01:00:00Z", + "2026-10-25 03:30" to "2026-10-25T00:30:00Z", + "2026-10-25 04:00" to "2026-10-25T02:00:00Z", + "2026-10-25 02:59" to "2026-10-24T23:59:00Z", + "2026-03-29T03:30:00" to "2026-03-29T01:30:00Z", + "2026-03-29" to "2026-03-28T22:00:00Z", + "2026-10-25" to "2026-10-24T21:00:00Z", + "Oct 14, 2026" to null, + "hello" to null, + "2026" to "1970-01-01T00:33:46Z", + "2026-10" to null, + "12:00" to null, + "2026-10-14T19:32:53Z\n" to "2026-10-14T19:32:53Z", + "\u0661\u0664-\u0661\u0660-\u0662\u0660\u0662\u0666" to null, + "\uff12\uff10\uff12\uff16-10-14" to null, + "2026-10-14T\u0661\u0669:32:53Z" to null, + "1700000000.5" to null, + "1.7e9" to null, + "+1700000000" to null, + "\u0663\u0664\u0665" to "1970-01-01T00:05:45Z", + ) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/KeepParserTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/KeepParserTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/data/importer/KeepParserTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/KeepParserTest.kt index 69c8b46d..5c68a292 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/KeepParserTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/KeepParserTest.kt @@ -57,7 +57,7 @@ class KeepParserTest { ), ) assertTrue(note.trashed) - assertEquals(ImportLosses(attachments = 2, pinned = 1, archived = 1, colors = 1, extraTags = 1), note.losses) + assertEquals(ImportLosses(attachments = 2, pinned = 1, archived = 1, colors = 1), note.losses) assertEquals(listOf("A", "B"), note.tags) } diff --git a/app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt index bb8b7a4e..e428c253 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt @@ -1,8 +1,8 @@ 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 com.pasich.encly.editor.codec.BlockConverter import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Test @@ -49,7 +49,7 @@ class NotesnookAndMarkdownFixturesTest { "work", local(14, 22, 48), local(14, 22, 48), - losses = ImportLosses(extraTags = 1), + moreTags = 1, ), ExpectedNote("Ідеї 💡", ideas, null, local(14, 22, 49), local(14, 22, 49)), ExpectedNote( @@ -166,7 +166,7 @@ class NotesnookAndMarkdownFixturesTest { "work", ImportFixtures.NOW, ImportFixtures.NOW, - losses = ImportLosses(extraTags = 1), + moreTags = 1, ), ExpectedNote( "Ідеї 💡", diff --git a/app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/RealExportsTest.kt similarity index 92% rename from app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/RealExportsTest.kt index 44327bb2..75186202 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/RealExportsTest.kt @@ -1,8 +1,8 @@ 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 com.pasich.encly.editor.codec.BlockConverter import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Test @@ -33,7 +33,7 @@ class RealExportsTest { "encly-home", 1792006977756, 1792006990486, - losses = ImportLosses(extraTags = 1), + moreTags = 1, ), ExpectedNote( prefix + "Pinned reminder", @@ -104,8 +104,8 @@ class RealExportsTest { 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) + // Every tag of every note is created: a note keeps all of its tags. + assertEquals(3, plan.payload.tags.size) assertEquals(1, plan.preview.trashed) assertEquals(1, plan.preview.losses.pinned) assertEquals(1, plan.preview.losses.archived) @@ -131,8 +131,8 @@ class RealExportsTest { 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]) + assertEquals("home", tagNames[groceries.tagUids.first()]) + assertEquals("important", tagNames[meeting.tagUids.first()]) val ideas = plan.payload.notes.first { it.title == "Ідеї 💡" } assertEquals( listOf("text:Перша думка.", "text:Друга думка."), @@ -176,12 +176,14 @@ class RealExportsTest { "important", t("2026-10-14T19:32:53.807Z"), t("2026-10-14T19:32:53.807Z"), - losses = ImportLosses(extraTags = 1), + moreTags = 1, ), ExpectedNote( "Call the bank", emptyList(), - "Work", + // The notebook "Work" is the tag "work" that another note carries (one tag per name, the first + // spelling wins). + "work", t("2026-10-14T19:32:55.989Z"), t("2026-10-14T19:32:56.506Z"), losses = ImportLosses(todos = 1), @@ -189,7 +191,9 @@ class RealExportsTest { ExpectedNote( "Renew passport", emptyList(), - "Work", + // The notebook "Work" is the tag "work" that another note carries (one tag per name, the first + // spelling wins). + "work", t("2026-10-14T19:32:57.013Z"), t("2026-10-14T19:32:57.013Z"), losses = ImportLosses(todos = 1), @@ -255,10 +259,10 @@ class RealExportsTest { 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("home", tagNames[groceries.tagUids.first()]) 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("important", tagNames[meeting.tagUids.first()]) assertEquals(ms("2026-10-14T19:32:53Z"), meeting.date) val ideas = plan.payload.notes.first { it.title == "Ідеї 💡" } assertEquals( @@ -266,6 +270,6 @@ class RealExportsTest { 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]) + assertEquals("Work", tagNames[plan.payload.notes.first { it.title == "Call the bank" }.tagUids.first()]) } } diff --git a/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/TextParsersTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/TextParsersTest.kt index b68bcd4e..605ec6a4 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/TextParsersTest.kt @@ -52,7 +52,7 @@ class StandardNotesParserTest { 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) + assertEquals(ImportLosses(pinned = 1, archived = 1), n.losses) } @Test @@ -227,7 +227,6 @@ class JoplinParserTest { 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) @@ -240,7 +239,7 @@ class JoplinParserTest { assertEquals(1, parsed.skips.unsupported) } - private fun describe(blocks: List) = ImportFixtures.describe(blocks) + private fun describe(blocks: List) = ImportFixtures.describe(blocks) } class MarkdownNotesParserTest { @@ -315,7 +314,7 @@ class MarkdownNotesParserTest { 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(ImportLosses(pinned = 1, colors = 1), plan.losses) assertEquals(1_704_067_200_000L, plan.created) val old = parsed.notes.first { it.title == "Old" } assertEquals(listOf("Work"), old.tags) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ZipStreamReaderTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ZipStreamReaderTest.kt new file mode 100644 index 00000000..0ba4ab02 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ZipStreamReaderTest.kt @@ -0,0 +1,259 @@ +package com.pasich.encly.data.importer + +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import kotlinx.io.writeIntLe +import kotlinx.io.writeLongLe +import kotlinx.io.writeShortLe +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.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class ZipStreamReaderTest { + private fun source(bytes: ByteArray) = Buffer().apply { write(bytes) } + + private fun ZipStreamReader.bytes(): ByteArray = data().buffered().readByteArray() + + private fun readAll(zip: ByteArray): List> { + val reader = ZipStreamReader(source(zip)) + val out = ArrayList>() + while (true) { + val entry = reader.next() ?: return out + out += entry.name to reader.bytes() + } + } + + private fun storedZip(data: ByteArray): ByteArray { + 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() + } + return out.toByteArray() + } + + /** The message of the IOException reading [zip] to its end raises. */ + private fun failure(zip: ByteArray): String? = assertThrows(IOException::class.java) { readAll(zip) }.message + + /** Where the data descriptor (signature 0x08074b50) of the first deflated entry starts. */ + private fun descriptorAt(bytes: ByteArray): Int = bytes.indices.first { at -> + at + 3 < bytes.size && bytes[at] == 0x50.toByte() && bytes[at + 1] == 0x4b.toByte() && + bytes[at + 2] == 7.toByte() && bytes[at + 3] == 8.toByte() + } + + @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() + assertArrayEquals(data, readAll(storedZip(data)).single().second) + } + + @Test + fun anEntryLeftUnreadIsSkippedToTheNext() { + val zip = ImportFixtures.zip("a.txt" to ByteArray(50_000) { 1 }, "b.txt" to "b".toByteArray()) + val reader = ZipStreamReader(source(zip)) + assertEquals("a.txt", reader.next()?.name) + assertEquals("b.txt", reader.next()?.name) + assertEquals("b", String(reader.bytes())) + assertNull(reader.next()) + } + + @Test + fun aDamagedStoredEntryFailsItsChecksum() { + val bytes = storedZip("stored text".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() + assertEquals("zip checksum mismatch", failure(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(source(out.toByteArray())) + assertEquals("d/", reader.next()?.name) + assertThrows(IOException::class.java) { reader.next() } + } + + @Test + fun anUnknownSignatureBetweenEntriesIsAnIoErrorNotTheEnd() { + val bytes = ImportFixtures.zipText("a.txt" to "a", "b.txt" to "b") + val second = bytes.indices.drop(1).first { at -> + at + 3 < bytes.size && bytes[at] == 0x50.toByte() && bytes[at + 1] == 0x4b.toByte() && + bytes[at + 2] == 3.toByte() && bytes[at + 3] == 4.toByte() + } + bytes[second + 2] = 9 + val reader = ZipStreamReader(source(bytes)) + assertEquals("a.txt", reader.next()?.name) + assertThrows(IOException::class.java) { reader.next() } + } + + @Test + fun onlyTheCentralDirectoryAnEndRecordOrACleanEndFinishesAnArchive() { + fun ends(third: Int, fourth: Int): Boolean { + val bytes = byteArrayOf(0x50, 0x4b, third.toByte(), fourth.toByte()) + ByteArray(18) + return ZipStreamReader(source(bytes)).next() == null + } + assertEquals(true, ends(0x01, 0x02)) // central directory header + assertEquals(true, ends(0x05, 0x06)) // end of central directory + assertEquals(true, ends(0x06, 0x06)) // ZIP64 end of central directory + assertEquals(true, ends(0x06, 0x08)) // archive extra data + assertNull(ZipStreamReader(source(ByteArray(0))).next()) + } + + @Test + fun aNameThatIsNotUtf8IsReadAsCp437SoDifferentNamesStayDifferent() { + val out = ByteArrayOutputStream() + // Latin-1 name bytes and no UTF-8 flag: 0x82 and 0x83 are not valid UTF-8 on their own. + ZipOutputStream(out, Charsets.ISO_8859_1).use { zip -> + for (name in listOf("\u0082.md", "\u0083.md")) { + zip.putNextEntry(ZipEntry(name)) + zip.write(1) + zip.closeEntry() + } + } + assertEquals(listOf("\u00e9.md", "\u00e2.md"), readAll(out.toByteArray()).map { it.first }) + } + + @Test + fun aUtf8NameIsReadAsUtf8() { + val names = readAll(ImportFixtures.zipText("Привіт/нотатка.md" to "x")).map { it.first } + assertEquals(listOf("Привіт/нотатка.md"), names) + } + + @Test + fun anEncryptedEntryIsRefused() { + val bytes = ImportFixtures.zipText("a.txt" to "a") + bytes[FLAGS_AT] = (bytes[FLAGS_AT].toInt() or 1).toByte() + assertEquals("unsupported zip entry", failure(bytes)) + } + + @Test + fun aMethodOtherThanStoredOrDeflatedIsRefused() { + val bytes = ImportFixtures.zipText("a.txt" to "a") + bytes[METHOD_AT] = 12 // bzip2 + assertEquals("unsupported zip entry", failure(bytes)) + } + + @Test + fun aStoredEntryOfUnknownSizeIsRefused() { + val bytes = storedZip("stored text".toByteArray()) + bytes[FLAGS_AT] = (bytes[FLAGS_AT].toInt() or 8).toByte() // sizes follow the data + assertEquals("stored zip entry of unknown size", failure(bytes)) + } + + @Test + fun aDeflatedEntryWithAWrongChecksumInItsDescriptorIsRefused() { + val bytes = ImportFixtures.zipText("a.txt" to "hello") + bytes[descriptorAt(bytes) + 4] = (bytes[descriptorAt(bytes) + 4].toInt() xor 0x5a).toByte() + assertEquals("zip checksum mismatch", failure(bytes)) + } + + @Test + fun aDescriptorWhoseSizesDoNotMatchWhatWasInflatedIsRefused() { + val bytes = ImportFixtures.zipText("a.txt" to "hello") + val size = descriptorAt(bytes) + 12 // signature, CRC, compressed size, then the size + bytes[size] = (bytes[size].toInt() + 1).toByte() + assertEquals("bad zip data descriptor", failure(bytes)) + } + + @Test + fun corruptDeflateDataIsRefused() { + val bytes = ImportFixtures.zipText("a.txt" to "hello") + bytes[LOCAL_HEADER + "a.txt".length] = 0x07 // a final block of the reserved type 3 + assertEquals("bad zip data", failure(bytes)) + } + + @Test + fun aStoredEntryThatEndsEarlyIsRefusedAsTruncated() { + val bytes = storedZip("stored text".toByteArray()) + assertEquals("truncated zip", failure(bytes.copyOf(LOCAL_HEADER + "s.txt".length + 3))) + } + + @Test + fun aHeaderCutShortIsRefusedAsTruncated() { + assertEquals("truncated zip", failure(ImportFixtures.zipText("a.txt" to "a").copyOf(10))) + } + + @Test + fun theZip64SizesOfAnExtraFieldReplaceTheMarkerSizes() { + // A stored entry whose 32-bit sizes are the 0xFFFFFFFF marker and whose real sizes sit in a ZIP64 extra field. + val data = "zip64 text".toByteArray() + val name = "z.txt".toByteArray() + val extra = Buffer().apply { + writeShortLe(1) + writeShortLe(16) + writeLongLe(data.size.toLong()) + writeLongLe(data.size.toLong()) + }.readByteArray() + val header = Buffer().apply { + writeIntLe(0x04034b50) + writeShortLe(45) + writeShortLe(0) + writeShortLe(0) // stored + writeIntLe(0) + writeIntLe(CRC32().apply { update(data) }.value.toInt()) + writeIntLe(-1) + writeIntLe(-1) + writeShortLe(name.size.toShort()) + writeShortLe(extra.size.toShort()) + write(name) + write(extra) + write(data) + }.readByteArray() + assertArrayEquals(data, readAll(header).single().second) + } + + private companion object { + const val LOCAL_HEADER = 30 + const val FLAGS_AT = 6 + const val METHOD_AT = 8 + } +} diff --git a/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/DaoRepositoriesTest.kt similarity index 88% rename from app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/DaoRepositoriesTest.kt index b34e5a36..8871a97b 100644 --- a/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/DaoRepositoriesTest.kt @@ -7,7 +7,7 @@ import com.pasich.encly.data.database.dao.NotesDao import com.pasich.encly.data.database.dao.TagsDao import com.pasich.encly.data.database.dao.TasksDao import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task import kotlinx.coroutines.CompletableDeferred @@ -82,15 +82,19 @@ class DaoRepositoriesTest { @Test fun noteReadsAndDeletesGoThroughTheDao() = runBlocking { val note = Note(id = 5, title = "n") - val withTag = listOf(NoteWithTag(note, Tag(id = 1))) - `when`(notesDao.getAllNotesWithTags()).thenReturn(flowOf(withTag)) - `when`(notesDao.getNotesByTagId(1)).thenReturn(flowOf(withTag)) - `when`(notesDao.getNoteById(5)).thenReturn(note) + // The tags come sorted by their order, and their ids on the note. + val stored = listOf(NoteWithTags(note, listOf(Tag(id = 2, position = 1), Tag(id = 1, position = 0)))) + val sorted = listOf(Tag(id = 1), Tag(id = 2, position = 1)) + val withTag = listOf(NoteWithTags(note.copy(tagIds = setOf(1, 2)), sorted)) + `when`(notesDao.getAllNotesWithTags()).thenReturn(flowOf(stored)) + `when`(notesDao.getNotesOfTagWithArchived(1)).thenReturn(flowOf(stored)) + `when`(notesDao.getNoteWithTags(5)).thenReturn(NoteWithTags(note, emptyList())) `when`(notesDao.deleteNoteById(5)).thenReturn(1) `when`(notesDao.deleteNoteById(6)).thenReturn(0) assertEquals(withTag, notes.getAllNotesWithTag().first()) - assertEquals(withTag, notes.getNotesByTagId(1).first()) + assertEquals(withTag, notes.getNotesOfTagWithArchived(1).first()) + assertEquals(listOf(1L, 2L), notes.getAllNotesWithTag().first().single().note.tagIds.toList()) assertEquals(note, notes.getNoteById(5)) assertNull(notes.getNoteById(6)) assertTrue(notes.deleteNoteById(5).isSuccess) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/RepositoryImplTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/RepositoryImplTest.kt new file mode 100644 index 00000000..93e9342e --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/RepositoryImplTest.kt @@ -0,0 +1,184 @@ +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 +import com.pasich.encly.data.database.dao.TasksDao +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Tag +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +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` +import java.io.IOException + +/** Repositories report failures as a Result or a failing flow: never a sentinel 0/false. */ +class RepositoryImplTest { + private val notesDao = mock(NotesDao::class.java) + private val tagsDao = mock(TagsDao::class.java) + private val tasksDao = mock(TasksDao::class.java) + private val database = mock(AppDatabase::class.java).also { + `when`(it.notesDao()).thenReturn(notesDao) + `when`(it.tagsDao()).thenReturn(tagsDao) + `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 + } + } + + @Test + fun updatingZeroRowsIsAFailure() = runBlocking { + `when`(notesDao.updateTrashed(NOTE_ID, true, TRASHED_AT)).thenReturn(0) + + assertTrue(NotesRepositoryImpl(provider).setTrashed(NOTE_ID, true, TRASHED_AT).isFailure) + } + + @Test + fun updatingARowSucceeds() = runBlocking { + `when`(notesDao.updateTrashed(NOTE_ID, true, TRASHED_AT)).thenReturn(1) + + assertTrue(NotesRepositoryImpl(provider).setTrashed(NOTE_ID, true, TRASHED_AT).isSuccess) + } + + @Test + fun restoringWritesNoTrashTime() = runBlocking { + `when`(notesDao.updateTrashed(NOTE_ID, false, null)).thenReturn(1) + + assertTrue(NotesRepositoryImpl(provider).setTrashed(NOTE_ID, false, TRASHED_AT).isSuccess) + } + + @Test + fun anEditSavesOnlyTheColumnsItChanged() = runBlocking { + val base = + Note( + id = NOTE_ID, + title = "t", + value = "v", + date = 1L, + color = "teal", + isPinned = true, + tagIds = setOf(1, 2), + ) + val edited = base.copy(value = "w", tagIds = setOf(2, 3)) + // The title and content go; tag 3 on and tag 1 off (2 untouched); the pin, colour and sizes are left to + // whatever the row holds. + `when`( + notesDao.saveEdited( + NOTE_ID, "t", "w", false, "", 1L, false, true, false, "teal", false, null, false, null, + setOf(3L), setOf(1L), + ), + ).thenReturn(1) + + assertTrue(NotesRepositoryImpl(provider).updateEdited(edited, base).isSuccess) + } + + @Test + fun withoutABaseEveryColumnIsWritten() = runBlocking { + val note = Note(id = NOTE_ID, title = "t", value = "v", date = 1L, tagIds = setOf(4)) + `when`(notesDao.updateEditedWithAllTags(note)).thenReturn(1) + + assertTrue(NotesRepositoryImpl(provider).updateEdited(note, null).isSuccess) + } + + @Test + fun aStorageExceptionBecomesAFailureNotACrash() = runBlocking { + val note = Note(id = NOTE_ID) + `when`(notesDao.insertNoteWithTags(note)).thenAnswer { throw IOException("disk") } + + assertTrue(NotesRepositoryImpl(provider).insertNote(note).exceptionOrNull() is IOException) + } + + @Test + fun aLockedVaultFailsTheWriteInsteadOfThrowing() = runBlocking { + locked = true + + 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.insertNoteWithTags(note)).thenReturn(NOTE_ID) + + assertTrue(stale.insertNote(note).isFailure) + assertEquals(NOTE_ID, NotesRepositoryImpl(provider).insertNote(note).getOrNull()) + } + + @Test + fun cancellationIsRethrownNotReportedAsAFailure() { + val cancellation = CancellationException("cancelled") + runBlocking { `when`(notesDao.updateTrashed(NOTE_ID, true, TRASHED_AT)).thenAnswer { throw cancellation } } + + val thrown = runCatching { + runBlocking { NotesRepositoryImpl(provider).setTrashed(NOTE_ID, true, TRASHED_AT) } + }.exceptionOrNull() + + assertSame(cancellation, thrown) + } + + @Test + fun aLockedVaultFailsInsideTheFlowWhereTheScreenHandlesIt() = runBlocking { + locked = true + // Requesting the flow must not throw; collecting it fails. + val flow = NotesRepositoryImpl(provider).getTrashNotes() + + assertTrue(runCatching { flow.first() }.exceptionOrNull() is IllegalStateException) + } + + @Test + fun deletingATagAlsoUntagsItsNotesInOneTransaction(): Unit = runBlocking { + val tag = Tag(id = TAG_ID, nameTag = "Work") + `when`(tagsDao.deleteTagDetachingNotes(tag)).thenReturn(1) + + assertTrue(TagsRepositoryImpl(provider).deleteTag(tag).isSuccess) + + verify(tagsDao).deleteTagDetachingNotes(tag) + verify(tagsDao, never()).deleteTag(tag) + } + + @Test + fun reorderingFailsUnlessEveryTagIsUpdated() = runBlocking { + val tags = listOf(Tag(id = 1, nameTag = "a"), Tag(id = 2, nameTag = "b")) + `when`(tagsDao.updateTags(tags)).thenReturn(1) + + assertTrue(TagsRepositoryImpl(provider).updateTags(tags).isFailure) + assertTrue(TagsRepositoryImpl(provider).updateTags(emptyList()).isSuccess) + } + + @Test + fun tagsComeInTheirDisplayOrder() = runBlocking { + `when`(tagsDao.getTags()).thenReturn( + flowOf(listOf(Tag(id = 1, nameTag = "b", position = 2), Tag(id = 2, nameTag = "a", position = 0))), + ) + + assertEquals(listOf(2L, 1L), TagsRepositoryImpl(provider).getTags().first().map { it.id }) + } + + private companion object { + const val NOTE_ID = 5L + const val TRASHED_AT = 1_000L + const val TAG_ID = 8L + const val TASK_ID = 3L + } +} diff --git a/app/src/test/java/com/pasich/encly/data/repository/SettingsRepositoryImplTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/SettingsRepositoryImplTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/data/repository/SettingsRepositoryImplTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/SettingsRepositoryImplTest.kt diff --git a/app/src/test/java/com/pasich/encly/data/repository/TasksRepositorySubtasksTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/TasksRepositorySubtasksTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/data/repository/TasksRepositorySubtasksTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/TasksRepositorySubtasksTest.kt diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/enums/TitleCollationTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/enums/TitleCollationTest.kt new file mode 100644 index 00000000..0808d9fd --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/enums/TitleCollationTest.kt @@ -0,0 +1,37 @@ +package com.pasich.encly.domain.enums + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +/** Titles follow the alphabet of the current language, not the code-point order. */ +class TitleCollationTest { + private val previous = Locale.getDefault() + + @After + fun restoreLocale() = Locale.setDefault(previous) + + private fun sorted(tag: String, vararg titles: String): List { + Locale.setDefault(Locale.forLanguageTag(tag)) + return titles.sortedWith(titleCollator()) + } + + @Test + fun caseDoesNotDecideTheOrder() { + assertEquals(listOf("apple", "Banana", "cherry"), sorted("en", "cherry", "Banana", "apple")) + } + + @Test + fun germanPutsAUmlautNextToA() { + assertEquals(listOf("Apfel", "Äpfel", "Zebra"), sorted("de", "Zebra", "Äpfel", "Apfel")) + } + + @Test + fun ukrainianFollowsItsAlphabetWithYiAfterI() { + assertEquals( + listOf("Дім", "Ера", "Ірина", "Їжа", "Яблуко"), + sorted("uk", "Яблуко", "Їжа", "Ірина", "Ера", "Дім"), + ) + } +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/model/EditorToolbarTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/model/EditorToolbarTest.kt new file mode 100644 index 00000000..534c1c11 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/model/EditorToolbarTest.kt @@ -0,0 +1,150 @@ +package com.pasich.encly.domain.model + +import com.pasich.encly.core.backup.BackupSettings +import com.pasich.encly.presentation.viewmodel.EditorToolbarViewModel +import com.pasich.encly.testutil.InMemoryKeyValueStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditorToolbarTest { + private val default = ToolbarLayout.DEFAULT + + private fun ToolbarLayout.everyToolOnce() { + val all = main + more + hidden + assertEquals(ToolbarTool.entries.toSet(), all.toSet()) + assertEquals(all.size, all.toSet().size) + } + + @Test + fun theDefaultLayoutHoldsEveryToolOnceWithCodeInTheRowAndTheTableBehindTheChevron() { + default.everyToolOnce() + assertTrue(ToolbarTool.CODE in default.main) + assertTrue(ToolbarTool.TABLE in default.more) + assertEquals(ToolbarSection.MAIN, default.sectionOf(ToolbarTool.QUOTE)) + assertEquals(ToolbarSection.MORE, default.sectionOf(ToolbarTool.LINK)) + assertEquals(ToolbarSection.HIDDEN, default.toggled(ToolbarTool.LINK).sectionOf(ToolbarTool.LINK)) + assertEquals(default.main, default.tools(ToolbarSection.MAIN)) + assertEquals(default.more, default.tools(ToolbarSection.MORE)) + assertEquals(emptyList(), default.tools(ToolbarSection.HIDDEN)) + } + + @Test + fun aToolIsHiddenAndComesBackWhereItIsByDefault() { + val hidden = default.toggled(ToolbarTool.QUOTE).toggled(ToolbarTool.LINK) + hidden.everyToolOnce() + assertEquals(listOf(ToolbarTool.QUOTE, ToolbarTool.LINK), hidden.hidden) + + val back = hidden.toggled(ToolbarTool.QUOTE).toggled(ToolbarTool.LINK) + assertEquals(ToolbarTool.QUOTE, back.main.last()) + assertEquals(ToolbarTool.LINK, back.more.last()) + assertTrue(back.hidden.isEmpty()) + } + + @Test + fun movingATrailsDownTheListAndCrossesIntoTheNextSection() { + val down = default.moved(ToolbarTool.HEADING, 1) + assertEquals(ToolbarTool.LIST_CHECK, down.main.first()) + assertEquals(1, down.main.indexOf(ToolbarTool.HEADING)) + + // The last tool of the row, one place on, is the first behind the chevron. + val across = default.moved(ToolbarTool.CODE, 1) + assertEquals(ToolbarSection.MORE, across.sectionOf(ToolbarTool.CODE)) + assertEquals(ToolbarTool.CODE, across.more.first()) + + // The first tool behind the chevron, one place up, is the last of the row. + val up = default.moved(ToolbarTool.TEXT, -1) + assertEquals(ToolbarTool.TEXT, up.main.last()) + + // All the way to the end is hidden; all the way to the start is the first tool of the row. + assertEquals(ToolbarSection.HIDDEN, default.moved(ToolbarTool.HEADING, 99).sectionOf(ToolbarTool.HEADING)) + assertEquals(ToolbarTool.SEPARATOR, default.moved(ToolbarTool.SEPARATOR, -99).main.first()) + assertEquals(default, default.moved(ToolbarTool.HEADING, 0)) + down.everyToolOnce() + across.everyToolOnce() + } + + @Test + fun theLayoutIsStoredAsTextAndReadBackExactly() { + val custom = default.moved(ToolbarTool.TABLE, -20).toggled(ToolbarTool.QUOTE).moved(ToolbarTool.H4, -3) + + assertEquals(custom, ToolbarLayout.decode(custom.encode())) + assertEquals(default, ToolbarLayout.decode(default.encode())) + assertTrue(custom.encode().startsWith("main:TABLE,")) + } + + @Test + fun unreadableOrNoTextIsTheDefaultLayout() { + assertEquals(default, ToolbarLayout.decode(null)) + assertEquals(default, ToolbarLayout.decode("")) + assertEquals(default, ToolbarLayout.decode("nonsense")) + assertEquals(default, ToolbarLayout.decode("bar:HEADING;baz:TEXT")) + // A section word with no colon names nothing. + assertEquals(default, ToolbarLayout.decode("main")) + } + + @Test + fun unknownToolsAreDroppedDuplicatesKeepTheirFirstPlaceAndMissingToolsGoWhereTheyAreByDefault() { + val decoded = ToolbarLayout.decode("main:TABLE,GONE,TABLE, QUOTE;more:QUOTE,TEXT;hidden:H1") + + decoded.everyToolOnce() + assertEquals(listOf(ToolbarTool.TABLE, ToolbarTool.QUOTE), decoded.main.take(2)) + assertTrue("a tool this text leaves out is in the row by default", ToolbarTool.HEADING in decoded.main) + assertTrue("or behind the chevron by default", ToolbarTool.LINK in decoded.more) + assertEquals(listOf(ToolbarTool.H1), decoded.hidden) + assertFalse(ToolbarTool.QUOTE in decoded.more) + } + + @Test + fun theButtonsShrinkAsTheRowFills() { + // A 411 dp screen leaves 391 dp for the toolbar's row. + assertEquals(ToolbarScale(44, 22), toolbarScale(buttons = 4, availableDp = 391)) + assertEquals(ToolbarScale(44, 22), toolbarScale(buttons = 9, availableDp = 391)) + assertEquals(ToolbarScale(40, 20), toolbarScale(buttons = 10, availableDp = 391)) + assertEquals(ToolbarScale(36, 18), toolbarScale(buttons = 11, availableDp = 391)) + assertEquals(ToolbarScale(36, 18), toolbarScale(buttons = 30, availableDp = 391)) + assertEquals(ToolbarScale(36, 18), toolbarScale(buttons = 9, availableDp = 300)) + } + + @Test + fun theChoiceIsStoredAndFollowedBeforeAnyScreenAsksAndResetRestoresTheDefault() { + val flags = InMemoryKeyValueStore() + assertEquals(default, EditorToolbar(flags).layout.value) + + val custom = default.toggled(ToolbarTool.CODE) + EditorToolbar(flags).set(custom) + assertEquals(custom, EditorToolbar(flags).layout.value) + + val toolbar = EditorToolbar(flags) + toolbar.reset() + assertEquals(default, EditorToolbar(flags).layout.value) + } + + @Test + fun theViewModelMovesHidesAndResets() { + val toolbar = EditorToolbar(InMemoryKeyValueStore()) + val viewModel = EditorToolbarViewModel(toolbar) + + viewModel.move(ToolbarTool.HEADING, 1) + assertEquals(ToolbarTool.LIST_CHECK, viewModel.layout.value.main.first()) + viewModel.toggle(ToolbarTool.HEADING) + assertEquals(listOf(ToolbarTool.HEADING), viewModel.layout.value.hidden) + viewModel.reset() + assertEquals(default, viewModel.layout.value) + } + + @Test + fun theBackupAcceptsOnlyAWellFormedLayoutOfKnownTools() { + fun accepted(value: String) = + BackupSettings.accepted(mapOf(BackupSettings.EDITOR_TOOLBAR to value)).isNotEmpty() + + assertTrue(accepted(default.encode())) + assertTrue(accepted("main:HEADING;more:;hidden:")) + assertFalse(accepted("main:NOT_A_TOOL")) + assertFalse(accepted("tools:HEADING")) + assertFalse(accepted("main")) + assertFalse(accepted("")) + assertFalse(accepted("main:" + "HEADING,".repeat(60))) + } +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/model/NotePreviewsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/model/NotePreviewsTest.kt new file mode 100644 index 00000000..ff28eea7 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/model/NotePreviewsTest.kt @@ -0,0 +1,38 @@ +package com.pasich.encly.domain.model + +import com.pasich.encly.presentation.viewmodel.NotePreviewsViewModel +import com.pasich.encly.testutil.InMemoryKeyValueStore +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NotePreviewsTest { + private val flags = InMemoryKeyValueStore() + + @Test + fun previewsAreOnByDefault() { + assertTrue(NotePreviews(flags).show.value) + } + + @Test + fun theChoiceIsStoredAndFollowedBeforeAnyScreenAsks() { + NotePreviews(flags).setShow(false) + + assertFalse("read at once on the next start", NotePreviews(flags).show.value) + + NotePreviews(flags).setShow(true) + assertTrue(NotePreviews(flags).show.value) + } + + @Test + fun theViewModelReadsAndWritesTheSameSwitch() { + val previews = NotePreviews(flags) + val viewModel = NotePreviewsViewModel(previews) + + viewModel.setShow(false) + + assertFalse(viewModel.show.value) + assertFalse(previews.show.value) + assertFalse(NotePreviews(flags).show.value) + } +} diff --git a/app/src/test/java/com/pasich/encly/domain/usecase/CreateVaultTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/CreateVaultTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/domain/usecase/CreateVaultTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/CreateVaultTest.kt diff --git a/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt similarity index 86% rename from app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt index 5ecb7ac5..4487ebe5 100644 --- a/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt @@ -1,19 +1,21 @@ package com.pasich.encly.domain.usecase -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode -import cash.z.ecc.android.bip39.Mnemonics.WordCount import com.pasich.encly.core.backup.BackupCipher import com.pasich.encly.core.backup.BackupError import com.pasich.encly.core.backup.BackupException import com.pasich.encly.core.backup.BackupPayloadCodec import com.pasich.encly.core.backup.BackupSecret +import com.pasich.encly.core.security.Mnemonic import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.bip39.Bip39 import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupMapper +import com.pasich.encly.data.backup.NoBackupPreferences import com.pasich.encly.data.backup.PendingRestore import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteTag import com.pasich.encly.data.model.Tag -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -29,7 +31,7 @@ import org.mockito.Mockito.`when` /** Onboarding's "Restore from backup": decrypt first, create the vault only on success. */ class RestoreFromBackupTest { - private val words = MnemonicCode(WordCount.COUNT_12).chars + private val words = Mnemonic.generate() private lateinit var security: SecurityManager private lateinit var store: InMemoryVaultDataStore private lateinit var pending: PendingRestore @@ -42,7 +44,7 @@ class RestoreFromBackupTest { fun setUp() { security = mock(SecurityManager::class.java) `when`(security.isValidRecoveryPhrase(anyChars())).thenAnswer { - runCatching { MnemonicCode(it.getArgument(0).copyOf()).validate() }.isSuccess + Bip39().isValid(it.getArgument(0)) } `when`(security.initializeNewVault(anyChars())).thenAnswer { // Copied now: the use case wipes the phrase once the vault exists. @@ -50,7 +52,7 @@ class RestoreFromBackupTest { true } store = InMemoryVaultDataStore() - val backupManager = BackupManager(security, store, InMemorySharedPreferences()) + val backupManager = BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences) pending = PendingRestore(backupManager) useCase = OnboardingUseCase(security, backupManager, pending) } @@ -58,7 +60,8 @@ class RestoreFromBackupTest { private suspend fun backupFile(): ByteArray { val source = InMemoryVaultDataStore().apply { val tag = insertTag(Tag(nameTag = "Work", uid = "tag-work")) - insertNote(Note(title = "Plan", value = "[]", tagId = tag, uid = "n-plan")) + val plan = insertNote(Note(title = "Plan", value = "[]", uid = "n-plan")) + insertNoteTag(NoteTag(plan, tag)) } val plaintext = BackupPayloadCodec.encode(BackupMapper.toPayload(source.snapshot(), exportedAt = 1)) return BackupCipher.seal(plaintext, BackupSecret.RecoveryPhrase(words.copyOf())) @@ -77,12 +80,12 @@ class RestoreFromBackupTest { assertTrue(pending.apply()) assertEquals(listOf("n-plan"), store.notes.map { it.uid }) - assertEquals(store.tags.single().id, store.notes.single().tagId) + assertEquals(setOf(store.tags.single().id), store.tagIdsOf(store.notes.single().id)) } @Test fun wrongWordsCreateNothing() = runTest { - val result = useCase.restoreFromBackup(backupFile(), MnemonicCode(WordCount.COUNT_12).chars) + val result = useCase.restoreFromBackup(backupFile(), Mnemonic.generate()) assertEquals(BackupError.WRONG_SECRET, (result.exceptionOrNull() as BackupException).error) verify(security, never()).initializeNewVault(anyChars()) diff --git a/app/src/test/java/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt similarity index 87% rename from app/src/test/java/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt index 40998346..da937f19 100644 --- a/app/src/test/java/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt @@ -1,6 +1,7 @@ package com.pasich.encly.domain.usecase.note import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.domain.model.NoteColor import com.pasich.encly.domain.repository.NotesRepository import com.pasich.encly.testutil.TestNotesRepository @@ -19,7 +20,7 @@ import java.io.IOException class NoteUseCasesTest { private val repository = TestNotesRepository() - private val note = Note(id = 4, title = "Plan", description = "old", tagId = 1) + private val note = Note(id = 4, title = "Plan", description = "old", tagIds = setOf(1)) @Test fun trashAndRestoreChangeOnlyTheTrashFlag() = runTest { @@ -29,7 +30,9 @@ class NoteUseCasesTest { assertTrue(useCase(note.copy(isTrash = true), isTrash = false).isSuccess) assertEquals(listOf(true, false), repository.updatedNotes.map { it.isTrash }) - assertTrue(repository.updatedNotes.all { it.title == "Plan" && it.tagId == 1L }) + // Two columns of the row, by id: nothing else of the (possibly stale) note is written back. + assertTrue(repository.updatedNotes.all { it.id == 4L }) + assertEquals(0, repository.updateBases.size) } @Test @@ -56,6 +59,7 @@ class NoteUseCasesTest { fun trashingAnArchivedNoteKeepsItArchivedSoRestoringReturnsItToTheArchive() = runTest { val useCase = UpdateNoteTrashStatusUseCase(repository) val archived = note.copy(isArchived = true) + repository.archivedNotesWithTags.value = listOf(NoteWithTags(archived, emptyList())) useCase(archived, isTrash = true) useCase(archived.copy(isTrash = true), isTrash = false) @@ -75,11 +79,15 @@ class NoteUseCasesTest { @Test fun theTagAndTheDescriptionAreWrittenAlone() = runTest { UpdateNoteTagUseCase(repository)(note, tagId = 9) + UpdateNoteTagUseCase(repository)(note, tagId = 1, tagged = false) UpdateNoteTagUseCase(repository)(note, tagId = 0) UpdateNoteDescriptionUseCase(repository)(note, "new") - // One column each: nothing else of the (possibly stale) note is written back. - assertEquals(listOf("tag:4=9", "tag:4=null", "description:4=new"), repository.columnWrites) + // One write each, one tag at a time: nothing else of the (possibly stale) note is written back. + assertEquals( + listOf("tag:4=9", "untag:4=1", "tag:4=null", "description:4=new"), + repository.columnWrites, + ) assertEquals(0, repository.updateCalls) } diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/ObserveNotesUseCaseTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/ObserveNotesUseCaseTest.kt new file mode 100644 index 00000000..ab80d64f --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/ObserveNotesUseCaseTest.kt @@ -0,0 +1,233 @@ +package com.pasich.encly.domain.usecase.note + +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.data.model.Tag +import com.pasich.encly.domain.enums.NoteSortOption +import com.pasich.encly.domain.model.NoteListItem +import com.pasich.encly.domain.repository.NotesRepository +import com.pasich.encly.testutil.TestNotesRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class ObserveNotesUseCaseTest { + + @Test + fun aDatabaseReadFailureFailsTheFlowInsteadOfEmittingNoNotes() = runTest { + val failing = object : NotesRepository by TestNotesRepository() { + override fun getAllNotesWithTag(): Flow> = flow { throw IOException("vault closed") } + } + + val emitted = mutableListOf>() + val thrown = runCatching { + ObserveNotesUseCase(failing, StandardTestDispatcher(testScheduler))( + tagId = null, + sortOption = NoteSortOption.UPDATED_DESC, + ).toList(emitted) + }.exceptionOrNull() + + // The screen turns the failure into an error state (asLoadState); never an empty list. + assertTrue(thrown is IOException) + assertTrue(emitted.isEmpty()) + } + + @Test + fun allNotesLeaveOutHiddenTagsAndFollowTheSortOption() = runTest { + val hidden = Tag(id = 1, nameTag = "Private", isVisible = false) + val repository = TestNotesRepository().apply { + allNotesWithTags.value = listOf( + noteWithTag(id = 1, date = 10), + noteWithTag(id = 2, date = 30), + noteWithTag(id = 3, date = 20, tag = hidden), + ) + } + + val newestFirst = loaded(repository, NoteSortOption.UPDATED_DESC) + val oldestFirst = loaded(repository, NoteSortOption.UPDATED_ASC) + + assertEquals(listOf(2L, 1L), newestFirst.map { it.note.id }) + assertEquals(listOf(1L, 2L), oldestFirst.map { it.note.id }) + } + + @Test + fun pinnedNotesComeFirstWhateverTheSortOption() = runTest { + val repository = TestNotesRepository().apply { + allNotesWithTags.value = listOf( + noteWithTag(id = 1, date = 10), + noteWithTag(id = 2, date = 30), + noteWithTag(id = 3, date = 20, pinned = true), + noteWithTag(id = 4, date = 5, pinned = true), + ) + } + + assertEquals(listOf(3L, 4L, 2L, 1L), loaded(repository, NoteSortOption.UPDATED_DESC).map { it.note.id }) + assertEquals(listOf(4L, 3L, 1L, 2L), loaded(repository, NoteSortOption.UPDATED_ASC).map { it.note.id }) + } + + @Test + fun titleOrdersKeepPinnedFirstAndPutUntitledNotesLast() = runTest { + val repository = TestNotesRepository().apply { + allNotesWithTags.value = listOf( + noteWithTag(id = 1, date = 10, title = "beta"), + noteWithTag(id = 2, date = 30, title = ""), + noteWithTag(id = 3, date = 20, title = "Zulu", pinned = true), + noteWithTag(id = 4, date = 5, title = "alpha"), + noteWithTag(id = 5, date = 6, title = "Yankee", pinned = true), + ) + } + + assertEquals(listOf(5L, 3L, 4L, 1L, 2L), loaded(repository, NoteSortOption.TITLE_ASC).map { it.note.id }) + assertEquals(listOf(3L, 5L, 1L, 4L, 2L), loaded(repository, NoteSortOption.TITLE_DESC).map { it.note.id }) + } + + @Test + fun theArchiveIsReadFromItsOwnQueryAndLeavesOutHiddenTags() = runTest { + val hidden = Tag(id = 1, nameTag = "Private", isVisible = false) + val repository = TestNotesRepository().apply { + allNotesWithTags.value = listOf(noteWithTag(id = 1, date = 1)) + archivedNotesWithTags.value = listOf( + noteWithTag(id = 2, date = 10), + noteWithTag(id = 3, date = 20), + noteWithTag(id = 4, date = 30, tag = hidden), + ) + } + + val archived = ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))( + tagId = null, + sortOption = NoteSortOption.UPDATED_DESC, + archived = true, + ).first() + + assertEquals(listOf(3L, 2L), archived.map { it.note.id }) + } + + private fun archivedWithTag(id: Long, tag: Tag) = NoteWithTags( + Note(id = id, title = "a$id", date = id, tagIds = setOf(tag.id), isArchived = true), + listOf(tag), + ) + + @Test + fun aHiddenTagsViewListsItsArchivedNotesAfterTheOthers() = runTest { + val hidden = Tag(id = 5, nameTag = "Private", isVisible = false) + val repository = TestNotesRepository().apply { + tagFlow(5).value = + listOf(noteWithTag(id = 1, date = 10, tag = hidden), noteWithTag(id = 2, date = 1, tag = hidden)) + archivedNotesWithTags.value = + listOf(archivedWithTag(3, hidden), archivedWithTag(4, Tag(id = 6, isVisible = true))) + } + + val items = ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))( + tagId = 5, + sortOption = NoteSortOption.UPDATED_DESC, + ).first() + + assertEquals(listOf(1L, 2L, 3L), items.map { it.note.id }) + } + + @Test + fun aVisibleTagsViewLeavesItsArchivedNotesToTheArchive() = runTest { + val visible = Tag(id = 5, nameTag = "Work", isVisible = true) + val repository = TestNotesRepository().apply { + tagFlow(5).value = listOf(noteWithTag(id = 1, date = 10, tag = visible)) + archivedNotesWithTags.value = listOf(archivedWithTag(3, visible)) + } + + val items = ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))( + tagId = 5, + sortOption = NoteSortOption.UPDATED_DESC, + ).first() + + assertEquals(listOf(1L), items.map { it.note.id }) + } + + @Test + fun aNoteWithAHiddenTagShowsOnlyInTheHiddenTagsViewNotInItsVisibleTagsOrTheLists() = runTest { + val visible = Tag(id = 5, nameTag = "Work", position = 1) + val hidden = Tag(id = 6, nameTag = "Private", isVisible = false, position = 0) + val both = NoteWithTags(Note(id = 1, title = "both", date = 10, tagIds = setOf(5, 6)), listOf(hidden, visible)) + val workOnly = noteWithTag(id = 2, date = 5, tag = visible) + val repository = TestNotesRepository().apply { + allNotesWithTags.value = listOf(both, workOnly) + tagFlow(5).value = listOf(both, workOnly) + tagFlow(6).value = listOf(both) + } + val useCase = ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler)) + + assertEquals(listOf(2L), useCase(null, NoteSortOption.UPDATED_DESC).first().map { it.note.id }) + assertEquals(listOf(2L), useCase(5, NoteSortOption.UPDATED_DESC).first().map { it.note.id }) + val inHidden = useCase(6, NoteSortOption.UPDATED_DESC).first() + assertEquals(listOf(1L), inHidden.map { it.note.id }) + // The item carries every tag of the note. + assertEquals(listOf("Private", "Work"), inHidden.single().tags.map { it.nameTag }) + } + + @Test + fun theArchiveLeavesOutANoteWithAnyHiddenTag() = runTest { + val visible = Tag(id = 5, nameTag = "Work") + val hidden = Tag(id = 6, nameTag = "Private", isVisible = false) + val repository = TestNotesRepository().apply { + archivedNotesWithTags.value = listOf( + NoteWithTags(Note(id = 1, date = 1, isArchived = true), listOf(visible, hidden)), + NoteWithTags(Note(id = 2, date = 2, isArchived = true), listOf(visible)), + ) + } + + val archived = ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))( + tagId = null, + sortOption = NoteSortOption.UPDATED_DESC, + archived = true, + ).first() + + assertEquals(listOf(2L), archived.map { it.note.id }) + } + + @Test + fun oneUnreadableNoteDoesNotTakeTheListDown() = runTest { + val repository = TestNotesRepository().apply { + allNotesWithTags.value = listOf( + noteWithTag(id = 1, date = 2, value = """[{"blockType":"TEXT","text":"fine"}]"""), + noteWithTag(id = 2, date = 1, value = """[{"blockType":"FUTURE"}]"""), + ) + } + + val items = loaded(repository, NoteSortOption.UPDATED_DESC) + + assertEquals("fine", items[0].preview) + assertNull(items[1].preview) + } + + private suspend fun TestScope.loaded(repository: NotesRepository, sortOption: NoteSortOption): List = + ObserveNotesUseCase(repository, StandardTestDispatcher(testScheduler))(null, sortOption).first() + + private fun noteWithTag( + id: Long, + date: Long, + tag: Tag? = null, + value: String = "", + pinned: Boolean = false, + title: String = "n$id", + ) = NoteWithTags( + Note( + id = id, + title = title, + value = value, + date = date, + dateCreate = date, + tagIds = setOfNotNull(tag?.id), + isPinned = pinned, + ), + listOfNotNull(tag), + ) +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/TrashExpiryTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/TrashExpiryTest.kt new file mode 100644 index 00000000..9a9ecbab --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/TrashExpiryTest.kt @@ -0,0 +1,125 @@ +package com.pasich.encly.domain.usecase.note + +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.repository.NotesRepository +import com.pasich.encly.testutil.TestNotesRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class TrashExpiryTest { + private val repository = TestNotesRepository() + private val purge = PurgeExpiredTrashUseCase(repository) + + private fun trashed(id: Long, trashedAt: Long?) = Note(id = id, isTrash = true, trashedAt = trashedAt) + + @Test + fun trashingRemembersWhenAndRestoringForgetsIt() = runTest { + val useCase = UpdateNoteTrashStatusUseCase(repository) + val note = Note(id = 4, title = "Plan") + + useCase(note, isTrash = true, now = NOW) + useCase(note.copy(isTrash = true, trashedAt = NOW), isTrash = false, now = NOW + 1) + + assertEquals(listOf(NOW, null), repository.updatedNotes.map { it.trashedAt }) + } + + @Test + fun aNoteIsDeletedOnlyOnceItIsOlderThan28Days() = runTest { + repository.trashNotes.value = listOf( + trashed(1, NOW - TRASH_RETENTION_MILLIS - 1), + trashed(2, NOW - TRASH_RETENTION_MILLIS), + trashed(3, NOW - TRASH_RETENTION_MILLIS + 1), + trashed(4, NOW), + ) + + val result = purge(NOW) + + assertEquals(1, result.getOrNull()) + assertEquals(listOf(1L), repository.deletedIds) + } + + @Test + fun theRetentionIs28Days() { + assertEquals(28, TRASH_RETENTION_DAYS) + assertEquals(28L * 24 * 60 * 60 * 1000, TRASH_RETENTION_MILLIS) + } + + @Test + fun aTrashedNoteWithoutATimeStartsItsDaysNowAndIsKept() = runTest { + repository.trashNotes.value = listOf(trashed(1, null)) + + assertEquals(0, purge(NOW).getOrNull()) + + assertTrue(repository.deletedIds.isEmpty()) + assertEquals(NOW, repository.trashNotes.value.single().trashedAt) + // Twenty-eight days and a moment later it is gone. + assertEquals(1, purge(NOW + TRASH_RETENTION_MILLIS + 1).getOrNull()) + assertEquals(listOf(1L), repository.deletedIds) + } + + @Test + fun anEmptyTrashDeletesNothing() = runTest { + assertEquals(0, purge(NOW).getOrNull()) + assertTrue(repository.deletedIds.isEmpty()) + } + + @Test + fun aNoteRestoredMeanwhileIsNotDeleted() = runTest { + // The old note was read as expired, then restored: the delete must see it is no longer trashed. + repository.trashNotes.value = listOf( + trashed(1, NOW - TRASH_RETENTION_MILLIS - 1), + Note(id = 2, isTrash = false, trashedAt = NOW - TRASH_RETENTION_MILLIS - 1), + ) + + assertEquals(1, purge(NOW).getOrNull()) + + assertEquals(listOf(1L), repository.deletedIds) + assertEquals(listOf(2L), repository.trashNotes.value.map { it.id }) + } + + @Test + fun aTrashTimeInTheFutureIsNeverExpired() = runTest { + repository.trashNotes.value = listOf(trashed(1, NOW + TRASH_RETENTION_MILLIS * 2)) + + assertEquals(0, purge(NOW).getOrNull()) + + assertTrue(repository.deletedIds.isEmpty()) + } + + @Test + fun theCutoffIsTheRetentionBeforeNow() = runTest { + purge(NOW) + + assertEquals(listOf(NOW - TRASH_RETENTION_MILLIS), repository.purgeCutoffs) + } + + @Test + fun aFailedStampStopsBeforeAnythingIsDeleted() = runTest { + val notes = mock(NotesRepository::class.java) + `when`(notes.stampUntimedTrash(NOW)).thenReturn(Result.failure(IllegalStateException("disk"))) + + assertTrue(PurgeExpiredTrashUseCase(notes)(NOW).isFailure) + verify(notes, never()).deleteTrashedBefore(anyLong()) + } + + @Test + fun aFailedDeleteIsAFailure() = runTest { + val notes = mock(NotesRepository::class.java) + `when`(notes.stampUntimedTrash(NOW)).thenReturn(Result.success(Unit)) + `when`(notes.deleteTrashedBefore(NOW - TRASH_RETENTION_MILLIS)) + .thenReturn(Result.failure(IllegalStateException("vault locked"))) + + assertTrue(PurgeExpiredTrashUseCase(notes)(NOW).isFailure) + } + + private companion object { + const val NOW = 2_000_000_000_000L + } +} diff --git a/app/src/test/java/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCaseTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCaseTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCaseTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCaseTest.kt diff --git a/app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhrasePasteTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhrasePasteTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhrasePasteTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhrasePasteTest.kt index 28a3db67..f6a8ed32 100644 --- a/app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhrasePasteTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhrasePasteTest.kt @@ -1,7 +1,6 @@ package com.pasich.encly.presentation.designsystem -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode -import cash.z.ecc.android.bip39.Mnemonics.WordCount +import com.pasich.encly.core.security.Mnemonic import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -11,7 +10,7 @@ import org.junit.Test /** The 12 recovery-word cells: phrases pasted the ways people copy them, and invalid words. */ class RecoveryPhrasePasteTest { private val state = RecoveryPhraseState() - private val valid = String(MnemonicCode(WordCount.COUNT_12).chars).split(' ') + private val valid = String(Mnemonic.generate()).split(' ') @Test fun aNumberedListPastedFromNotesFillsTheCellsInOrder() { diff --git a/app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhraseStateTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseStateTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhraseStateTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseStateTest.kt index 60d92812..25bb7652 100644 --- a/app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhraseStateTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseStateTest.kt @@ -1,7 +1,6 @@ package com.pasich.encly.presentation.designsystem -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode -import cash.z.ecc.android.bip39.Mnemonics.WordCount +import com.pasich.encly.core.security.Mnemonic import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -11,7 +10,7 @@ import org.junit.Test class RecoveryPhraseStateTest { private val state = RecoveryPhraseState() - private val valid = String(MnemonicCode(WordCount.COUNT_12).chars).split(' ') + private val valid = String(Mnemonic.generate()).split(' ') private fun typeAll(words: List) = words.forEachIndexed { i, word -> state.onValueChange(i, word) } diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/HistoryShortcutTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/editor/HistoryShortcutTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/presentation/editor/HistoryShortcutTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/editor/HistoryShortcutTest.kt diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/persistence/AutosaveDebounceTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/editor/persistence/AutosaveDebounceTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/editor/persistence/AutosaveDebounceTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/editor/persistence/AutosaveDebounceTest.kt index 53790111..61db12ae 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/persistence/AutosaveDebounceTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/editor/persistence/AutosaveDebounceTest.kt @@ -1,9 +1,9 @@ package com.pasich.encly.presentation.editor.persistence -import com.pasich.encly.core.serialization.BlockConverter import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block import com.pasich.encly.testutil.TestNotesRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/LockFormStateTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/LockFormStateTest.kt new file mode 100644 index 00000000..1ddeaa37 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/LockFormStateTest.kt @@ -0,0 +1,145 @@ +package com.pasich.encly.presentation.screen + +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.lock_device_locked +import com.pasich.encly.core.resources.lock_pin_key_lost +import com.pasich.encly.core.resources.lock_wrong_pin +import com.pasich.encly.core.resources.lock_wrong_recovery_phrase +import com.pasich.encly.presentation.viewmodel.PinUnlockResult +import com.pasich.encly.presentation.viewmodel.SeedUnlockResult +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 LockFormStateTest { + private val form = LockFormState() + + private fun typePin(pin: String) = pin.forEach { form.typeDigit(it.digitToInt()) } + + @Test + fun aWrongPinBeforeTheLockoutShowsTheErrorAndShakes() { + typePin("123456") + assertEquals("123456", String(form.takePin())) + + form.onPinResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 0L) + + assertEquals(Res.string.lock_wrong_pin, form.pinError) + assertEquals(1, form.shakeKey) + assertEquals(0, form.pinLength) + assertEquals(0L, form.lockoutSeconds) + } + + @Test + fun theNextAttemptClearsTheError() { + form.onPinResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 0L) + + form.typeDigit(1) + + assertNull(form.pinError) + } + + @Test + fun aWrongPinThatStartsALockoutCountsItDownAndBlocksDigits() { + form.onPinResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 30_000L) + + assertEquals(30L, form.lockoutSeconds) + form.typeDigit(1) + assertEquals(0, form.pinLength) + } + + @Test + fun aWrongPhraseKeepsTheWordsAndShowsTheError() { + form.phrase.onValueChange(0, "orbit cactus velvet") + + form.onSeedResult(SeedUnlockResult.WRONG_SEED) + + assertEquals(listOf("orbit", "cactus", "velvet"), form.phrase.words.take(3)) + assertEquals(Res.string.lock_wrong_recovery_phrase, form.phraseError) + form.onPhraseEdited() + assertNull(form.phraseError) + } + + @Test + fun backToThePinPadDropsTheWords() { + form.useRecovery = true + form.phrase.onValueChange(0, "orbit cactus") + form.onSeedResult(SeedUnlockResult.WRONG_SEED) + + form.leaveRecovery() + + assertFalse(form.useRecovery) + assertTrue(form.phrase.words.all(String::isEmpty)) + assertNull(form.phraseError) + } + + @Test + fun anUnlockRefusedByTheDeviceLockSaysSoOnThePinPadAndTheRecoveryForm() { + form.onPinResult(PinUnlockResult.DEVICE_LOCKED, lockoutRemainingMillis = 0L) + form.onSeedResult(SeedUnlockResult.DEVICE_LOCKED) + + assertEquals(Res.string.lock_device_locked, form.pinError) + assertEquals(Res.string.lock_device_locked, form.phraseError) + assertFalse(form.lockedOut) + } + + @Test + fun aBackgroundedUnlockShowsNoError() { + form.onPinResult(PinUnlockResult.BACKGROUNDED, lockoutRemainingMillis = 0L) + form.onSeedResult(SeedUnlockResult.BACKGROUNDED) + + assertNull(form.pinError) + assertNull(form.phraseError) + assertEquals(0, form.pinLength) + } + + @Test + fun theTypedDigitsAreWipedWhenTakenOrCleared() { + typePin("12") + form.deleteDigit() + typePin("3456") + assertEquals(5, form.pinLength) + form.typeDigit(7) + + val taken = form.takePin() + + assertEquals("134567", String(taken)) + assertEquals(0, form.pinLength) + typePin("99") + form.clearPin() + assertEquals(0, form.pinLength) + assertEquals("a new PIN starts from nothing", "1", String(form.also { it.typeDigit(1) }.takePin())) + } + + @Test + fun aLostPinKeyExplainsItOnBothForms() { + form.onPinResult(PinUnlockResult.KEY_LOST, lockoutRemainingMillis = 0L) + + assertEquals(Res.string.lock_pin_key_lost, form.pinError) + assertEquals(Res.string.lock_pin_key_lost, form.phraseError) + } + + @Test + fun thePinTakesSixToTwelveDigitsAndIgnoresTheRest() { + typePin("12345") + assertFalse("five digits are no PIN", form.canSubmitPin) + typePin("6") + assertTrue(form.canSubmitPin) + + typePin("78901234567") + + assertEquals(12, form.pinLength) + assertEquals("123456789012", String(form.takePin())) + } + + @Test + fun theDotsKeepTheLengthOfThePinThatWasSentForChecking() { + typePin("12345678") + + form.takePin() + + assertEquals(0, form.pinLength) + assertEquals(8, form.submittedLength) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/screen/NoteEntriesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/NoteEntriesTest.kt similarity index 80% rename from app/src/test/java/com/pasich/encly/presentation/screen/NoteEntriesTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/NoteEntriesTest.kt index cd0c7d8f..187e2d52 100644 --- a/app/src/test/java/com/pasich/encly/presentation/screen/NoteEntriesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/NoteEntriesTest.kt @@ -1,8 +1,11 @@ package com.pasich.encly.presentation.screen -import com.pasich.encly.R +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.main_drawer_archive +import com.pasich.encly.core.resources.notes_section_other +import com.pasich.encly.core.resources.notes_section_pinned import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.domain.model.NoteListItem import org.junit.Assert.assertEquals import org.junit.Test @@ -10,7 +13,7 @@ import org.junit.Test class NoteEntriesTest { private fun item(id: Long, pinned: Boolean = false, archived: Boolean = false) = NoteListItem( - NoteWithTag(Note(id = id, isPinned = pinned, isArchived = archived), null), + NoteWithTags(Note(id = id, isPinned = pinned, isArchived = archived), emptyList()), preview = null, searchText = "", ) @@ -18,8 +21,8 @@ class NoteEntriesTest { private fun describe(entries: List) = entries.map { when (it) { is NoteSection -> when (it.title) { - R.string.notes_section_pinned -> "Pinned" - R.string.main_drawer_archive -> "Archived" + Res.string.notes_section_pinned -> "Pinned" + Res.string.main_drawer_archive -> "Archived" else -> "Others" } @@ -87,7 +90,12 @@ class NoteEntriesTest { val entries = noteEntries(listOf(item(7, pinned = true), item(8)), sections = true) assertEquals( - listOf("section-${R.string.notes_section_pinned}", 7L, "section-${R.string.notes_section_other}", 8L), + listOf( + "section-${Res.string.notes_section_pinned.key}", + 7L, + "section-${Res.string.notes_section_other.key}", + 8L, + ), entries.map { it.key }, diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/AppearanceViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AppearanceViewModelTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/AppearanceViewModelTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AppearanceViewModelTest.kt diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt index 54aacd7c..cc475665 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt @@ -5,9 +5,10 @@ import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupMapper +import com.pasich.encly.data.backup.NoBackupPreferences import com.pasich.encly.data.backup.PendingRestore import com.pasich.encly.data.model.Note -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -48,7 +49,7 @@ class AuthSetupViewModelTest { true } store = InMemoryVaultDataStore() - pending = PendingRestore(BackupManager(security, store, InMemorySharedPreferences())) + pending = PendingRestore(BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences)) val source = InMemoryVaultDataStore().apply { insertNote(Note(title = "Plan", value = "[]", uid = "n-plan")) } pending.stage(BackupMapper.toPayload(source.snapshot(), exportedAt = 1)) diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt similarity index 90% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt index a4c46133..c4652975 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt @@ -3,19 +3,19 @@ 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.data.model.NoteWithTags import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase 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.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.state.FindState +import com.pasich.encly.editor.state.LineBreak +import com.pasich.encly.editor.state.NoteTemplate +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.blocks import com.pasich.encly.testutil.TestNotesRepository import com.pasich.encly.testutil.TestSettingsRepository import kotlinx.coroutines.CoroutineScope @@ -46,8 +46,14 @@ class EditNoteViewModelComfortTest { private val dispatcher = StandardTestDispatcher() private val settings = TestSettingsRepository() private val locked = MutableStateFlow(false) + private var lockFlush: (suspend () -> Unit)? = null private val lockEvents = object : VaultLockEvents { override val locked = this@EditNoteViewModelComfortTest.locked + + override fun registerPreLockFlush(flush: suspend () -> Unit): () -> Unit { + lockFlush = flush + return { lockFlush = null } + } } private val note = Note( id = NOTE_ID, @@ -55,7 +61,7 @@ class EditNoteViewModelComfortTest { value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("the cat sat")))), ) private val repository = TestNotesRepository().apply { - allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) + allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) insertResult = INSERTED_ID } @@ -144,7 +150,7 @@ class EditNoteViewModelComfortTest { @Test fun aNoteStoredWithItsOwnTextOpensWithIt() = runTest(dispatcher) { repository.allNotesWithTags.value = listOf( - NoteWithTag(note.copy(textSize = 30, lineSpacing = NoteLineSpacing.COMFORTABLE.ordinal), null), + NoteWithTags(note.copy(textSize = 30, lineSpacing = NoteLineSpacing.COMFORTABLE.ordinal), emptyList()), ) val viewModel = viewModel() collectingText(viewModel) @@ -159,7 +165,7 @@ class EditNoteViewModelComfortTest { fun useDefaultReturnsTheNoteToTheDefaults() = runTest(dispatcher) { settings.fontSizeFlow.value = 18 repository.allNotesWithTags.value = listOf( - NoteWithTag(note.copy(textSize = 30, lineSpacing = NoteLineSpacing.COMFORTABLE.ordinal), null), + NoteWithTags(note.copy(textSize = 30, lineSpacing = NoteLineSpacing.COMFORTABLE.ordinal), emptyList()), ) val viewModel = viewModel() collectingText(viewModel) @@ -253,6 +259,22 @@ class EditNoteViewModelComfortTest { assertNull(viewModel.slashPicker.menu.value) } + @Test + fun textTypedJustBeforeALockIsSavedBeforeTheVaultCloses() = runTest(dispatcher) { + val viewModel = viewModel() + advanceUntilIdle() + (viewModel.blocks.single() as Block.TextBlock).text.value = "the cat sat on the mat" + advanceTimeBy(500) // well inside the 2 s autosave pause + + checkNotNull(lockFlush).invoke() + + assertEquals(1, repository.updatedNotes.size) + assertTrue(repository.updatedNotes.single().value.contains("on the mat")) + // Editing has ended: the autosave that was waiting writes nothing into the closed vault. + advanceTimeBy(10_000) + assertEquals(1, repository.updatedNotes.size) + } + // --- slash ---------------------------------------------------------------------------------------- @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt index 3ed468a0..4f29d05e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt @@ -1,20 +1,20 @@ package com.pasich.encly.presentation.viewmodel import androidx.lifecycle.SavedStateHandle -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.data.model.NoteWithTags 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.repository.SettingsRepository import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.model.ItemListBlock +import com.pasich.encly.editor.model.LinkDataBlock +import com.pasich.encly.editor.state.BlockRemoveAction +import com.pasich.encly.editor.state.FocusRequest import com.pasich.encly.presentation.editor.persistence.AUTOSAVE_DEBOUNCE_MS -import com.pasich.encly.presentation.editor.state.BlockRemoveAction -import com.pasich.encly.presentation.editor.state.FocusRequest import com.pasich.encly.testutil.TestNotesRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -168,7 +168,7 @@ class EditNoteViewModelEditorTest { advanceUntilIdle() assertEquals(-1L, viewModel.state.value.note.id) - assertNull(viewModel.state.value.note.tagId) + assertEquals(emptySet(), viewModel.state.value.note.tagIds) } // --- block identity and indices ------------------------------------------------------- @@ -409,16 +409,20 @@ class EditNoteViewModelEditorTest { } @Test - fun choosingNoTagStoresNoTag() = runTest { + fun tagsToggleOneByOneAndNoTagTakesThemAllOff() = runTest { val repository = repositoryWith(storedNote(text("body"))) val viewModel = createViewModel(repository, noteId = NOTE_ID) advanceUntilIdle() - viewModel.updateTagNote(TAG_ID) - assertEquals(TAG_ID, viewModel.state.value.note.tagId) - viewModel.updateTagNote(0L) + viewModel.toggleTagNote(TAG_ID) + assertEquals(setOf(TAG_ID), viewModel.state.value.note.tagIds) + viewModel.toggleTagNote(OTHER_TAG_ID) + assertEquals(setOf(TAG_ID, OTHER_TAG_ID), viewModel.state.value.note.tagIds) + viewModel.toggleTagNote(TAG_ID) + assertEquals(setOf(OTHER_TAG_ID), viewModel.state.value.note.tagIds) + viewModel.toggleTagNote(0L) - assertNull(viewModel.state.value.note.tagId) + assertEquals(emptySet(), viewModel.state.value.note.tagIds) } @Test @@ -482,7 +486,7 @@ class EditNoteViewModelEditorTest { } private fun repositoryWith(note: Note) = TestNotesRepository().apply { - allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) + allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) insertResult = INSERTED_ID } @@ -528,5 +532,6 @@ class EditNoteViewModelEditorTest { const val DEFAULT_FONT_SIZE = 16 const val SAVED_URL = "https://example.com/saved" const val TAG_ID = 5L + const val OTHER_TAG_ID = 6L } } diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.kt new file mode 100644 index 00000000..d3794b33 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.kt @@ -0,0 +1,316 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTags +import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase +import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType +import com.pasich.encly.editor.state.TextPoint +import com.pasich.encly.platform.SensitiveClipboard +import com.pasich.encly.presentation.screen.editnote.copyPickedBlocks +import com.pasich.encly.presentation.screen.editnote.cutPickedBlocks +import com.pasich.encly.presentation.screen.editnote.selectedBlocksText +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.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 + +/** Picking whole blocks in a note: who may, and what copy and cut put on the clipboard. */ +@OptIn(ExperimentalCoroutinesApi::class) +class EditNoteViewModelSelectionTest { + private val dispatcher = StandardTestDispatcher() + private val settings = TestSettingsRepository() + private val note = Note( + id = NOTE_ID, + title = "Cats", + value = BlockConverter.blocksToJson( + listOf( + Block.HBlock(MutableStateFlow("Title"), BlockType.H1), + Block.TextBlock(MutableStateFlow("the cat sat")), + Block.QuoteBlock(MutableStateFlow("on the mat")), + ), + ), + ) + private val repository = TestNotesRepository().apply { + allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) + } + private val clipboard = FakeClipboard() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private class FakeClipboard : SensitiveClipboard { + var copies = mutableListOf>() + var accepts = true + override var systemConfirmsCopy = false + + override fun copy(label: String, text: String): Boolean = accepts.also { if (it) copies += label to text } + + override fun clear() = Unit + } + + private fun viewModel(trash: Boolean = false) = EditNoteViewModel( + notesRepository = repository, + savedStateHandle = SavedStateHandle(mapOf("idNote" to NOTE_ID, "isReadTrashOnly" to trash)), + updateNoteTrashStatusUseCase = UpdateNoteTrashStatusUseCase(repository), + updateNoteArchiveStatusUseCase = UpdateNoteArchiveStatusUseCase(repository), + settingsRepository = settings, + appScope = CoroutineScope(dispatcher), + ioDispatcher = dispatcher, + copyTitle = { "$it (Copy)" }, + ) + + private fun TestScope.opened(trash: Boolean = false): EditNoteViewModel { + val viewModel = viewModel(trash) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.simpleEdit.collect {} } + advanceUntilIdle() + return viewModel + } + + // --- who may pick ---------------------------------------------------------------------------------- + + @Test + fun selectAllPicksTheWholeNote() = runTest(dispatcher) { + val viewModel = opened() + + assertTrue(viewModel.selectAllBlocks()) + + assertEquals(3, viewModel.selectedBlocks.count) + } + + @Test + fun selectAllTextSelectsTheWholeNotesTextExceptInSimpleEditing() = runTest(dispatcher) { + val viewModel = opened() + + assertTrue(viewModel.selectAllText()) + assertTrue(viewModel.textRange.isCrossField) + assertEquals( + "Title\n\nthe cat sat\n\non the mat", + viewModel.textRangeBlocks().let { + selectedBlocksText(it, false) + }, + ) + + settings.simpleEditFlow.value = true + val simple = opened() + assertFalse(simple.selectAllText()) + } + + @Test + fun simpleEditingHasNoBlocksToPick() = runTest(dispatcher) { + settings.simpleEditFlow.value = true + val viewModel = opened() + + assertFalse(viewModel.selectAllBlocks()) + + assertFalse(viewModel.selectedBlocks.isActive) + } + + @Test + fun aLockedNoteCanBePickedButNotCutOrDeleted() = runTest(dispatcher) { + val viewModel = opened() + viewModel.toggleLockEditor() + assertTrue(viewModel.selectAllBlocks()) + assertFalse(viewModel.canEditBlocks) + + assertFalse(viewModel.deleteSelectedBlocks()) + assertNull(cutPickedBlocks(clipboard, viewModel, "label", "Copied")) + + assertEquals(3, viewModel.blocks.size) + assertTrue(clipboard.copies.isEmpty()) + assertTrue(viewModel.selectedBlocks.isActive) + } + + @Test + fun aNoteInTheTrashCanBeCopiedFrom() = runTest(dispatcher) { + val viewModel = opened(trash = true) + viewModel.selectAllBlocks() + + assertEquals("Copied", copyPickedBlocks(clipboard, viewModel, markdown = false, "label", "Copied")) + + assertEquals("Title\n\nthe cat sat\n\non the mat", clipboard.copies.single().second) + } + + // --- deleting -------------------------------------------------------------------------------------- + + @Test + fun deletingPickedBlocksIsOneUndoStep() = runTest(dispatcher) { + val viewModel = opened() + viewModel.selectedBlocks.select(viewModel.blocks[0].id) + viewModel.selectedBlocks.toggle(viewModel.blocks[2].id) + + assertTrue(viewModel.deleteSelectedBlocks()) + assertEquals(1, viewModel.blocks.size) + + viewModel.undo() + assertEquals(3, viewModel.blocks.size) + } + + // --- copy and cut ---------------------------------------------------------------------------------- + + @Test + fun theTextOfPickedBlocksIsMarkdownOrPlain() { + val blocks = listOf( + Block.HBlock(MutableStateFlow("Title"), BlockType.H2), + Block.TextBlock(MutableStateFlow("body")), + ) + + assertEquals("## Title\n\nbody", selectedBlocksText(blocks, markdown = true)) + assertEquals("Title\n\nbody", selectedBlocksText(blocks, markdown = false)) + } + + @Test + fun copyingGoesThroughTheSensitiveClipboardAndEndsTheMode() = runTest(dispatcher) { + val viewModel = opened() + viewModel.selectAllBlocks() + + val said = copyPickedBlocks(clipboard, viewModel, markdown = true, "label", "Copied as Markdown") + + assertEquals("Copied as Markdown", said) + assertEquals("label" to "# Title\n\nthe cat sat\n\n> on the mat", clipboard.copies.single()) + assertFalse(viewModel.selectedBlocks.isActive) + assertEquals(3, viewModel.blocks.size) + } + + @Test + fun noConfirmationWhereTheSystemSaysItItself() = runTest(dispatcher) { + clipboard.systemConfirmsCopy = true + val viewModel = opened() + viewModel.selectAllBlocks() + + assertNull(copyPickedBlocks(clipboard, viewModel, markdown = false, "label", "Copied")) + + assertEquals(1, clipboard.copies.size) + } + + @Test + fun aCopyThatFailedKeepsTheSelection() = runTest(dispatcher) { + clipboard.accepts = false + val viewModel = opened() + viewModel.selectAllBlocks() + + assertNull(copyPickedBlocks(clipboard, viewModel, markdown = false, "label", "Copied")) + + assertTrue(viewModel.selectedBlocks.isActive) + } + + @Test + fun nothingPickedCopiesNothing() = runTest(dispatcher) { + val viewModel = opened() + + assertNull(copyPickedBlocks(clipboard, viewModel, markdown = false, "label", "Copied")) + + assertTrue(clipboard.copies.isEmpty()) + } + + @Test + fun cuttingCopiesAsMarkdownAndThenRemovesTheBlocksInOneStep() = runTest(dispatcher) { + val viewModel = opened() + viewModel.selectedBlocks.select(viewModel.blocks[1].id) + viewModel.selectedBlocks.toggle(viewModel.blocks[2].id) + + assertEquals("Copied", cutPickedBlocks(clipboard, viewModel, "label", "Copied")) + + assertEquals("the cat sat\n\n> on the mat", clipboard.copies.single().second) + assertEquals(1, viewModel.blocks.size) + viewModel.undo() + assertEquals(3, viewModel.blocks.size) + } + + @Test + fun pastingBackExactlyWhatWasCutInsertsTheTextNotItsEscapedMarkdown() = runTest(dispatcher) { + val viewModel = opened() + viewModel.onBlockTextChanged(viewModel.blocks[1], "price*2") + viewModel.selectedBlocks.select(viewModel.blocks[1].id) + cutPickedBlocks(clipboard, viewModel, "label", "Copied") + val onClipboard = clipboard.copies.single().second + assertEquals("price\\*2", onClipboard) + val target = viewModel.blocks.last() + + assertNull(viewModel.onPasteCopied(target, null, "", "something else", "")) + assertEquals("price*2on the mat", viewModel.onPasteCopied(target, null, "", onClipboard, "on the mat")) + + viewModel.toggleLockEditor() + assertNull(viewModel.onPasteCopied(target, null, "", onClipboard, "on the mat")) + } + + @Test + fun aCopyWithNothingInItForgetsTheEarlierOne() = runTest(dispatcher) { + val viewModel = opened() + viewModel.selectedBlocks.select(viewModel.blocks[1].id) + copyPickedBlocks(clipboard, viewModel, markdown = false, "label", "Copied") + + viewModel.rememberCopied("x", emptyList()) + + assertNull(viewModel.onPasteCopied(viewModel.blocks[2], null, "", "the cat sat", "")) + } + + @Test + fun aCutThatCouldNotCopyRemovesNothing() = runTest(dispatcher) { + clipboard.accepts = false + val viewModel = opened() + viewModel.selectAllBlocks() + + assertNull(cutPickedBlocks(clipboard, viewModel, "label", "Copied")) + + assertEquals(3, viewModel.blocks.size) + } + + @Test + fun cuttingAsksNothingOfASystemThatConfirmsItself() = runTest(dispatcher) { + clipboard.systemConfirmsCopy = true + val viewModel = opened() + viewModel.selectAllBlocks() + + assertNull(cutPickedBlocks(clipboard, viewModel, "label", "Copied")) + + assertEquals(1, viewModel.blocks.size) + } + + @Test + fun aTextSelectionAcrossBlocksIsReplacedOnlyWhereTheNoteCanChange() = runTest(dispatcher) { + val viewModel = opened() + val ends = viewModel.blocks.map { it.id } + viewModel.textRange.select(TextPoint(ends[0], 2), TextPoint(ends[2], 3)) + + assertTrue(viewModel.replaceTextRange("-")) + assertEquals(1, viewModel.blocks.size) + + viewModel.undo() + viewModel.toggleLockEditor() + viewModel.textRange.select(TextPoint(ends[0], 2), TextPoint(ends[2], 3)) + assertFalse(viewModel.replaceTextRange("-")) + assertEquals(3, viewModel.blocks.size) + } + + private companion object { + const val NOTE_ID = 91L + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelTest.kt index 0386b969..909f685a 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelTest.kt @@ -2,7 +2,7 @@ package com.pasich.encly.presentation.viewmodel import androidx.lifecycle.SavedStateHandle import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.domain.model.FontStyleType import com.pasich.encly.domain.repository.SettingsRepository import com.pasich.encly.domain.usecase.note.UpdateNoteArchiveStatusUseCase @@ -73,7 +73,7 @@ class EditNoteViewModelTest { fun titleOnlyEmptyBlockPayloadIsValidAndDoesNotTriggerCorruptionGuard() = runTest { val note = Note(id = EXISTING_NOTE_ID, title = "title only", value = "[]") val repository = TestNotesRepository().apply { - allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) + allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) } val viewModel = createViewModel(repository, noteId = EXISTING_NOTE_ID) @@ -88,7 +88,7 @@ class EditNoteViewModelTest { fun malformedStoredBlockJsonTriggersCorruptionGuard() = runTest { val note = Note(id = EXISTING_NOTE_ID, title = "broken", value = "{not-json") val repository = TestNotesRepository().apply { - allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) + allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) } val viewModel = createViewModel(repository, noteId = EXISTING_NOTE_ID) diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt similarity index 77% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt index 3da0dc0a..9ddad6ad 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt @@ -1,18 +1,18 @@ package com.pasich.encly.presentation.viewmodel -import android.app.Application -import android.content.Context -import android.net.Uri import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.SavedStateHandle -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.BackupManager +import com.pasich.encly.data.backup.NoBackupPreferences import com.pasich.encly.data.handoff.HandoffFixtures.handoff import com.pasich.encly.data.handoff.HandoffFixtures.handoffZip import com.pasich.encly.data.handoff.HandoffFixtures.note -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.platform.CacheDirectory +import com.pasich.encly.platform.DocumentRef +import com.pasich.encly.testutil.FakeDocuments +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -20,6 +20,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.io.files.Path import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -27,24 +28,23 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder -import org.junit.runner.RunWith import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config import java.io.File /** The hand-off's steps across a killed process: a finished import is not offered again. */ @OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35], application = Application::class) class ImportFromMyNotesViewModelTest { @get:Rule val temp = TemporaryFolder() - private val context: Context get() = ApplicationProvider.getApplicationContext() + private val documents = FakeDocuments() + private val cacheDir: File by lazy { temp.newFolder("cache") } + private val cache = object : CacheDirectory { + override val path get() = Path(cacheDir.path) + } private val dispatcher = UnconfinedTestDispatcher() private lateinit var security: SecurityManager private lateinit var sessionLock: SessionLockManager @@ -68,11 +68,15 @@ class ImportFromMyNotesViewModelTest { Dispatchers.resetMain() } + /** The zip as a picked document the way a provider would hand it out. */ + private fun doc(zip: File) = DocumentRef(zip.path).also { documents.files[it] = zip.readBytes() } + private fun viewModel() = ImportFromMyNotesViewModel( - context = context, + documents = documents, + cache = cache, securityManager = security, sessionLockManager = sessionLock, - backupManager = BackupManager(security, store, InMemorySharedPreferences()), + backupManager = BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences), ioDispatcher = dispatcher, savedStateHandle = savedState, ) @@ -80,7 +84,7 @@ class ImportFromMyNotesViewModelTest { @Test fun aFinishedHandoffShowsItsCountsAgainAfterTheProcessWasKilled() { val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1"), note("n2")))) - val uri = Uri.fromFile(zip) + val uri = doc(zip) val first = viewModel() first.start(uri) assertTrue(first.step.value is HandoffStep.Preview) @@ -90,6 +94,7 @@ class ImportFromMyNotesViewModelTest { // A new process: the activity comes back with the same intent and the saved state. zip.delete() + documents.files.clear() val restored = viewModel() restored.start(uri) @@ -101,10 +106,10 @@ class ImportFromMyNotesViewModelTest { @Test fun aHandoffNotFinishedBeforeTheKillStartsOver() { val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1")))) - viewModel().start(Uri.fromFile(zip)) + viewModel().start(doc(zip)) val restored = viewModel() - restored.start(Uri.fromFile(zip)) + restored.start(doc(zip)) assertTrue(restored.step.value is HandoffStep.Preview) verify(security, never()).resolveInitialStatus() @@ -114,8 +119,8 @@ class ImportFromMyNotesViewModelTest { fun theStagingDirectoryIsEmptyAfterTheRead() { val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1")))) - viewModel().start(Uri.fromFile(zip)) + viewModel().start(doc(zip)) - assertTrue(File(context.cacheDir, "mynotes-handoff").list().isNullOrEmpty()) + assertTrue(File(cacheDir, "mynotes-handoff").list().isNullOrEmpty()) } } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ListLoadStateViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ListLoadStateViewModelTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/ListLoadStateViewModelTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ListLoadStateViewModelTest.kt diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt new file mode 100644 index 00000000..835039b8 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -0,0 +1,458 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.CreationExtras +import com.pasich.encly.core.security.AuthStrategy +import com.pasich.encly.core.security.DeviceLockWatcher +import com.pasich.encly.core.security.RecoveryStage +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.core.security.VaultUnlockResult +import com.pasich.encly.testutil.TestScreen +import com.pasich.encly.testutil.answerCallback +import com.pasich.encly.testutil.anyByteArray +import com.pasich.encly.testutil.anyCallback +import com.pasich.encly.testutil.anyCharArray +import com.pasich.encly.testutil.eqValue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.timeout +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.reflect.KClass + +/** The lock screen: PIN, recovery words and biometric unlock, and an unlock that lands in the background. */ +@OptIn(ExperimentalCoroutinesApi::class) +class LockViewModelTest { + private lateinit var security: SecurityManager + private lateinit var sessionLock: SessionLockManager + private lateinit var viewModel: LockViewModel + private val screen = TestScreen() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + security = mock(SecurityManager::class.java) + sessionLock = SessionLockManager(security) + sessionLock.onStart(mock(LifecycleOwner::class.java)) + viewModel = LockViewModel(security, sessionLock, CoroutineScope(Dispatchers.Default)) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun theRightPinUnlocks() = runTest { + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + + assertEquals(PinUnlockResult.SUCCESS, pin()) + assertFalse(viewModel.busy.value) + assertFalse(sessionLock.locked.value) + } + + @Test + fun theRestOfAWipePinEraseRunsAfterThePinUnlockedAndOnlyThen() = runTest { + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) + pin() + verify(security, never()).completePendingWipe() + + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + assertEquals(PinUnlockResult.SUCCESS, pin()) + + // On the application scope, off the unlock's path. + verify(security, timeout(WAIT_MS)).completePendingWipe() + } + + @Test + fun aNoteIsReopenedOnlyInTheVaultItWasRememberedIn() { + `when`(security.eraseEpoch()).thenReturn(7L) + + assertTrue(viewModel.canReopenNote(7L)) + assertFalse("an erase changed the epoch", viewModel.canReopenNote(6L)) + assertFalse("nothing remembered", viewModel.canReopenNote(null)) + } + + @Test + fun aLockThatCameWhileThePinWasBeingCheckedIsNotLostToTheUnlock() = runTest { + // The OS locks the session (or the idle lock fires) during the PBKDF2 run: nothing is open yet to close. + `when`(security.isLockable()).thenReturn(true) + doAnswer { + sessionLock.lockNow() + VaultUnlockResult.SUCCESS + }.`when`(security).unlockWithPin(PIN.toCharArray()) + + assertEquals(PinUnlockResult.BACKGROUNDED, pin()) + + verify(security).lock() + assertTrue(sessionLock.locked.value) + } + + @Test + fun aRightPinWhileTheDeviceIsLockedIsRefusedAndSaysWhy() = runTest { + val deviceLocked = object : DeviceLockWatcher { + override fun isDeviceLocked(): Boolean = true + + override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit = {} + } + val guarded = SessionLockManager(security, deviceLock = deviceLocked) + guarded.onStart(mock(LifecycleOwner::class.java)) + val locked = LockViewModel(security, guarded, CoroutineScope(Dispatchers.Default)) + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + `when`(security.unlockWithSeed(WORDS.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + val pin = CompletableDeferred() + val seed = CompletableDeferred() + + locked.authenticatePin(PIN.toCharArray()) { pin.complete(it) } + locked.authenticateSeed(WORDS.toCharArray()) { seed.complete(it) } + + assertEquals(PinUnlockResult.DEVICE_LOCKED, pin.await()) + assertEquals(SeedUnlockResult.DEVICE_LOCKED, seed.await()) + assertTrue(guarded.locked.value) + } + + @Test + fun theRecoveryPhraseResetIsAskedOfTheSecurityLayer() { + `when`(security.recoveryStage()).thenReturn(RecoveryStage.MUST_SET_PIN) + assertTrue(viewModel.mustResetPin()) + assertEquals(RecoveryStage.MUST_SET_PIN, viewModel.recoveryStage()) + + // A choice is not a forced reset: the lock screen routes it to the keep-or-change page instead. + `when`(security.recoveryStage()).thenReturn(RecoveryStage.CHOOSE) + assertFalse(viewModel.mustResetPin()) + assertEquals(RecoveryStage.CHOOSE, viewModel.recoveryStage()) + + `when`(security.recoveryStage()).thenReturn(RecoveryStage.NONE) + assertFalse(viewModel.mustResetPin()) + } + + @Test + fun keepingThePinAfterRecoveryIsLeftToTheSecurityLayer() { + `when`(security.keepUnlockMethodsAfterRecovery()).thenReturn(true) + + assertTrue(viewModel.keepAfterRecovery()) + + verify(security).keepUnlockMethodsAfterRecovery() + } + + @Test + fun aWrongPinAndABrokenVaultAreToldApart() = runTest { + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) + assertEquals(PinUnlockResult.WRONG_PIN, pin()) + + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.DB_ERROR) + assertEquals(PinUnlockResult.DB_ERROR, pin()) + + verify(security, never()).lock() + } + + @Test + fun aPinUnlockThatFinishesInTheBackgroundClosesTheVaultAgain() = runTest { + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + sessionLock.onStop(mock(LifecycleOwner::class.java)) + + assertEquals(PinUnlockResult.BACKGROUNDED, pin()) + verify(security).lock() + assertTrue(sessionLock.locked.value) + } + + @Test + fun anUnlockThatFinishesAfterTheScreenIsGoneClosesTheVaultAgain() { + // The PIN check (KDF) is running when the lock screen's ViewModel is cleared. + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + `when`(security.unlockWithPin(anyCharArray())).thenAnswer { + entered.countDown() + release.await(5, TimeUnit.SECONDS) + VaultUnlockResult.SUCCESS + } + `when`(security.isLockable()).thenReturn(true) + `when`(security.isDatabaseUnlocked()).thenReturn(true) + val store = ViewModelStore() + val owned = ViewModelProvider.create( + store, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: KClass, extras: CreationExtras): T = viewModel as T + }, + )[LockViewModel::class] + var published: PinUnlockResult? = null + + owned.authenticatePin(PIN.toCharArray()) { published = it } + assertTrue(entered.await(5, TimeUnit.SECONDS)) + store.clear() + release.countDown() + + verify(security, timeout(5_000)).lock() + assertTrue(sessionLock.locked.value) + assertEquals("nothing navigates into the vault", null, published) + } + + @Test + fun aLostPinKeyAndALockoutAreReported() = runTest { + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.PIN_KEY_LOST) + assertEquals(PinUnlockResult.KEY_LOST, pin()) + + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.LOCKED_OUT) + assertEquals(PinUnlockResult.LOCKED_OUT, pin()) + } + + @Test + fun theRecoveryWordsUnlockAndAreWipedAfterwards() = runTest { + `when`(security.unlockWithSeed(ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0))) + .thenReturn(VaultUnlockResult.SUCCESS) + val words = WORDS.toCharArray() + + assertEquals(SeedUnlockResult.SUCCESS, seed(words)) + assertArrayEquals(CharArray(words.size), words) + } + + @Test + fun wrongRecoveryWordsAreReportedAndStillWiped() = runTest { + `when`(security.unlockWithSeed(ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0))) + .thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) + val words = WORDS.toCharArray() + + assertEquals(SeedUnlockResult.WRONG_SEED, seed(words)) + assertArrayEquals(CharArray(words.size), words) + + `when`(security.unlockWithSeed(ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0))) + .thenReturn(VaultUnlockResult.DB_ERROR) + assertEquals(SeedUnlockResult.DB_ERROR, seed(WORDS.toCharArray())) + } + + @Test + fun aSeedUnlockThatFinishesInTheBackgroundIsNotPublished() = runTest { + `when`(security.unlockWithSeed(ArgumentMatchers.any(CharArray::class.java) ?: CharArray(0))) + .thenReturn(VaultUnlockResult.SUCCESS) + sessionLock.onStop(mock(LifecycleOwner::class.java)) + + assertEquals(SeedUnlockResult.BACKGROUNDED, seed(WORDS.toCharArray())) + } + + @Test + fun aBiometricKeyUnlocksAndIsWiped() = runTest { + val key = ByteArray(KEY_LENGTH) { 7 } + answerCallback( + key, + ).`when`(security).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + `when`(security.unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean())).thenReturn(true) + + assertTrue(biometric()) + assertArrayEquals(ByteArray(KEY_LENGTH), key) + } + + @Test + fun aCancelledPromptOrARejectedKeyDoesNotUnlock() = runTest { + answerCallback( + null, + ).`when`(security).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + assertFalse(biometric()) + verify(security, never()).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) + + answerCallback(ByteArray(KEY_LENGTH)).`when`(security) + .requestBiometricKey(eqValue(screen.biometric), anyCallback()) + `when`(security.unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean())).thenReturn(false) + assertFalse(biometric()) + } + + @Test + fun aSecondBiometricRequestWhileOneIsOpenIsIgnored() { + // The prompt never answers: the first request stays in flight. + viewModel.authenticateBiometric(screen.host) {} + viewModel.authenticateBiometric(screen.host) {} + + verify(security, times(1)).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + assertTrue(viewModel.biometricInFlight) + } + + @Test + fun aRotationDuringThePromptReleasesItForTheRecreatedScreen() { + viewModel.authenticateBiometric(screen.host) {} + assertTrue(viewModel.biometricInFlight) + + // androidx.biometric drops the answer of a prompt whose activity is gone: without this the + // ViewModel, which outlives the rotation, would never prompt again. + screen.destroy() + + assertFalse(viewModel.biometricInFlight) + val recreated = TestScreen() + viewModel.authenticateBiometric(recreated.host) {} + verify(security).requestBiometricKey(eqValue(recreated.biometric), anyCallback()) + } + + @Test + fun aPromptCutOffByTheAppLeavingTheForegroundLeavesTheButtonUsable() { + // androidx.biometric cancels a prompt whose activity stops, and never answers it. + viewModel.authenticateBiometric(screen.host) {} + assertTrue(viewModel.biometricInFlight) + screen.stop() + assertFalse(viewModel.biometricInFlight) + + screen.resume() + viewModel.authenticateBiometric(screen.host) {} + + verify(security, times(2)).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + assertTrue(viewModel.biometricInFlight) + } + + @Test + fun aBackgroundedActivityGetsNoPromptAndTheNextOneIsNotBlocked() { + // The auto-lock fires in the background: the lock screen asks while the activity is stopped. + val backgrounded = TestScreen(Lifecycle.State.CREATED) + viewModel.authenticateBiometric(backgrounded.host) {} + + verify(security, never()).requestBiometricKey(eqValue(backgrounded.biometric), anyCallback()) + assertFalse(viewModel.biometricInFlight) + + backgrounded.resume() + viewModel.authenticateBiometric(backgrounded.host) {} + verify(security).requestBiometricKey(eqValue(backgrounded.biometric), anyCallback()) + } + + @Test + fun anAnswerAfterAStopIsTakenWhenNoNewerPromptStarted() { + val answer = arrayOfNulls<(ByteArray?) -> Unit>(1) + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + answer[0] = invocation.arguments.last() as (ByteArray?) -> Unit + null + }.`when`(security).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + `when`(security.unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean())).thenReturn(true) + val results = mutableListOf() + viewModel.authenticateBiometric(screen.host) { results += it } + // Some devices show the prompt over a stopped activity; its answer still unlocks. + screen.stop() + + answer[0]!!(ByteArray(KEY_LENGTH) { 7 }) + + verify(security, timeout(WAIT_MS)).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) + } + + @Test + fun anAnswerFromAStoppedPromptIsDroppedOnceANewerPromptStarted() { + val answers = mutableListOf<(ByteArray?) -> Unit>() + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + answers += invocation.arguments.last() as (ByteArray?) -> Unit + null + }.`when`(security).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + val results = mutableListOf() + viewModel.authenticateBiometric(screen.host) { results += it } + screen.stop() + screen.resume() + viewModel.authenticateBiometric(screen.host) { results += it } + + val key = ByteArray(KEY_LENGTH) { 7 } + answers.first()(key) + + assertTrue(results.isEmpty()) + assertArrayEquals(ByteArray(KEY_LENGTH), key) + verify(security, never()).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) + } + + @Test + fun aLateAnswerFromTheDestroyedActivityIsDroppedAndItsKeyWiped() { + val answer = arrayOfNulls<(ByteArray?) -> Unit>(1) + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + answer[0] = invocation.arguments.last() as (ByteArray?) -> Unit + null + }.`when`(security).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + val results = mutableListOf() + viewModel.authenticateBiometric(screen.host) { results += it } + screen.destroy() + + val key = ByteArray(KEY_LENGTH) { 7 } + answer[0]!!(key) + + assertTrue(results.isEmpty()) + assertArrayEquals(ByteArray(KEY_LENGTH), key) + verify(security, never()).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) + } + + @Test + fun aDestroyedActivityGetsNoPrompt() { + screen.destroy() + + viewModel.authenticateBiometric(screen.host) {} + + verify(security, never()).requestBiometricKey(eqValue(screen.biometric), anyCallback()) + assertFalse(viewModel.biometricInFlight) + } + + @Test + fun theStrategyDecidesWhichFormTheLockScreenShows() { + `when`(security.authStrategy()).thenReturn(AuthStrategy.SEED_PHRASE_BIOMETRIC) + assertTrue(viewModel.isSeedStrategy()) + `when`(security.authStrategy()).thenReturn(AuthStrategy.SEED_PHRASE) + assertTrue(viewModel.isSeedStrategy()) + `when`(security.authStrategy()).thenReturn(AuthStrategy.PIN_BIOMETRIC) + assertFalse(viewModel.isSeedStrategy()) + assertEquals(AuthStrategy.PIN_BIOMETRIC, viewModel.strategy()) + } + + @Test + fun theLockScreenAsksTheVaultWhatItCanOffer() { + `when`(security.isBiometricEnabled()).thenReturn(true) + `when`(security.biometricAvailable()).thenReturn(false) + `when`(security.hasRecoverySeed()).thenReturn(true) + `when`(security.pinLockoutRemainingMillis()).thenReturn(LOCKOUT_MS) + + assertTrue(viewModel.biometricEnabled()) + assertFalse(viewModel.biometricAvailable()) + assertTrue(viewModel.recoveryAvailable()) + assertEquals(LOCKOUT_MS, viewModel.lockoutRemainingMillis()) + } + + private suspend fun pin(): PinUnlockResult { + val result = CompletableDeferred() + viewModel.authenticatePin(PIN.toCharArray()) { result.complete(it) } + return result.await() + } + + private suspend fun seed(words: CharArray): SeedUnlockResult { + val result = CompletableDeferred() + viewModel.authenticateSeed(words) { result.complete(it) } + return result.await() + } + + private suspend fun biometric(): Boolean { + val result = CompletableDeferred() + viewModel.authenticateBiometric(screen.host) { result.complete(it) } + return result.await() + } + + private companion object { + const val PIN = "482915" + const val WORDS = "one two three four five six seven eight nine ten eleven twelve" + const val KEY_LENGTH = 32 + const val LOCKOUT_MS = 30_000L + const val WAIT_MS = 5_000L + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListEventsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEventsTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListEventsTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEventsTest.kt index e7b4efa2..f4f6031b 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListEventsTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEventsTest.kt @@ -1,7 +1,7 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.data.model.Tag import com.pasich.encly.domain.enums.NoteSortOption import com.pasich.encly.domain.model.NoteColor @@ -45,7 +45,7 @@ class NoteListEventsTest { entry(Note(id = 1, title = "Older", date = 10, dateCreate = 30)), entry(Note(id = 2, title = "Newer", date = 20, dateCreate = 10)), entry( - Note(id = 3, title = "Hidden", date = 30, tagId = HIDDEN_TAG), + Note(id = 3, title = "Hidden", date = 30, tagIds = setOf(HIDDEN_TAG)), Tag(id = HIDDEN_TAG, isVisible = false), ), ) @@ -126,7 +126,7 @@ class NoteListEventsTest { fun anActionOnAStaleNoteWritesOnlyItsOwnColumn() = runTest(dispatcher) { advanceUntilIdle() // The sheet still holds the note as read in tag 7's view; its tag was changed to 4 since. - val stale = note(1).copy(tagId = 7) + val stale = note(1).copy(tagIds = setOf(7)) viewModel.onEvent(NoteListEvent.ChangeTag(stale, idTag = WORK_TAG)) viewModel.onEvent(NoteListEvent.ChangeColor(stale, NoteColor.TEAL)) @@ -192,7 +192,7 @@ class NoteListEventsTest { private fun note(id: Long) = repository.allNotesWithTags.value.single { it.note.id == id }.note - private fun entry(note: Note, tag: Tag? = null) = NoteWithTag(note = note, tag = tag) + private fun entry(note: Note, tag: Tag? = null) = NoteWithTags(note, listOfNotNull(tag)) private companion object { const val HIDDEN_TAG = 9L diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModelTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListViewModelTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModelTest.kt index c225fd10..2d9a722c 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/NoteListViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModelTest.kt @@ -2,7 +2,7 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.core.common.LoadState import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import com.pasich.encly.data.model.Tag import com.pasich.encly.domain.enums.NoteSortOption import com.pasich.encly.domain.repository.NotesRepository @@ -116,7 +116,7 @@ class NoteListViewModelTest { @Test fun aFailedReadIsShownAsAnErrorNotAsAnEmptyVault() = runTest { val failing = object : NotesRepository by TestNotesRepository() { - override fun getAllNotesWithTag(): Flow> = flow { throw IOException("vault closed") } + override fun getAllNotesWithTag(): Flow> = flow { throw IOException("vault closed") } } val settings = mock(SettingsRepository::class.java) `when`(settings.getSortNotes).thenReturn(MutableStateFlow(NoteSortOption.UPDATED_DESC)) @@ -214,15 +214,15 @@ class NoteListViewModelTest { assertEquals(second, viewModel.scrollToTopRequest.value) } - private fun noteWithTitle(id: Long, title: String, tag: Tag? = null): NoteWithTag = NoteWithTag( - note = Note( + private fun noteWithTitle(id: Long, title: String, tag: Tag? = null): NoteWithTags = NoteWithTags( + Note( id = id, title = title, date = id, dateCreate = id, - tagId = tag?.id, + tagIds = setOfNotNull(tag?.id), ), - tag = tag, + listOfNotNull(tag), ) private companion object { diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt new file mode 100644 index 00000000..a485c08a --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt @@ -0,0 +1,306 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.core.common.UiText +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.backup_error_no_picker +import com.pasich.encly.core.resources.onboarding_error_initialization +import com.pasich.encly.core.resources.onboarding_error_seed_generation +import com.pasich.encly.core.resources.onboarding_error_vault_creation +import com.pasich.encly.core.security.Mnemonic +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.data.backup.BackupDocuments +import com.pasich.encly.data.backup.BackupManager +import com.pasich.encly.data.backup.NoBackupPreferences +import com.pasich.encly.data.backup.PendingRestore +import com.pasich.encly.domain.usecase.OnboardingUseCase +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.testutil.InMemoryVaultDataStore +import com.pasich.encly.testutil.TestScreen +import com.pasich.encly.testutil.answerCallback +import com.pasich.encly.testutil.anyCallback +import com.pasich.encly.testutil.anyCharArray +import com.pasich.encly.testutil.eqValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +/** Onboarding when something fails, plus the small switches of its steps. */ +@OptIn(ExperimentalCoroutinesApi::class) +class OnboardingFailuresTest { + private val words = Mnemonic.generate() + private lateinit var security: SecurityManager + private val screen = TestScreen() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + security = mock(SecurityManager::class.java) + `when`(security.generateMnemonicCode()).thenAnswer { words.copyOf() } + `when`(security.initializeNewVault(anyChars())).thenReturn(true) + `when`(security.configurePin(anyCharArray())).thenReturn(true) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun aVaultThatCannotBeCreatedOnSkipShowsAnErrorAndStays() = runTest { + `when`(security.initializeNewVault(anyChars())).thenReturn(false) + val vm = atRecoveryInfo() + + vm.skipPhrase() + val state = vm.uiState.first { !it.isLoading } + + assertEquals(OnboardingStep.RECOVERY_INFO, state.step) + assertEquals(UiText.of(Res.string.onboarding_error_initialization), state.error) + verify(security, never()).configurePin(anyCharArray()) + } + + @Test + fun aVaultThatCannotBeCreatedAfterTheCheckShowsAnErrorAndCanBeRetried() = runTest { + `when`(security.initializeNewVault(anyChars())).thenReturn(false) + val vm = atRecoveryInfo() + vm.createPhrase() + vm.startSeedPhraseVerification() + vm.uiState.value.verificationWords.forEach { (index, word) -> vm.updateUserAnswer(index, word) } + + vm.completeVerification() + val failed = vm.uiState.first { !it.isLoading } + assertEquals(UiText.of(Res.string.onboarding_error_vault_creation), failed.error) + assertEquals(OnboardingStep.VERIFY, failed.step) + + `when`(security.initializeNewVault(anyChars())).thenReturn(true) + vm.completeVerification() + + assertEquals(OnboardingStep.READY, vm.uiState.first { it.step == OnboardingStep.READY }.step) + } + + @Test + fun wordsThatCannotBeGeneratedShowAnError() { + `when`(security.generateMnemonicCode()).thenThrow(IllegalStateException("no entropy")) + val vm = atRecoveryInfo() + + vm.createPhrase() + + assertTrue(vm.uiState.value.words.isEmpty()) + assertEquals(UiText.of(Res.string.onboarding_error_seed_generation), vm.uiState.value.error) + } + + @Test + fun theWordsCanBeHiddenAndShownAgain() { + val vm = atRecoveryInfo() + vm.createPhrase() + val hidden = vm.uiState.value.wordsHidden + + vm.toggleWordsHidden() + assertEquals(!hidden, vm.uiState.value.wordsHidden) + vm.toggleWordsHidden() + assertEquals(hidden, vm.uiState.value.wordsHidden) + } + + @Test + fun answersAreComparedTrimmedAndIgnoringCase() { + val vm = atRecoveryInfo() + vm.createPhrase() + vm.startSeedPhraseVerification() + val checks = vm.uiState.value.verificationWords + + checks.forEach { (index, word) -> vm.updateUserAnswer(index, " ${word.uppercase()} ") } + + assertTrue(vm.uiState.value.isVerificationComplete) + } + + @Test + fun oneWrongAnswerKeepsTheCheckOpen() { + val vm = atRecoveryInfo() + vm.createPhrase() + vm.startSeedPhraseVerification() + val checks = vm.uiState.value.verificationWords + + checks.forEach { (index, word) -> vm.updateUserAnswer(index, word) } + vm.updateUserAnswer(checks.first().first, "zzz") + vm.completeVerification() + + assertFalse(vm.uiState.value.isVerificationComplete) + verify(security, never()).initializeNewVault(anyChars()) + } + + @Test + fun skippingTheFingerprintContinuesWithThePinOnly() = runTest { + val vm = readyWithBiometricPending() + + vm.skipBiometric() + + assertFalse(vm.uiState.value.biometricPending) + assertFalse(vm.uiState.value.biometricEnabled) + verify(security, never()).enrollBiometric(eqValue(screen.biometric), anyCallback()) + } + + @Test + fun theFingerprintPromptIsShownOnceAndItsResultKept() = runTest { + answerCallback(true).`when`(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) + val vm = readyWithBiometricPending() + + vm.enrollBiometric(screen.host) + vm.enrollBiometric(screen.host) + + assertTrue(vm.uiState.value.biometricEnabled) + verify(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) + } + + @Test + fun aCancelledFingerprintPromptIsNotShownAgainByItself() = runTest { + answerCallback(false).`when`(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) + val vm = readyWithBiometricPending() + + vm.enrollBiometric(screen.host) + // What a resume of the screen or a recomposition would do: ask again. + screen.stop() + screen.resume() + vm.enrollBiometric(screen.host) + + val state = vm.uiState.value + assertFalse(state.biometricPending) + assertFalse(state.biometricEnabled) + assertTrue(state.biometricRetry) + verify(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) + } + + @Test + fun theRetryButtonShowsOnePromptPerTap() = runTest { + answerCallback(false).`when`(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) + val vm = readyWithBiometricPending() + vm.enrollBiometric(screen.host) + + vm.retryBiometric(screen.host) + + // One prompt for the first request, one for the tap; the cancelled tap offers the button again. + verify(security, times(2)).enrollBiometric(eqValue(screen.biometric), anyCallback()) + assertTrue(vm.uiState.value.biometricRetry) + } + + @Test + fun aTapWhileThePromptIsOpenIsIgnored() = runTest { + // The prompt never answers: it stays open. + val vm = readyWithBiometricPending() + vm.enrollBiometric(screen.host) + + vm.retryBiometric(screen.host) + + verify(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) + assertFalse(vm.uiState.value.biometricRetry) + } + + @Test + fun aPromptThatCannotBeShownOffersTheButton() = runTest { + val vm = readyWithBiometricPending() + screen.stop() + + vm.enrollBiometric(screen.host) + + assertTrue(vm.uiState.value.biometricRetry) + verify(security, never()).enrollBiometric(eqValue(screen.biometric), anyCallback()) + } + + @Test + fun aSuccessfulRetryEnrolsTheFingerprint() = runTest { + answerCallback(false).`when`(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) + val vm = readyWithBiometricPending() + vm.enrollBiometric(screen.host) + answerCallback(true).`when`(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) + + vm.retryBiometric(screen.host) + + assertTrue(vm.uiState.value.biometricEnabled) + assertFalse(vm.uiState.value.biometricRetry) + } + + @Test + fun noDocumentPickerIsReportedOnTheRestoreStep() { + val vm = viewModel() + vm.startRestore() + + vm.onRestorePickerUnavailable() + + assertEquals(UiText.of(Res.string.backup_error_no_picker), vm.uiState.value.restoreFileError) + } + + @Test + fun wordsTypedBeforeAFileIsPickedAreIgnored() { + val vm = viewModel() + vm.startRestore() + vm.onRestoreFilePicked(null) + + vm.restoreBackup(String(words).toCharArray()) + + assertFalse(vm.uiState.value.isLoading) + verify(security, never()).initializeNewVault(anyChars()) + } + + @Test + fun anAnswerIsWrongOnlyOnceItIsAsLongAsTheWord() { + assertEquals(AnswerState.EMPTY, phraseAnswerState("orbit", " ")) + assertEquals(AnswerState.TYPING, phraseAnswerState("orbit", "orb")) + assertEquals(AnswerState.TYPING, phraseAnswerState("orbit", "xyz")) + assertEquals(AnswerState.WRONG, phraseAnswerState("orbit", "orbix")) + assertEquals(AnswerState.CORRECT, phraseAnswerState("orbit", " ORBIT ")) + assertEquals(AnswerState.TYPING, phraseAnswerState(null, "orbit")) + } + + private fun viewModel(biometric: Boolean = false): OnboardingViewModel { + `when`(security.biometricAvailable()).thenReturn(biometric) + val backupManager = + BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore(), NoBackupPreferences) + return OnboardingViewModel( + OnboardingUseCase(security, backupManager, PendingRestore(backupManager)), + mock(BackupDocuments::class.java), + ) + } + + private fun atRecoveryInfo(): OnboardingViewModel = viewModel().apply { + getStarted() + repeat(2) { + PIN.forEach { onPinDigit(it.digitToInt()) } + submitPin() + } + check(uiState.value.step == OnboardingStep.RECOVERY_INFO) { "at ${uiState.value.step}" } + } + + private suspend fun readyWithBiometricPending(): OnboardingViewModel { + val vm = viewModel(biometric = true) + vm.getStarted() + vm.setBiometricRequested(true) + repeat(2) { + PIN.forEach { vm.onPinDigit(it.digitToInt()) } + vm.submitPin() + } + vm.skipPhrase() + vm.uiState.first { it.step == OnboardingStep.READY && !it.isLoading } + check(vm.uiState.value.biometricPending) { "no biometric pending" } + return vm + } + + private fun anyChars(): CharArray? = ArgumentMatchers.any() + + private companion object { + const val PIN = "482915" + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt similarity index 78% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt index ded7acf2..a1f95a7f 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt @@ -1,8 +1,11 @@ package com.pasich.encly.presentation.viewmodel -import androidx.fragment.app.FragmentActivity -import com.pasich.encly.R import com.pasich.encly.core.common.UiText +import com.pasich.encly.core.resources.Res +import com.pasich.encly.core.resources.biometric_change_not_confirmed +import com.pasich.encly.core.resources.biometric_enroll_failed +import com.pasich.encly.core.resources.pin_current_wrong +import com.pasich.encly.core.resources.pin_update_failed import com.pasich.encly.core.security.AuthSettings import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock @@ -10,10 +13,11 @@ 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.ScreenshotAllowance import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.WipePinChange -import com.pasich.encly.testutil.InMemorySharedPreferences -import com.pasich.encly.testutil.MockActivity +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.testutil.TestScreen import com.pasich.encly.testutil.answerCallback import com.pasich.encly.testutil.anyCallback import com.pasich.encly.testutil.anyCharArray @@ -47,8 +51,8 @@ import org.mockito.Mockito.`when` class SecuritySettingsViewModelTest { private lateinit var security: SecurityManager private lateinit var viewModel: SecuritySettingsViewModel - private val host = MockActivity() - private val activity: FragmentActivity = host.activity + private val screen = TestScreen() + private val screenshots = ScreenshotAllowance() @Before fun setUp() { @@ -61,13 +65,26 @@ class SecuritySettingsViewModelTest { viewModel = SecuritySettingsViewModel( security, - KeyboardPrivacy(InMemorySharedPreferences()), - AutoLock(InMemorySharedPreferences()), + KeyboardPrivacy(InMemoryKeyValueStore()), + AutoLock(InMemoryKeyValueStore()), + screenshots, ) // The first load reads the mock on an IO thread; stubbing it meanwhile would race. runBlocking { viewModel.uiState.first { it.loaded } } } + @Test + fun screenshotsAreBlockedUntilAllowedAndTurningThemOffEndsTheAllowance() { + assertFalse(viewModel.screenshotsAllowed.value) + + screenshots.allow() + assertTrue(viewModel.screenshotsAllowed.value) + + viewModel.stopAllowingScreenshots() + assertFalse(viewModel.screenshotsAllowed.value) + assertFalse(screenshots.capturable) + } + @After fun tearDown() { Dispatchers.resetMain() @@ -106,7 +123,7 @@ class SecuritySettingsViewModelTest { `when`(security.verifyPin("222222".toCharArray())).thenReturn(true) assertFalse(checkCurrent("111111")) - assertEquals(UiText.of(R.string.pin_current_wrong), viewModel.uiState.value.error) + assertEquals(UiText.of(Res.string.pin_current_wrong), viewModel.uiState.value.error) viewModel.clearError() assertTrue(checkCurrent("222222")) @@ -124,7 +141,7 @@ class SecuritySettingsViewModelTest { assertNull(viewModel.uiState.value.error) assertFalse(activate("444444")) - assertEquals(UiText.of(R.string.pin_update_failed), viewModel.uiState.value.error) + assertEquals(UiText.of(Res.string.pin_update_failed), viewModel.uiState.value.error) } @Test @@ -247,10 +264,10 @@ class SecuritySettingsViewModelTest { @Test fun enablingBiometricsEnrollsAndShowsTheResult() = runTest { viewModel.uiState.first { it.loaded } - answerCallback(true).`when`(security).enrollBiometric(eqValue(activity), anyCallback()) + answerCallback(true).`when`(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) `when`(security.isBiometricEnabled()).thenReturn(true) - viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(screen.host, enable = true) assertTrue(viewModel.uiState.value.biometricEnable) assertNull(viewModel.uiState.value.error) @@ -259,20 +276,20 @@ class SecuritySettingsViewModelTest { @Test fun aFailedEnrollmentLeavesBiometricsOffWithAMessage() = runTest { viewModel.uiState.first { it.loaded } - answerCallback(false).`when`(security).enrollBiometric(eqValue(activity), anyCallback()) + answerCallback(false).`when`(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) - viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(screen.host, enable = true) assertFalse(viewModel.uiState.value.biometricEnable) - assertEquals(UiText.of(R.string.biometric_enroll_failed), viewModel.uiState.value.error) + assertEquals(UiText.of(Res.string.biometric_enroll_failed), viewModel.uiState.value.error) } @Test fun disablingBiometricsNeedsAConfirmedPrompt() = runTest { viewModel.uiState.first { it.loaded } - answerCallback(true).`when`(security).confirmBiometric(eqValue(activity), anyCallback()) + answerCallback(true).`when`(security).confirmBiometric(eqValue(screen.biometric), anyCallback()) - viewModel.toggleBiometric(activity, enable = false) + viewModel.toggleBiometric(screen.host, enable = false) verify(security).disableBiometric() assertFalse(viewModel.uiState.value.biometricEnable) @@ -281,38 +298,38 @@ class SecuritySettingsViewModelTest { @Test fun anUnconfirmedDisableKeepsBiometricsOn() = runTest { viewModel.uiState.first { it.loaded } - answerCallback(false).`when`(security).confirmBiometric(eqValue(activity), anyCallback()) + answerCallback(false).`when`(security).confirmBiometric(eqValue(screen.biometric), anyCallback()) `when`(security.isBiometricEnabled()).thenReturn(true) - viewModel.toggleBiometric(activity, enable = false) + viewModel.toggleBiometric(screen.host, enable = false) verify(security, never()).disableBiometric() assertTrue(viewModel.uiState.value.biometricEnable) - assertEquals(UiText.of(R.string.biometric_change_not_confirmed), viewModel.uiState.value.error) + assertEquals(UiText.of(Res.string.biometric_change_not_confirmed), viewModel.uiState.value.error) } @Test fun tapsWhileTheEnrolPromptIsOpenAreIgnored() = runTest { viewModel.uiState.first { it.loaded } // The prompt never answers: it stays open. - viewModel.toggleBiometric(activity, enable = true) - viewModel.toggleBiometric(activity, enable = true) - viewModel.toggleBiometric(activity, enable = false) + viewModel.toggleBiometric(screen.host, enable = true) + viewModel.toggleBiometric(screen.host, enable = true) + viewModel.toggleBiometric(screen.host, enable = false) assertTrue(viewModel.biometricInFlight) - verify(security, times(1)).enrollBiometric(eqValue(activity), anyCallback()) - verify(security, never()).confirmBiometric(eqValue(activity), anyCallback()) + verify(security, times(1)).enrollBiometric(eqValue(screen.biometric), anyCallback()) + verify(security, never()).confirmBiometric(eqValue(screen.biometric), anyCallback()) } @Test fun tapsWhileTheDisablePromptIsOpenAreIgnored() = runTest { viewModel.uiState.first { it.loaded } - viewModel.toggleBiometric(activity, enable = false) - viewModel.toggleBiometric(activity, enable = false) - viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(screen.host, enable = false) + viewModel.toggleBiometric(screen.host, enable = false) + viewModel.toggleBiometric(screen.host, enable = true) - verify(security, times(1)).confirmBiometric(eqValue(activity), anyCallback()) - verify(security, never()).enrollBiometric(eqValue(activity), anyCallback()) + verify(security, times(1)).confirmBiometric(eqValue(screen.biometric), anyCallback()) + verify(security, never()).enrollBiometric(eqValue(screen.biometric), anyCallback()) verify(security, never()).disableBiometric() } @@ -324,30 +341,30 @@ class SecuritySettingsViewModelTest { @Suppress("UNCHECKED_CAST") answer[0] = invocation.arguments.last() as (Boolean) -> Unit null - }.`when`(security).enrollBiometric(eqValue(activity), anyCallback()) + }.`when`(security).enrollBiometric(eqValue(screen.biometric), anyCallback()) - viewModel.toggleBiometric(activity, enable = true) - viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(screen.host, enable = true) + viewModel.toggleBiometric(screen.host, enable = true) answer[0]!!(false) assertFalse(viewModel.biometricInFlight) - viewModel.toggleBiometric(activity, enable = true) - verify(security, times(2)).enrollBiometric(eqValue(activity), anyCallback()) + viewModel.toggleBiometric(screen.host, enable = true) + verify(security, times(2)).enrollBiometric(eqValue(screen.biometric), anyCallback()) } @Test fun aRotationDuringThePromptDoesNotLeaveTheSwitchStuck() = runTest { viewModel.uiState.first { it.loaded } - viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(screen.host, enable = true) assertTrue(viewModel.biometricInFlight) // androidx.biometric drops the answer of a prompt whose activity is gone. - host.destroy() + screen.destroy() assertFalse(viewModel.biometricInFlight) - val recreated = MockActivity().activity - viewModel.toggleBiometric(recreated, enable = true) - verify(security).enrollBiometric(eqValue(recreated), anyCallback()) + val recreated = TestScreen() + viewModel.toggleBiometric(recreated.host, enable = true) + verify(security).enrollBiometric(eqValue(recreated.biometric), anyCallback()) } private suspend fun checkCurrent(pin: String): Boolean = checkCurrent(pin.toCharArray()) diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SmallViewModelsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SmallViewModelsTest.kt similarity index 95% rename from app/src/test/java/com/pasich/encly/presentation/viewmodel/SmallViewModelsTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SmallViewModelsTest.kt index 8b77e35a..2d1e3b47 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SmallViewModelsTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SmallViewModelsTest.kt @@ -8,6 +8,7 @@ import com.pasich.encly.data.model.Task import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.domain.model.ThemeType +import com.pasich.encly.testutil.FakeAppLocales import com.pasich.encly.testutil.TestSettingsRepository import com.pasich.encly.testutil.TestTagsRepository import com.pasich.encly.testutil.TestTasksRepository @@ -37,6 +38,7 @@ import org.mockito.Mockito.`when` class SmallViewModelsTest { private val dispatcher = StandardTestDispatcher() private val settings = TestSettingsRepository() + private val locales = FakeAppLocales() @Before fun setUp() { @@ -52,7 +54,7 @@ class SmallViewModelsTest { fun settingsSwitchesStartFromDefaultsThenFollowTheStore() = runTest(dispatcher) { settings.showTasksFlow.value = false settings.simpleEditFlow.value = true - val viewModel = SettingsViewModel(settings) + val viewModel = SettingsViewModel(settings, locales) assertTrue("default before the store is read", viewModel.showTasksFlow.value) assertFalse(viewModel.simpleEditFlow.value) @@ -64,7 +66,7 @@ class SmallViewModelsTest { @Test fun settingsSwitchesAreStored() = runTest(dispatcher) { - val viewModel = SettingsViewModel(settings) + val viewModel = SettingsViewModel(settings, locales) viewModel.onEvent(SettingsEvent.UpdateShowTasks(false)) viewModel.onEvent(SettingsEvent.UpdateSimpleEdit(true)) @@ -78,7 +80,7 @@ class SmallViewModelsTest { @Test fun theLanguageDialogOpensAndCloses() { - val viewModel = SettingsViewModel(settings) + val viewModel = SettingsViewModel(settings, locales) viewModel.setLanguageDialogVisibility(true) assertTrue(viewModel.languageDialogVisible.value) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/StorageUnreadableViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/StorageUnreadableViewModelTest.kt new file mode 100644 index 00000000..884f55e8 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/StorageUnreadableViewModelTest.kt @@ -0,0 +1,71 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +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.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +@OptIn(ExperimentalCoroutinesApi::class) +class StorageUnreadableViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun retryingAnUnreadableStartAsksAgainAndNeverWipes() = runTest(dispatcher) { + val security = mock(SecurityManager::class.java) + `when`(security.resolveInitialStatus()) + .thenReturn(InitialStatus.STORAGE_UNREADABLE, InitialStatus.AUTH) + val lock = SessionLockManager(security) + val viewModel = StorageUnreadableViewModel(security, lock) + val reported = mutableListOf() + + viewModel.retry { reported += it } + awaitReports(reported, 1) + + assertEquals(listOf(InitialStatus.STORAGE_UNREADABLE), reported) + assertTrue(viewModel.stillUnreadable.value) + assertFalse(lock.locked.value) + + viewModel.retry { reported += it } + awaitReports(reported, 2) + + assertEquals(InitialStatus.AUTH, reported.last()) + assertFalse(viewModel.stillUnreadable.value) + assertTrue("a committed vault that is not open starts locked", lock.locked.value) + verify(security, never()).wipeAndReset() + } + + /** The retry resolves off the main thread: lets it finish, then runs what it posted to the test main. */ + private fun kotlinx.coroutines.test.TestScope.awaitReports(reported: List, count: Int) { + val deadline = System.nanoTime() + 5_000_000_000L + while (reported.size < count && System.nanoTime() < deadline) { + advanceUntilIdle() + Thread.sleep(10) + } + } +} diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/InMemoryKeyValueStore.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/InMemoryKeyValueStore.kt new file mode 100644 index 00000000..dd0ee136 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/InMemoryKeyValueStore.kt @@ -0,0 +1,55 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.platform.KeyValueStore + +/** + * A [KeyValueStore] for unit tests that keeps its values in a map. [edit] and [commit] apply a batch + * at once, the way the Android adapter does, and [commit] always reports success. + */ +internal class InMemoryKeyValueStore : KeyValueStore { + private val values = mutableMapOf() + + override fun getString(key: String, default: String?): String? = values[key] as? String ?: default + + override fun getBoolean(key: String, default: Boolean): Boolean = values[key] as? Boolean ?: default + + override fun getLong(key: String, default: Long): Long = values[key] as? Long ?: default + + override fun contains(key: String): Boolean = key in values + + override fun edit(block: KeyValueStore.Editor.() -> Unit) { + MapEditor().apply(block).applyTo(values) + } + + override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean { + edit(block) + return true + } + + /** The changes of one batch, in the order they were made. */ + private class MapEditor : KeyValueStore.Editor { + private val steps = mutableListOf<(MutableMap) -> Unit>() + + override fun putString(key: String, value: String) { + steps += { it[key] = value } + } + + override fun putBoolean(key: String, value: Boolean) { + steps += { it[key] = value } + } + + override fun putLong(key: String, value: Long) { + steps += { it[key] = value } + } + + override fun remove(key: String) { + steps += { it.remove(key) } + } + + override fun clear() { + steps += { it.clear() } + } + + fun applyTo(target: MutableMap) = steps.forEach { it(target) } + } +} diff --git a/app/src/test/java/com/pasich/encly/testutil/MockitoCallbacks.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/MockitoCallbacks.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/testutil/MockitoCallbacks.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/MockitoCallbacks.kt diff --git a/app/src/test/java/com/pasich/encly/testutil/MockitoKotlin.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/MockitoKotlin.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/testutil/MockitoKotlin.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/MockitoKotlin.kt diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/SecurityFakes.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/SecurityFakes.kt new file mode 100644 index 00000000..a8b23f0f --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/SecurityFakes.kt @@ -0,0 +1,78 @@ +package com.pasich.encly.testutil + +import com.pasich.encly.core.security.LockoutClock +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.VaultStore +import java.io.File +import java.nio.file.Files +import java.security.SecureRandom +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * A [PinHardwareFactor] with software HMAC keys standing in for the Keystore ones, one per + * [PinKeySlot]. [lost] and [failing] simulate an invalidated key and a transient Keystore error + * (for every slot), [failingSlots] one for some slots only; [failingReset] a key that cannot be + * generated. [delete] removes a key the way the system can, so [ensureKey] then reports a new + * one. [calls] counts MACs, i.e. PIN guesses that reached the "hardware". + */ +internal class FakePinFactor : PinHardwareFactor { + private val keys = mutableMapOf() + var lost = false + var failing = false + var failingReset = false + + /** Slots whose MAC fails like a transient Keystore error, while the others still work. */ + var failingSlots: Set = emptySet() + var calls = 0 + private set + var resets = 0 + private set + + fun hasKey(slot: PinKeySlot): Boolean = slot in keys + + override fun ensureKey(slot: PinKeySlot): Boolean { + val missing = slot !in keys + if (missing) reset(slot) + return missing + } + + override fun reset(slot: PinKeySlot) { + keys.remove(slot) + if (failingReset) throw PinFactorException(lost = false) + keys[slot] = ByteArray(32).also { SecureRandom().nextBytes(it) } + lost = false + resets++ + } + + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { + if (failing || slot in failingSlots) throw PinFactorException(lost = false) + val current = keys[slot] + if (lost || current == null) throw PinFactorException(lost = true) + calls++ + return Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(current, "HmacSHA256")) + doFinal(data) + } + } + + override fun delete(slot: PinKeySlot) { + keys.remove(slot) + } +} + +/** A [LockoutClock] the test moves by hand. */ +internal class FakeLockoutClock(var elapsed: Long = 1_000_000L, var boot: Int = 1) : LockoutClock { + override fun elapsedRealtime(): Long = elapsed + override fun bootCount(): Int = boot +} + +/** A [VaultStore] on a fresh temp file (deleted when the JVM exits). */ +internal fun tempVaultStore(): VaultStore = VaultStore(tempVaultFile()) + +internal fun tempVaultFile(): File { + val dir = Files.createTempDirectory("vault").toFile().apply { deleteOnExit() } + return File(dir, "vault_state.bin").apply { deleteOnExit() } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TestRoutes.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/ui/screens/TestRoutes.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/ui/screens/TestRoutes.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/ui/screens/TestRoutes.kt diff --git a/app/src/test/java/com/pasich/encly/ui/theme/NoteColorsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/ui/theme/NoteColorsTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/ui/theme/NoteColorsTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/ui/theme/NoteColorsTest.kt diff --git a/app/src/test/java/com/pasich/encly/ui/theme/PalettesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/ui/theme/PalettesTest.kt similarity index 100% rename from app/src/test/java/com/pasich/encly/ui/theme/PalettesTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/ui/theme/PalettesTest.kt diff --git a/app/src/test/java/com/pasich/encly/utils/FontSizeUtilsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/utils/FontSizeUtilsTest.kt similarity index 95% rename from app/src/test/java/com/pasich/encly/utils/FontSizeUtilsTest.kt rename to shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/utils/FontSizeUtilsTest.kt index bd225f1f..397cfe75 100644 --- a/app/src/test/java/com/pasich/encly/utils/FontSizeUtilsTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/utils/FontSizeUtilsTest.kt @@ -1,7 +1,7 @@ package com.pasich.encly.utils import androidx.compose.ui.unit.sp -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.model.BlockType import org.junit.Assert.assertEquals import org.junit.Test diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt new file mode 100644 index 00000000..e51c1cd8 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt @@ -0,0 +1,241 @@ +package com.pasich.encly.whatsnew + +import com.pasich.encly.data.model.Note +import com.pasich.encly.domain.usecase.note.CleanTrashNotesUseCase +import com.pasich.encly.domain.usecase.note.PurgeExpiredTrashUseCase +import com.pasich.encly.domain.usecase.note.TRASH_RETENTION_MILLIS +import com.pasich.encly.platform.AppInfo +import com.pasich.encly.presentation.viewmodel.StartupViewModel +import com.pasich.encly.testutil.InMemoryKeyValueStore +import com.pasich.encly.testutil.TestNotesRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.util.Properties + +/** "What's new" after an update: shown once per version, never on a fresh install, in the app's language. */ +@OptIn(ExperimentalCoroutinesApi::class) +class WhatsNewTest { + private val flags = InMemoryKeyValueStore() + + private fun app(version: String) = object : AppInfo { + override val appName = "Encly" + override val versionName = version + override val donationsEnabled = false + override val storeRatingEnabled = false + override val storePageLinks = emptyList() + } + + private fun tracker(version: String) = WhatsNewTracker(flags, app(version)) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun anUpdateIsDueOnceAndThenNot() { + tracker("2.3.0").recordFreshInstall() + + assertTrue(tracker("2.4.0").takeUnseen()) + assertFalse(tracker("2.4.0").takeUnseen()) + assertTrue(tracker("2.5.0").takeUnseen()) + } + + @Test + fun aFreshInstallStartsOnThisVersionAndIsNeverDue() { + tracker("2.4.0").recordFreshInstall() + + assertFalse(tracker("2.4.0").takeUnseen()) + assertFalse(tracker("2.4.0").takeUnseen()) + } + + @Test + fun recordingAFreshInstallKeepsAVersionAlreadyRecorded() { + tracker("2.3.0").recordFreshInstall() + tracker("2.4.0").recordFreshInstall() + + assertTrue("the older recorded version still counts as the one before", tracker("2.4.0").takeUnseen()) + } + + @Test + fun anInstallFromBeforeTheFeatureHasNothingRecordedAndIsShownTheUpdate() { + assertTrue(tracker("2.4.0").takeUnseen()) + assertFalse(tracker("2.4.0").takeUnseen()) + } + + @Test + fun aDowngradeIsNotAnUpdateButIsRemembered() { + tracker("2.4.0").recordFreshInstall() + + assertFalse(tracker("2.3.0").takeUnseen()) + assertTrue("the way back up is an update again", tracker("2.4.0").takeUnseen()) + } + + @Test + fun aBuildSuffixIsNotAnotherVersionNumber() { + tracker("2.4.0").recordFreshInstall() + + assertFalse(tracker("2.4.0-debug").takeUnseen()) + assertTrue(tracker("2.4.1-debug").takeUnseen()) + } + + @Test + fun aVersionNameThatIsNotANumberCountsAsNew() { + tracker("dev").recordFreshInstall() + + assertTrue(tracker("2.4.0").takeUnseen()) + } + + @Test + fun everyAppLanguageHasItsStoreFolder() { + val folders = mapOf( + "en" to "en-US", + "uk" to "uk", + "de" to "de-DE", + "fr" to "fr-FR", + "es" to "es-ES", + "it" to "it-IT", + "pl" to "pl-PL", + "pt" to "pt-PT", + "nl" to "nl-NL", + "ar" to "ar", + ) + folders.forEach { (tag, folder) -> assertEquals(tag, folder, Changelogs.folderFor(tag)) } + assertEquals("pt-PT", Changelogs.folderFor("pt-BR")) + assertEquals("uk", Changelogs.folderFor("uk_UA")) + } + + @Test + fun anUnsupportedOrMissingLanguageReadsEnglish() { + assertEquals("en-US", Changelogs.folderFor("ja-JP")) + assertEquals("en-US", Changelogs.folderFor("he")) + assertEquals("en-US", Changelogs.folderFor(null)) + assertEquals("en-US", Changelogs.folderFor("")) + } + + @Test + fun theNotesAreInTheAppsLanguageElseEnglish() { + val bundled = mapOf( + "en-US" to "Encly 2.4.0\n• English change", + "de-DE" to "Encly 2.4.0\n• Deutsche Änderung", + ) + + assertEquals("• Deutsche Änderung", Changelogs.notes("de", bundled)?.body) + // French has no file for this version, Japanese is not an app language: both read English. + assertEquals("• English change", Changelogs.notes("fr", bundled)?.body) + assertEquals("• English change", Changelogs.notes("ja", bundled)?.body) + assertEquals("Encly 2.4.0", Changelogs.notes("de", bundled)?.heading) + } + + @Test + fun noNotesAtAllMeansNothingToShow() { + assertNull(Changelogs.notes("en", emptyMap())) + assertNull(Changelogs.notes("en", mapOf("en-US" to " \n "))) + assertEquals("", Changelogs.notes("en", mapOf("en-US" to "Encly 2.4.0"))?.body) + } + + @Test + fun theBundledNotesAreTheStoreChangelogOfThisVersion() { + val root = listOf(File(".."), File(".")).first { File(it, "version.properties").isFile } + val version = Properties().apply { File(root, "version.properties").inputStream().use { load(it) } } + fun part(name: String) = version.getProperty(name).trim().toInt() + val code = part("VERSION_MAJOR") * 10_000 + part("VERSION_MINOR") * 100 + part("VERSION_PATCH") + assertEquals(code, BundledChangelogs.VERSION_CODE) + + val expected = File(root, "fastlane/metadata/android").listFiles().orEmpty() + .map { File(it, "changelogs/$code.txt") } + .filter { it.isFile } + .associate { it.parentFile.parentFile.name to it.readText().trim() } + assertEquals(expected, BundledChangelogs.notes) + // Every language of the app has its notes for the version, since the store has them. + assertEquals(10, BundledChangelogs.notes.size) + assertNotNull(Changelogs.current("uk")) + assertTrue(BundledChangelogs.notes.getValue("uk").startsWith(Changelogs.current("uk")!!.heading)) + } + + private fun startup(version: String, repository: TestNotesRepository = TestNotesRepository()) = StartupViewModel( + tracker(version), + PurgeExpiredTrashUseCase(repository), + ) + + @Test + fun theUpdateSheetIsDueOnceAfterTheUnlockAndGoesWhenDismissed() { + startup("2.3.0").onAppStart(freshInstall = true) + + val after = startup("2.4.0") + assertNull("nothing before the vault is unlocked", after.whatsNewDue.value) + after.onUnlocked("en", bundled = mapOf("en-US" to "Encly 2.4.0\n• A change")) + assertEquals("• A change", after.whatsNewDue.value?.body) + + after.dismissWhatsNew() + assertNull(after.whatsNewDue.value) + // The next unlock of the same version, here a fresh ViewModel, has nothing to show. + val again = startup("2.4.0") + again.onUnlocked("en", bundled = mapOf("en-US" to "Encly 2.4.0\n• A change")) + assertNull(again.whatsNewDue.value) + } + + @Test + fun aFreshInstallIsShownNothingAfterItsFirstUnlock() { + val viewModel = startup("2.4.0") + viewModel.onAppStart(freshInstall = true) + + viewModel.onUnlocked("en", bundled = mapOf("en-US" to "Encly 2.4.0\n• A change")) + + assertNull(viewModel.whatsNewDue.value) + } + + @Test + fun aRestartThatIsNotAFreshInstallDoesNotHideTheUpdate() { + startup("2.3.0").onAppStart(freshInstall = true) + + val viewModel = startup("2.4.0") + viewModel.onAppStart(freshInstall = false) + viewModel.onUnlocked("en", bundled = mapOf("en-US" to "Encly 2.4.0\n• A change")) + + assertNotNull(viewModel.whatsNewDue.value) + } + + @Test + fun aVersionWithoutNotesShowsNoSheet() { + startup("2.3.0").onAppStart(freshInstall = true) + val viewModel = startup("2.4.0") + + viewModel.onUnlocked("en", bundled = emptyMap()) + + assertNull(viewModel.whatsNewDue.value) + } + + @Test + fun unlockingCleansTheTrashOfExpiredNotesOnce() { + val repository = TestNotesRepository() + val now = System.currentTimeMillis() + repository.trashNotes.value = listOf( + Note(id = 1, isTrash = true, trashedAt = now - TRASH_RETENTION_MILLIS - 60_000), + Note(id = 2, isTrash = true, trashedAt = now), + ) + val viewModel = startup("2.4.0", repository) + + viewModel.onUnlocked("en", bundled = emptyMap()) + viewModel.onUnlocked("en", bundled = emptyMap()) + + assertEquals(listOf(1L), repository.deletedIds) + } +} diff --git a/app/src/test/resources/import-fixtures/joplin/README.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/README.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/README.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/README.md diff --git a/app/src/test/resources/import-fixtures/joplin/md/Personal/Groceries.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Personal/Groceries.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/md/Personal/Groceries.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Personal/Groceries.md 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/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" similarity index 100% rename from "app/src/test/resources/import-fixtures/joplin/md/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" rename to "shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" diff --git a/app/src/test/resources/import-fixtures/joplin/md/Work/Renew passport.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Work/Call the bank.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/md/Work/Renew passport.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Work/Call the bank.md diff --git a/app/src/test/resources/import-fixtures/joplin/md/Work/Meeting notes.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Work/Meeting notes.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/md/Work/Meeting notes.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Work/Meeting notes.md diff --git a/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md/Work/Renew passport.md b/shared/src/jvmAndAndroidTest/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/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Personal/Groceries.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/Groceries.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Personal/Groceries.md 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/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" similarity index 100% rename from "app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" rename to "shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" diff --git a/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Call the bank.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Work/Call the bank.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Call the bank.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Work/Call the bank.md diff --git a/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Meeting notes.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Work/Meeting notes.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Meeting notes.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Work/Meeting notes.md diff --git a/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Renew passport.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Work/Renew passport.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Renew passport.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/md_frontmatter/Work/Renew passport.md diff --git a/app/src/test/resources/import-fixtures/joplin/notes.jex b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/notes.jex similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/notes.jex rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/notes.jex diff --git a/app/src/test/resources/import-fixtures/joplin/raw/05e7fc905c484b89bd8d6aae13073c8f.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/05e7fc905c484b89bd8d6aae13073c8f.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/05e7fc905c484b89bd8d6aae13073c8f.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/05e7fc905c484b89bd8d6aae13073c8f.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/0b9a0c4713e54184a552af48e6b13460.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/0b9a0c4713e54184a552af48e6b13460.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/0b9a0c4713e54184a552af48e6b13460.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/0b9a0c4713e54184a552af48e6b13460.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/3d060bc9f9484e49b05a9f006cf362ab.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/3d060bc9f9484e49b05a9f006cf362ab.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/3d060bc9f9484e49b05a9f006cf362ab.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/3d060bc9f9484e49b05a9f006cf362ab.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/6ca5bdfec28c489c9d18a75584c72238.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/6ca5bdfec28c489c9d18a75584c72238.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/6ca5bdfec28c489c9d18a75584c72238.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/6ca5bdfec28c489c9d18a75584c72238.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/70e99e28284f447fb84681d796a98d2f.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/70e99e28284f447fb84681d796a98d2f.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/70e99e28284f447fb84681d796a98d2f.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/70e99e28284f447fb84681d796a98d2f.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/a0361945271f4eb5b2400c5e5ce4545a.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/a0361945271f4eb5b2400c5e5ce4545a.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/a0361945271f4eb5b2400c5e5ce4545a.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/a0361945271f4eb5b2400c5e5ce4545a.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/a3afd784a6344c2f90026d22a05adb76.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/a3afd784a6344c2f90026d22a05adb76.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/a3afd784a6344c2f90026d22a05adb76.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/a3afd784a6344c2f90026d22a05adb76.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/b3eaf8f8050a42178bb80c2e53e8fa34.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/b3eaf8f8050a42178bb80c2e53e8fa34.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/b3eaf8f8050a42178bb80c2e53e8fa34.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/b3eaf8f8050a42178bb80c2e53e8fa34.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/ba98e539389140aa90f4cd70fb226964.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/ba98e539389140aa90f4cd70fb226964.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/ba98e539389140aa90f4cd70fb226964.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/ba98e539389140aa90f4cd70fb226964.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/c82ee80c86ff479c98f35a4e341e2164.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/c82ee80c86ff479c98f35a4e341e2164.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/c82ee80c86ff479c98f35a4e341e2164.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/c82ee80c86ff479c98f35a4e341e2164.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/fa94f84f272643cfa3a74deb510ff642.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/fa94f84f272643cfa3a74deb510ff642.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/fa94f84f272643cfa3a74deb510ff642.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/fa94f84f272643cfa3a74deb510ff642.md diff --git a/app/src/test/resources/import-fixtures/joplin/raw/fe3af29373694b86b45af6ae9b2c6516.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/fe3af29373694b86b45af6ae9b2c6516.md similarity index 100% rename from app/src/test/resources/import-fixtures/joplin/raw/fe3af29373694b86b45af6ae9b2c6516.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/joplin/raw/fe3af29373694b86b45af6ae9b2c6516.md diff --git a/app/src/test/resources/import-fixtures/keep/README.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/keep/README.md similarity index 100% rename from app/src/test/resources/import-fixtures/keep/README.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/keep/README.md diff --git a/app/src/test/resources/import-fixtures/keep/takeout-keep.zip b/shared/src/jvmAndAndroidTest/resources/import-fixtures/keep/takeout-keep.zip similarity index 100% rename from app/src/test/resources/import-fixtures/keep/takeout-keep.zip rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/keep/takeout-keep.zip diff --git a/app/src/test/resources/import-fixtures/markdown/README.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/README.md similarity index 100% rename from app/src/test/resources/import-fixtures/markdown/README.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/README.md diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/app.json b/shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/.obsidian/app.json similarity index 100% rename from app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/app.json rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/.obsidian/app.json diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/workspace.json b/shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/.obsidian/workspace.json similarity index 100% rename from app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/workspace.json rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/.obsidian/workspace.json diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Attachments/image.png b/shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/Attachments/image.png similarity index 100% rename from app/src/test/resources/import-fixtures/markdown/obsidian-vault/Attachments/image.png rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/Attachments/image.png diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Groceries.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/Groceries.md similarity index 100% rename from app/src/test/resources/import-fixtures/markdown/obsidian-vault/Groceries.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/Groceries.md diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Markor note.txt b/shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/Markor note.txt similarity index 100% rename from app/src/test/resources/import-fixtures/markdown/obsidian-vault/Markor note.txt rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/Markor note.txt diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Work/Meeting notes.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/Work/Meeting notes.md similarity index 100% rename from app/src/test/resources/import-fixtures/markdown/obsidian-vault/Work/Meeting notes.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/Work/Meeting notes.md 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/shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/\320\206\320\264\320\265\321\227 \360\237\222\241.md" similarity index 100% rename from "app/src/test/resources/import-fixtures/markdown/obsidian-vault/\320\206\320\264\320\265\321\227 \360\237\222\241.md" rename to "shared/src/jvmAndAndroidTest/resources/import-fixtures/markdown/obsidian-vault/\320\206\320\264\320\265\321\227 \360\237\222\241.md" diff --git a/app/src/test/resources/import-fixtures/notesnook/README.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/README.md similarity index 100% rename from app/src/test/resources/import-fixtures/notesnook/README.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/README.md diff --git a/app/src/test/resources/import-fixtures/notesnook/backup.nnbackupz b/shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/backup.nnbackupz similarity index 100% rename from app/src/test/resources/import-fixtures/notesnook/backup.nnbackupz rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/backup.nnbackupz diff --git a/app/src/test/resources/import-fixtures/notesnook/notes-md-frontmatter.zip b/shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/notes-md-frontmatter.zip similarity index 100% rename from app/src/test/resources/import-fixtures/notesnook/notes-md-frontmatter.zip rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/notes-md-frontmatter.zip diff --git a/app/src/test/resources/import-fixtures/notesnook/notes-md.zip b/shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/notes-md.zip similarity index 100% rename from app/src/test/resources/import-fixtures/notesnook/notes-md.zip rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/notes-md.zip diff --git a/app/src/test/resources/import-fixtures/notesnook/notes-txt.zip b/shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/notes-txt.zip similarity index 100% rename from app/src/test/resources/import-fixtures/notesnook/notes-txt.zip rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/notesnook/notes-txt.zip diff --git a/app/src/test/resources/import-fixtures/standard-notes/README.md b/shared/src/jvmAndAndroidTest/resources/import-fixtures/standard-notes/README.md similarity index 100% rename from app/src/test/resources/import-fixtures/standard-notes/README.md rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/standard-notes/README.md diff --git a/app/src/test/resources/import-fixtures/standard-notes/Standard Notes Backup - decrypted.zip b/shared/src/jvmAndAndroidTest/resources/import-fixtures/standard-notes/Standard Notes Backup - decrypted.zip similarity index 100% rename from app/src/test/resources/import-fixtures/standard-notes/Standard Notes Backup - decrypted.zip rename to shared/src/jvmAndAndroidTest/resources/import-fixtures/standard-notes/Standard Notes Backup - decrypted.zip diff --git a/version.properties b/version.properties index 29be6532..575cf048 100644 --- a/version.properties +++ b/version.properties @@ -6,6 +6,6 @@ # # Release tags must be "v" + versionName (e.g. v2.0.0); .github/workflows/release.yml # refuses a tag that does not match this file. See CONTRIBUTING.md > Releasing. -VERSION_MAJOR=2 -VERSION_MINOR=3 -VERSION_PATCH=1 +VERSION_MAJOR=3 +VERSION_MINOR=0 +VERSION_PATCH=0